Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions content/guides/de/anythingmcp-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@ $ amcp api GET "/api/audit/invocations?status=ERROR&limit=3" \

`GET /api/audit/breakdowns?days=30` liefert Nutzung und Kosten nach Connector, MCP-Server und Benutzer aufgeschlüsselt. Das passt gut für einen Monatsbericht.

### Rechnungen per E-Mail erhalten

In AnythingMCP Cloud kann ein Admin jede bezahlte Rechnung als PDF per E-Mail verschicken lassen, zum Beispiel an die Buchhaltung:

```bash
amcp invoices email on --to buchhaltung@acme.de --yes
amcp invoices email
# Invoices by email: on
# Recipient: buchhaltung@acme.de (another address)
```

Mit `--billing-contact` gehen sie wieder an die Rechnungs-E-Mail des Abonnements, mit `off` werden sie nicht mehr verschickt. Details: [Invoices on AnythingMCP Cloud](https://github.com/HelpCode-ai/anythingmcp/blob/main/docs/cloud-invoices.md).

### Die ganze API offline entdecken

Sie brauchen die Doku nicht geöffnet zu haben. Der Katalog der Management-Operationen wird mit der CLI ausgeliefert:
Expand Down
13 changes: 13 additions & 0 deletions content/guides/en/anythingmcp-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@ $ amcp api GET "/api/audit/invocations?status=ERROR&limit=3" \

`GET /api/audit/breakdowns?days=30` returns usage and cost broken down by connector, MCP server and user. It's a good fit for a monthly report.

### Get your invoices by email

On AnythingMCP Cloud, an admin can have every paid invoice emailed as a PDF, for example to the accounting team:

```bash
amcp invoices email on --to accounting@acme.io --yes
amcp invoices email
# Invoices by email: on
# Recipient: accounting@acme.io (another address)
```

`--billing-contact` sends them to the subscription's billing email again; `off` stops them. Details: [Invoices on AnythingMCP Cloud](https://github.com/HelpCode-ai/anythingmcp/blob/main/docs/cloud-invoices.md).

### Discover the whole API offline

You don't need the docs open. The catalogue of management operations ships with the CLI:
Expand Down
13 changes: 13 additions & 0 deletions content/guides/it/anythingmcp-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@ $ amcp api GET "/api/audit/invocations?status=ERROR&limit=3" \

`GET /api/audit/breakdowns?days=30` restituisce utilizzo e costi suddivisi per connettore, server MCP e utente. È adatto per un report mensile.

### Ricevi le fatture via email

Su AnythingMCP Cloud un admin può farsi inviare ogni fattura pagata in PDF via email, per esempio alla contabilità:

```bash
amcp invoices email on --to contabilita@acme.it --yes
amcp invoices email
# Invoices by email: on
# Recipient: contabilita@acme.it (another address)
```

Con `--billing-contact` tornano all'email di fatturazione dell'abbonamento, con `off` non vengono più inviate. Dettagli: [Invoices on AnythingMCP Cloud](https://github.com/HelpCode-ai/anythingmcp/blob/main/docs/cloud-invoices.md).

### Scopri tutta l'API offline

Non serve avere la documentazione aperta. Il catalogo delle operazioni di gestione è incluso nella CLI:
Expand Down
50 changes: 50 additions & 0 deletions docs/cloud-invoices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Invoices on AnythingMCP Cloud

This page is about the invoices for your AnythingMCP Cloud subscription. It does
not apply to self-hosted installations: self-hosted licences are managed on
anythingmcp.com/account.

## Where to find your invoices

Open **Settings → License** and select **Manage subscription & billing**. The
billing portal lists every invoice of the workspace, each with a PDF download.
You can update your company name, address and VAT ID there too; changes apply
to the next invoice.

Only workspace admins see this page.

## Invoices by email

On **Settings → License**, switch on **Invoices by email** to get every paid
invoice as a PDF attachment. It is off until an admin switches it on.

- **When:** right after each payment that has an amount. The €0 invoice that
opens a card trial is not sent.
- **To whom:** the billing contact (the email address of the subscription in
the billing portal), or another address you enter, for example your
accounting team.
- **Changing the address:** the new address applies to the next invoice. The
previous address gets a short notice saying invoices now go elsewhere.
- **What the email contains:** the invoice number, amount, period and the PDF.
The link in the email opens Settings → License, which needs a sign-in.

Each invoice goes only to the address set for the workspace it belongs to.
When a licence key moves to another workspace, invoices by email are off for it
until an admin of the new workspace switches them on.

## From the command line

With a management key of a workspace admin (`amcp login --browser --scope admin`):

```bash
amcp invoices email # show the setting
amcp invoices email on --yes # to the billing contact
amcp invoices email on --to accounting@example.com --yes
amcp invoices email --billing-contact --yes # back to the billing contact
amcp invoices email off --yes
```

The same through the API: `GET /api/license/invoice-email` and
`PUT /api/license/invoice-email` with `{"enabled": true, "to": "accounting@example.com"}`
(`"to": null` for the billing contact; leave `to` out to keep the current address).
Both need an admin; a read-only management key can only read.
57 changes: 57 additions & 0 deletions llms-full.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions llms.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/backend/src/audit/security-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export const SecurityEvents = {
// ── Licence plane ───────────────────────────────────────────────────────
/** Cloud: the workspace's trial was extended by 7 days from the email link. */
TRIAL_EXTENDED: 'TRIAL_EXTENDED',
/** Cloud: an admin switched invoices by email on or off, or changed their address (recipient domain only). */
INVOICE_EMAIL_CHANGED: 'INVOICE_EMAIL_CHANGED',
} as const;

export type SecurityEventName =
Expand Down
146 changes: 146 additions & 0 deletions packages/backend/src/license/invoice-email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { PrismaService } from '../common/prisma.service';
import { DeploymentService } from '../common/deployment.service';
import { LICENSE_API_URL, LicenseService, licenseServiceHeaders } from './license.service';

/**
* Invoices by email (Cloud): a workspace admin has every paid invoice emailed
* as a PDF, to the billing contact or to another address. The licence site
* owns Stripe, stores the setting on the workspace's licence and sends the
* emails (its lib/invoice-email.ts); this server only relays the admin's
* choice, with its service token.
*
* Tenant isolation on this side: the workspace is the caller's own
* (`organizationId` from the session, never from the request) and so is the
* licence key, resolved here exactly as for the billing portal. The site
* checks again that the key is bound to that workspace and answers 404
* otherwise.
*/

export interface InvoiceEmailView {
/** False when the workspace has no subscription to send invoices for. */
available: boolean;
enabled: boolean;
/** The Stripe customer's email: where invoices go unless another address is set. */
billingContact: string | null;
recipient: { kind: 'billing' | 'custom'; address: string | null };
/** The last invoice emailed; `to` is masked by the site. */
lastSent: { number: string; at: string; to: string } | null;
}

/** A refusal with the HTTP status the controller should answer. */
export class InvoiceEmailError extends Error {
constructor(
readonly status: 400 | 404 | 409 | 429 | 502,
message: string,
) {
super(message);
}
}

const UNAVAILABLE: InvoiceEmailView = {
available: false,
enabled: false,
billingContact: null,
recipient: { kind: 'billing', address: null },
lastSent: null,
};

/** The site's view, checked field by field: nothing else reaches the browser. */
export function parseInvoiceEmailView(data: unknown): InvoiceEmailView | null {
if (!data || typeof data !== 'object') return null;
const d = data as Record<string, any>;
if (typeof d.available !== 'boolean' || typeof d.enabled !== 'boolean') return null;
const str = (v: unknown, max = 254) => (typeof v === 'string' && v.length <= max ? v : null);
const kind = d.recipient?.kind === 'custom' ? 'custom' : 'billing';
const last = d.lastSent && typeof d.lastSent === 'object' ? d.lastSent : null;
const lastSent =
last && str(last.number, 64) && str(last.at, 64) && !Number.isNaN(Date.parse(last.at)) && str(last.to)
? { number: last.number as string, at: last.at as string, to: last.to as string }
: null;
return {
available: d.available,
enabled: d.enabled,
billingContact: str(d.billingContact),
recipient: { kind, address: str(d.recipient?.address) },
lastSent,
};
}

@Injectable()
export class InvoiceEmailService {
private readonly logger = new Logger(InvoiceEmailService.name);
private readonly apiBase = LICENSE_API_URL;

constructor(
private readonly licenses: LicenseService,
private readonly prisma: PrismaService,
private readonly deployment: DeploymentService,
) {}

/** The licence that carries the workspace's subscription, as for the billing portal. */
private async licenseKeyOf(organizationId: string): Promise<string | null> {
const current = await this.licenses.getCurrentLicense(organizationId);
const license =
current && current.plan !== 'trial'
? current
: ((await this.licenses.getPaymentFailedLicense(organizationId)) ?? current);
return license?.licenseKey ?? null;
}

private async call(body: Record<string, unknown>): Promise<InvoiceEmailView> {
const headers = licenseServiceHeaders();
if (!this.deployment.isCloud() || !headers['x-amcp-service-token']) {
throw new InvoiceEmailError(502, 'Invoices by email are unavailable right now.');
}
try {
const { data } = await axios.post(`${this.apiBase}/api/license/invoice-email`, body, { timeout: 15000, headers });
const view = parseInvoiceEmailView(data);
if (!view) throw new InvoiceEmailError(502, 'Invoices by email are unavailable right now.');
return view;
} catch (err: any) {
if (err instanceof InvoiceEmailError) throw err;
const status = err?.response?.status;
this.logger.warn(`Invoice email settings (${body.action}) for workspace ${body.organizationId}: ${status ?? 'no response'}`);
// The site's own wording stays on the site, except for a refused address.
if (status === 400) throw new InvoiceEmailError(400, 'Invalid email address.');
if (status === 404) throw new InvoiceEmailError(404, 'No subscription found for this workspace.');
if (status === 409) throw new InvoiceEmailError(409, 'This workspace has no subscription to send invoices for.');
if (status === 429) throw new InvoiceEmailError(429, 'Too many changes. Try again in a minute.');
throw new InvoiceEmailError(502, 'Invoices by email are unavailable right now.');
}
}

async get(organizationId: string): Promise<InvoiceEmailView> {
const licenseKey = await this.licenseKeyOf(organizationId);
if (!licenseKey) return UNAVAILABLE;
return this.call({ action: 'get', licenseKey, organizationId });
}

/**
* `to`: undefined keeps the current address, null (or '') goes back to the
* billing contact, a string sets that address.
*/
async set(
organizationId: string,
userId: string,
change: { enabled: boolean; to?: string | null },
): Promise<InvoiceEmailView> {
const licenseKey = await this.licenseKeyOf(organizationId);
if (!licenseKey) throw new InvoiceEmailError(409, 'This workspace has no subscription to send invoices for.');
const [org, actor] = await Promise.all([
this.prisma.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }),
]);
return this.call({
action: 'set',
licenseKey,
organizationId,
enabled: change.enabled,
...(change.to !== undefined && { to: change.to === '' ? null : change.to }),
...(org?.name && { workspaceName: org.name }),
...(actor?.email && { actorEmail: actor.email }),
});
}
}
Loading
Loading