diff --git a/content/guides/de/anythingmcp-cli.mdx b/content/guides/de/anythingmcp-cli.mdx index 65e8a6dca..018b8cf6d 100644 --- a/content/guides/de/anythingmcp-cli.mdx +++ b/content/guides/de/anythingmcp-cli.mdx @@ -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: diff --git a/content/guides/en/anythingmcp-cli.mdx b/content/guides/en/anythingmcp-cli.mdx index 7c7aabcdb..098517972 100644 --- a/content/guides/en/anythingmcp-cli.mdx +++ b/content/guides/en/anythingmcp-cli.mdx @@ -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: diff --git a/content/guides/it/anythingmcp-cli.mdx b/content/guides/it/anythingmcp-cli.mdx index e9801a0ee..a511144f7 100644 --- a/content/guides/it/anythingmcp-cli.mdx +++ b/content/guides/it/anythingmcp-cli.mdx @@ -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: diff --git a/docs/cloud-invoices.md b/docs/cloud-invoices.md new file mode 100644 index 000000000..0c26bcd4f --- /dev/null +++ b/docs/cloud-invoices.md @@ -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. diff --git a/llms-full.txt b/llms-full.txt index d7b6e57e9..7e3d07fba 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -7122,6 +7122,63 @@ You do not need one to use AnythingMCP at work: Community is free for commercial --- +## docs/cloud-invoices.md + +Source: https://raw.githubusercontent.com/HelpCode-ai/anythingmcp/main/docs/cloud-invoices.md + +# 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. + +--- + ## docs/api-reference.md Source: https://raw.githubusercontent.com/HelpCode-ai/anythingmcp/main/docs/api-reference.md diff --git a/llms.txt b/llms.txt index da4b83fa6..859c36e66 100644 --- a/llms.txt +++ b/llms.txt @@ -44,6 +44,7 @@ - [SCIM provisioning with Microsoft Entra ID — step by step](https://raw.githubusercontent.com/HelpCode-ai/anythingmcp/main/docs/scim-entra-setup.md): Click-by-click setup for automatic user and group provisioning from Entra ID into AnythingMCP. - [Code mode](https://raw.githubusercontent.com/HelpCode-ai/anythingmcp/main/docs/code-mode.md): Run isolated JavaScript that orchestrates several MCP tools in one step: saved code tools, inline code_execute, governance and deployment. - [License FAQ](https://raw.githubusercontent.com/HelpCode-ai/anythingmcp/main/docs/license-faq.md): Understand the AGPL license, the commercial EE boundary, and contributor licensing. +- [Invoices on AnythingMCP Cloud](https://raw.githubusercontent.com/HelpCode-ai/anythingmcp/main/docs/cloud-invoices.md): Find your AnythingMCP Cloud invoices and have each paid invoice emailed as a PDF, from the dashboard or the CLI. ## Reference diff --git a/packages/backend/src/audit/security-event.service.ts b/packages/backend/src/audit/security-event.service.ts index 25d97f8ec..53a71a302 100644 --- a/packages/backend/src/audit/security-event.service.ts +++ b/packages/backend/src/audit/security-event.service.ts @@ -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 = diff --git a/packages/backend/src/license/invoice-email.service.ts b/packages/backend/src/license/invoice-email.service.ts new file mode 100644 index 000000000..3ce701ce4 --- /dev/null +++ b/packages/backend/src/license/invoice-email.service.ts @@ -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; + 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 { + 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): Promise { + 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 { + 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 { + 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 }), + }); + } +} diff --git a/packages/backend/src/license/invoice-email.spec.ts b/packages/backend/src/license/invoice-email.spec.ts new file mode 100644 index 000000000..8bb556278 --- /dev/null +++ b/packages/backend/src/license/invoice-email.spec.ts @@ -0,0 +1,202 @@ +import 'reflect-metadata'; +import axios from 'axios'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { BadGatewayException, ConflictException, NotFoundException } from '@nestjs/common'; +import { InvoiceEmailService, InvoiceEmailError, parseInvoiceEmailView } from './invoice-email.service'; +import { InvoiceEmailDto, LicenseController } from './license.controller'; +import { ROLES_KEY } from '../auth/roles.guard'; +import { SecurityEvents } from '../audit/security-event.service'; + +jest.mock('axios'); +const post = axios.post as jest.Mock; + +const VIEW = { + available: true, + enabled: true, + billingContact: 'billing@acme-gmbh.de', + recipient: { kind: 'custom', address: 'accounting@acme-gmbh.de' }, + lastSent: { number: 'VCOAA7SE-1402', at: '2026-11-09T10:00:00.000Z', to: 'ac***@acme-gmbh.de' }, +}; + +function makeService(opts: { cloud?: boolean; current?: any; failed?: any } = {}) { + const licenses = { + getCurrentLicense: jest.fn(async () => ('current' in opts ? opts.current : { licenseKey: 'AMCP-OWN-KEY', plan: 'starter' })), + getPaymentFailedLicense: jest.fn(async () => opts.failed ?? null), + }; + const prisma = { + organization: { findUnique: jest.fn(async () => ({ name: 'Acme GmbH' })) }, + user: { findUnique: jest.fn(async () => ({ email: 'admin@acme-gmbh.de' })) }, + }; + const service = new InvoiceEmailService(licenses as any, prisma as any, { isCloud: () => opts.cloud ?? true } as any); + return { service, licenses, prisma }; +} + +describe('InvoiceEmailService', () => { + const ENV = { ...process.env }; + beforeEach(() => { + post.mockReset(); + process.env.LICENSE_SERVICE_TOKEN = 'svc-token'; + }); + afterAll(() => { + process.env = ENV; + }); + + it("asks the site with the caller's own workspace and licence key, never anything from the request", async () => { + post.mockResolvedValue({ data: VIEW }); + const { service, licenses } = makeService(); + await expect(service.get('org-own')).resolves.toEqual(VIEW); + expect(licenses.getCurrentLicense).toHaveBeenCalledWith('org-own'); + const [url, body, config] = post.mock.calls[0]; + expect(url).toMatch(/\/api\/license\/invoice-email$/); + expect(body).toEqual({ action: 'get', licenseKey: 'AMCP-OWN-KEY', organizationId: 'org-own' }); + expect(config.headers).toEqual({ 'x-amcp-service-token': 'svc-token' }); + }); + + it('sends the change with the workspace name and the acting admin', async () => { + post.mockResolvedValue({ data: VIEW }); + const { service } = makeService(); + await service.set('org-own', 'user-1', { enabled: true, to: 'accounting@acme-gmbh.de' }); + expect(post.mock.calls[0][1]).toEqual({ + action: 'set', + licenseKey: 'AMCP-OWN-KEY', + organizationId: 'org-own', + enabled: true, + to: 'accounting@acme-gmbh.de', + workspaceName: 'Acme GmbH', + actorEmail: 'admin@acme-gmbh.de', + }); + post.mockClear(); + await service.set('org-own', 'user-1', { enabled: false }); + expect(post.mock.calls[0][1]).not.toHaveProperty('to'); + post.mockClear(); + await service.set('org-own', 'user-1', { enabled: true, to: '' }); + expect(post.mock.calls[0][1].to).toBeNull(); + }); + + it('uses the subscription stopped on a failed payment when no paid licence is active', async () => { + post.mockResolvedValue({ data: VIEW }); + const { service } = makeService({ current: { licenseKey: 'AMCP-TRIAL', plan: 'trial' }, failed: { licenseKey: 'AMCP-FAILED', plan: 'starter' } }); + await service.get('org-own'); + expect(post.mock.calls[0][1].licenseKey).toBe('AMCP-FAILED'); + }); + + it('answers unavailable without a licence, and refuses a change', async () => { + const { service } = makeService({ current: null }); + await expect(service.get('org-own')).resolves.toMatchObject({ available: false, enabled: false }); + await expect(service.set('org-own', 'u', { enabled: true })).rejects.toMatchObject({ status: 409 }); + expect(post).not.toHaveBeenCalled(); + }); + + it("maps the site's refusals without passing its wording on", async () => { + const cases: Array<[number, number]> = [ + [400, 400], + [404, 404], + [409, 409], + [429, 429], + [500, 502], + ]; + for (const [site, ours] of cases) { + post.mockRejectedValueOnce(Object.assign(new Error('x'), { response: { status: site, data: { error: 'internal detail' } } })); + const { service } = makeService(); + const err = await service.get('org-own').catch((e) => e); + expect(err).toBeInstanceOf(InvoiceEmailError); + expect(err.status).toBe(ours); + expect(err.message).not.toContain('internal detail'); + } + }); + + it('never calls off Cloud or without a service token', async () => { + await expect(makeService({ cloud: false }).service.get('org-own')).rejects.toMatchObject({ status: 502 }); + delete process.env.LICENSE_SERVICE_TOKEN; + await expect(makeService().service.get('org-own')).rejects.toMatchObject({ status: 502 }); + expect(post).not.toHaveBeenCalled(); + }); + + it('keeps only the expected fields of the reply', () => { + expect(parseInvoiceEmailView({ ...VIEW, extra: 'x', recipient: { kind: 'custom', address: 'a@b.de', secret: 1 } })).toEqual({ + ...VIEW, + recipient: { kind: 'custom', address: 'a@b.de' }, + }); + expect(parseInvoiceEmailView({ enabled: true })).toBeNull(); + expect(parseInvoiceEmailView({ ...VIEW, lastSent: { number: 'X', at: 'never', to: 'a' } })?.lastSent).toBeNull(); + }); +}); + +describe('InvoiceEmailDto', () => { + const errors = (body: unknown) => validateSync(plainToInstance(InvoiceEmailDto, body), { whitelist: true, forbidNonWhitelisted: true }); + + it('accepts on/off with an address, null or empty for the billing contact', () => { + expect(errors({ enabled: true })).toHaveLength(0); + expect(errors({ enabled: true, to: 'accounting@acme-gmbh.de' })).toHaveLength(0); + expect(errors({ enabled: true, to: null })).toHaveLength(0); + expect(errors({ enabled: false, to: '' })).toHaveLength(0); + }); + + it('refuses a malformed address, a missing switch, and any workspace or licence field', () => { + expect(errors({ enabled: true, to: 'not-an-email' }).length).toBeGreaterThan(0); + expect(errors({ to: 'a@b.de' }).length).toBeGreaterThan(0); + expect(errors({ enabled: true, organizationId: 'org-other' }).length).toBeGreaterThan(0); + expect(errors({ enabled: true, licenseKey: 'AMCP-AAAA-BBBB-CCCC-DDDD' }).length).toBeGreaterThan(0); + }); +}); + +describe('LicenseController — invoices by email', () => { + function make(opts: { cloud?: boolean; set?: jest.Mock; get?: jest.Mock } = {}) { + const invoiceEmail = { + get: opts.get ?? jest.fn(async () => VIEW), + set: opts.set ?? jest.fn(async () => VIEW), + }; + const securityEvents = { log: jest.fn(async () => undefined) }; + const controller = new LicenseController( + {} as any, + {} as any, + {} as any, + {} as any, + { isCloud: () => opts.cloud ?? true } as any, + {} as any, + {} as any, + {} as any, + undefined, + invoiceEmail as any, + securityEvents as any, + ); + return { controller, invoiceEmail, securityEvents }; + } + const req = { user: { sub: 'user-1', organizationId: 'org-own' }, ip: '1.2.3.4', headers: { 'user-agent': 'jest' } }; + + it('is for workspace admins only', () => { + for (const handler of ['getInvoiceEmail', 'setInvoiceEmail'] as const) { + expect(Reflect.getMetadata(ROLES_KEY, LicenseController.prototype[handler])).toEqual(['ADMIN']); + } + }); + + it('is 404 on self-hosted', async () => { + const { controller, invoiceEmail } = make({ cloud: false }); + await expect(controller.getInvoiceEmail(req)).rejects.toBeInstanceOf(NotFoundException); + await expect(controller.setInvoiceEmail(req, { enabled: true })).rejects.toBeInstanceOf(NotFoundException); + expect(invoiceEmail.get).not.toHaveBeenCalled(); + }); + + it("changes the caller's own workspace and records who did it, with the recipient's domain only", async () => { + const { controller, invoiceEmail, securityEvents } = make(); + await expect(controller.setInvoiceEmail(req, { enabled: true, to: 'accounting@acme-gmbh.de' })).resolves.toEqual(VIEW); + expect(invoiceEmail.set).toHaveBeenCalledWith('org-own', 'user-1', { enabled: true, to: 'accounting@acme-gmbh.de' }); + expect(securityEvents.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: SecurityEvents.INVOICE_EMAIL_CHANGED, + organizationId: 'org-own', + actorUserId: 'user-1', + metadata: { enabled: true, recipientKind: 'custom', recipientDomain: 'acme-gmbh.de' }, + }), + ); + }); + + it('turns refusals into HTTP errors', async () => { + const conflict = make({ set: jest.fn(async () => Promise.reject(new InvoiceEmailError(409, 'none'))) }); + await expect(conflict.controller.setInvoiceEmail(req, { enabled: true })).rejects.toBeInstanceOf(ConflictException); + expect(conflict.securityEvents.log).not.toHaveBeenCalled(); + const down = make({ get: jest.fn(async () => Promise.reject(new InvoiceEmailError(502, 'down'))) }); + await expect(down.controller.getInvoiceEmail(req)).rejects.toBeInstanceOf(BadGatewayException); + }); +}); diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index adad835ef..1bdb543d7 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -12,6 +12,8 @@ import { ForbiddenException, NotFoundException, HttpCode, + HttpException, + HttpStatus, Logger, Optional, } from '@nestjs/common'; @@ -19,7 +21,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiSecurity, ApiExcludeEndpoint } import { timingSafeEqual } from 'crypto'; import { JwtOrManagementKeyGuard } from '../auth/jwt-or-management-key.guard'; import { Throttle } from '@nestjs/throttler'; -import { IsBoolean, IsIn, IsString, IsOptional, Matches } from 'class-validator'; +import { IsBoolean, IsEmail, IsIn, IsString, IsOptional, Matches, MaxLength, ValidateIf } from 'class-validator'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { LicenseService, type BillingPortalFlow } from './license.service'; import { LicenseGuardService } from './license-guard.service'; @@ -30,6 +32,8 @@ import { ProductEventService, ProductEvents } from '../audit/product-event.servi import { CardTrialEligibilityService } from './card-trial-eligibility.service'; import { CardTrialOfferService } from './card-trial-offer.service'; import { LicenseWallService, type LicenseWallView } from './license-wall.service'; +import { InvoiceEmailError, InvoiceEmailService } from './invoice-email.service'; +import { SecurityEventService, SecurityEvents } from '../audit/security-event.service'; import { createEmailLinkToken } from '../common/email-link-token'; import { CHECKOUT_BILLING_PERIODS, @@ -100,6 +104,23 @@ class DpaLinkDto { locale?: string; } +/** + * Invoices by email. `to` is the recipient: absent keeps the current one, + * null or '' goes back to the billing contact, an address sets that address. + * There is no workspace or licence field: both come from the session. + */ +export class InvoiceEmailDto { + @IsBoolean() + enabled: boolean; + + @IsOptional() + @ValidateIf((o) => typeof o.to === 'string' && o.to !== '') + @IsString() + @MaxLength(254) + @IsEmail({}, { message: 'Invalid email address' }) + to?: string | null; +} + @ApiTags('License') @Controller('api/license') export class LicenseController { @@ -115,6 +136,8 @@ export class LicenseController { private readonly cardTrialEligibility: CardTrialEligibilityService, private readonly cardTrialOffer: CardTrialOfferService, @Optional() private readonly licenseWall?: LicenseWallService, + @Optional() private readonly invoiceEmail?: InvoiceEmailService, + @Optional() private readonly securityEvents?: SecurityEventService, ) {} @Get('status') @@ -311,6 +334,76 @@ export class LicenseController { } } + // ── Invoices by email (Cloud) ────────────────────────────────────────────── + + @Get('invoice-email') + @UseGuards(JwtOrManagementKeyGuard, RolesGuard) + @Roles('ADMIN') + @ApiBearerAuth() + @ApiSecurity('api-key') + @ApiOperation({ summary: 'Whether each paid invoice is emailed as a PDF, and to which address (cloud, ADMIN)' }) + async getInvoiceEmail(@Req() req: any) { + if (!this.deployment.isCloud() || !this.invoiceEmail) { + throw new NotFoundException('Invoices by email apply to AnythingMCP Cloud only.'); + } + return this.invoiceEmailCall(() => this.invoiceEmail!.get(req.user.organizationId)); + } + + @Put('invoice-email') + @UseGuards(JwtOrManagementKeyGuard, RolesGuard) + @Roles('ADMIN') + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @ApiBearerAuth() + @ApiSecurity('api-key') + @ApiOperation({ + summary: 'Switch emailing each paid invoice as a PDF on or off, and set the address (cloud, ADMIN)', + }) + async setInvoiceEmail(@Req() req: any, @Body() dto: InvoiceEmailDto) { + if (!this.deployment.isCloud() || !this.invoiceEmail) { + throw new NotFoundException('Invoices by email apply to AnythingMCP Cloud only.'); + } + const organizationId: string = req.user.organizationId; + const view = await this.invoiceEmailCall(() => + this.invoiceEmail!.set(organizationId, req.user.sub, { enabled: dto.enabled, to: dto.to }), + ); + const address = view.recipient.address; + await this.securityEvents?.log({ + event: SecurityEvents.INVOICE_EMAIL_CHANGED, + actorType: 'USER', + organizationId, + actorUserId: req.user.sub, + // The domain only: enough to spot a redirect to an outside address. + metadata: { + enabled: view.enabled, + recipientKind: view.recipient.kind, + recipientDomain: address ? address.slice(address.lastIndexOf('@') + 1) : null, + }, + ip: req.ip, + userAgent: req.headers?.['user-agent'], + }); + return view; + } + + private async invoiceEmailCall(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (!(err instanceof InvoiceEmailError)) throw err; + switch (err.status) { + case 400: + throw new BadRequestException(err.message); + case 404: + throw new NotFoundException(err.message); + case 409: + throw new ConflictException(err.message); + case 429: + throw new HttpException(err.message, HttpStatus.TOO_MANY_REQUESTS); + default: + throw new BadGatewayException(err.message); + } + } + } + @Post('dpa-link') @HttpCode(200) @UseGuards(JwtOrManagementKeyGuard, RolesGuard) diff --git a/packages/backend/src/license/license.module.ts b/packages/backend/src/license/license.module.ts index c3cc6b269..4268a2f49 100644 --- a/packages/backend/src/license/license.module.ts +++ b/packages/backend/src/license/license.module.ts @@ -6,6 +6,7 @@ import { CardTrialEligibilityService } from './card-trial-eligibility.service'; import { CardTrialOfferService } from './card-trial-offer.service'; import { LicenseWallService } from './license-wall.service'; import { RetentionLicenceClient } from './retention-licence.client'; +import { InvoiceEmailService } from './invoice-email.service'; import { TrialExtensionController } from './trial-extension.controller'; import { ReactivationController } from './reactivation.controller'; import { SettingsModule } from '../settings/settings.module'; @@ -21,6 +22,7 @@ import { UsersModule } from '../users/users.module'; CardTrialOfferService, LicenseWallService, RetentionLicenceClient, + InvoiceEmailService, ], exports: [ LicenseService, diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index be907fdca..bf4bc4cb9 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -526,7 +526,7 @@ test('bash completion suggests commands, subcommands, flags and shells', async ( 'bash', file, 'amcp', ...words]); return stdout.split('\n').filter(Boolean).sort(); }; - assert.deepEqual(await complete(''), ['api', 'completion', 'login', 'logout', 'tools', 'whoami']); + assert.deepEqual(await complete(''), ['api', 'completion', 'invoices', 'login', 'logout', 'tools', 'whoami']); assert.deepEqual(await complete('tools', 'c'), ['call']); assert.deepEqual(await complete('--profile', 'work', 'tools', ''), ['call', 'describe', 'ls']); assert.deepEqual(await complete('tools', 'call', 'my_tool', '--t'), ['--timeout']); @@ -543,7 +543,7 @@ test('fish completion suggests commands, subcommands, flags and shells', async ( const { stdout } = await execFileAsync('fish', ['--no-config', '-c', 'source $argv[1]; complete -C $argv[2]', file, line]); return stdout.split('\n').filter(Boolean).map((entry) => entry.split('\t')[0]).sort(); }; - assert.deepEqual(await complete('amcp '), ['api', 'completion', 'login', 'logout', 'tools', 'whoami']); + assert.deepEqual(await complete('amcp '), ['api', 'completion', 'invoices', 'login', 'logout', 'tools', 'whoami']); assert.deepEqual(await complete('amcp tools c'), ['call']); assert.deepEqual(await complete('amcp --profile work tools '), ['call', 'describe', 'ls']); assert.deepEqual(await complete('amcp tools call my_tool --t'), ['--timeout']); @@ -791,3 +791,63 @@ test('browserCommand passes the login URL to Windows as one argument, not throug assert.deepEqual(browserCommand('darwin', url), ['open', [url]]); assert.deepEqual(browserCommand('linux', url), ['xdg-open', [url]]); }); + +test('invoices email shows and changes the workspace setting with the management key', async () => { + const seen: { method?: string; path: string; body: string }[] = []; + let state = { available: true, enabled: false, billingContact: 'billing@acme-gmbh.de', recipient: { kind: 'billing', address: 'billing@acme-gmbh.de' }, lastSent: null as unknown }; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk.toString(); + seen.push({ method: req.method, path: req.url || '', body }); + if (req.headers['authorization'] !== 'Bearer mcp_mgmtsecret') { res.writeHead(401); res.end('{}'); return; } + if (req.url !== '/api/license/invoice-email') { res.writeHead(404); res.end('{}'); return; } + if (req.method === 'PUT') { + const change = JSON.parse(body); + const to = change.to === undefined ? state.recipient.address : change.to ?? state.billingContact; + state = { ...state, enabled: change.enabled, recipient: { kind: to === state.billingContact ? 'billing' : 'custom', address: to } }; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(state)); + }); + server.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing address'); + const cli = join(__dirname, 'index.js'); + const env = { ...process.env, AMCP_URL: `http://127.0.0.1:${address.port}`, AMCP_API_KEY: 'mcp_mgmtsecret' }; + const shown = await execFileAsync(process.execPath, [cli, 'invoices', 'email'], { env }); + assert.match(shown.stdout, /^Invoices by email: off$/m); + assert.match(shown.stdout, /^Recipient: billing@acme-gmbh\.de \(billing contact\)$/m); + + const on = await execFileAsync(process.execPath, [cli, 'invoices', 'email', 'on', '--to', 'accounting@acme-gmbh.de', '--yes'], { env }); + assert.match(on.stdout, /^Invoices by email: on$/m); + assert.match(on.stdout, /^Recipient: accounting@acme-gmbh\.de \(another address\)$/m); + assert.deepEqual(JSON.parse(seen.at(-1)!.body), { enabled: true, to: 'accounting@acme-gmbh.de' }); + + // Only the address: the switch stays as it is (read first). + await execFileAsync(process.execPath, [cli, 'invoices', 'email', '--billing-contact', '--yes', '--json'], { env }); + assert.deepEqual(JSON.parse(seen.at(-1)!.body), { enabled: true, to: null }); + assert.equal(seen.at(-2)!.method, 'GET'); + + await execFileAsync(process.execPath, [cli, 'invoices', 'email', 'off', '--yes'], { env }); + assert.deepEqual(JSON.parse(seen.at(-1)!.body), { enabled: false }); + + // Changes need --yes without a terminal; bad combinations fail before any request. + const before = seen.length; + for (const args of [ + ['invoices', 'email', 'on'], + ['invoices', 'email', 'off', '--to', 'a@b.de', '--yes'], + ['invoices', 'email', 'on', '--to', 'a@b.de', '--billing-contact', '--yes'], + ['invoices', 'email', 'on', '--to', 'a@b.de,c@d.de', '--yes'], + ['invoices', 'email', 'maybe'], + ['invoices', 'list'], + ]) { + const failure = await execFileAsync(process.execPath, [cli, ...args], { env }) + .then(() => { throw new Error(`should have failed: ${args.join(' ')}`); }, (error: { code?: number }) => error); + assert.equal(failure.code, 2, args.join(' ')); + } + assert.equal(seen.length, before); + } finally { + server.close(); + } +}); diff --git a/packages/cli/src/completion.ts b/packages/cli/src/completion.ts index 5562e1df1..59cc55d61 100644 --- a/packages/cli/src/completion.ts +++ b/packages/cli/src/completion.ts @@ -24,6 +24,8 @@ export const FLAGS: readonly FlagSpec[] = [ { name: 'list', description: 'List the management API operations amcp api can call' }, { name: 'browser', description: 'Log in through the browser and mint a management key' }, { name: 'scope', value: 'read|admin', description: 'Scope for a browser-minted management key' }, + { name: 'to', value: 'email address', description: 'Send each invoice to this address (amcp invoices email)' }, + { name: 'billing-contact', description: 'Send invoices to the billing contact again (amcp invoices email)' }, { name: 'json', description: 'Machine-readable output' }, { name: 'help', short: 'h', description: 'Show usage' }, { name: 'version', short: 'v', description: 'Show the CLI version' }, @@ -43,6 +45,11 @@ export const COMMANDS: readonly CommandSpec[] = [ ], }, { name: 'api', description: 'Call the management API (/api/*) with a management key', flags: ['url', 'data', 'key-stdin', 'yes', 'list', 'profile'] }, + { + name: 'invoices', description: 'Invoices of your AnythingMCP Cloud workspace', subcommands: [ + { name: 'email', description: 'Show or change emailing each paid invoice as a PDF', flags: ['to', 'billing-contact', 'yes', 'url', 'key-stdin', 'profile'] }, + ], + }, { name: 'completion', description: 'Print a shell completion script', values: SHELLS.map((shell) => ({ name: shell, description: `${shell} completion script` })), diff --git a/packages/cli/src/generated/operations.ts b/packages/cli/src/generated/operations.ts index 6f511141c..7598e52b2 100644 --- a/packages/cli/src/generated/operations.ts +++ b/packages/cli/src/generated/operations.ts @@ -1598,6 +1598,26 @@ export const OPERATIONS: readonly ApiOperation[] = [ "pathParams": [], "hasBody": true }, + { + "resource": "license", + "action": "getInvoiceEmail", + "method": "GET", + "path": "/api/license/invoice-email", + "summary": "Whether each paid invoice is emailed as a PDF, and to which address (cloud, ADMIN)", + "admin": true, + "pathParams": [], + "hasBody": false + }, + { + "resource": "license", + "action": "setInvoiceEmail", + "method": "PUT", + "path": "/api/license/invoice-email", + "summary": "Switch emailing each paid invoice as a PDF on or off, and set the address (cloud, ADMIN)", + "admin": true, + "pathParams": [], + "hasBody": true + }, { "resource": "license", "action": "setLicenseKey", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c2f97c9af..3dc6226bb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -194,6 +194,7 @@ export const OPTIONS = { args: { type: 'string' }, yes: { type: 'boolean' }, json: { type: 'boolean' }, timeout: { type: 'string' }, data: { type: 'string' }, management: { type: 'boolean' }, list: { type: 'boolean' }, browser: { type: 'boolean' }, scope: { type: 'string' }, + to: { type: 'string' }, 'billing-contact': { type: 'boolean' }, version: { type: 'boolean', short: 'v' }, } as const; @@ -234,12 +235,122 @@ function usage(): string { amcp tools call [--args '' | --args @file.json | --args -] [--yes] [--timeout ] amcp api [--data '' | --data @file.json | --data -] [--yes] [--url ] amcp api --list [] + amcp invoices email [on|off] [--to
| --billing-contact] [--yes] amcp completion amcp --version Use --json for machine-readable output. For CI, set AMCP_URL, AMCP_SERVER and AMCP_API_KEY. The key is never accepted as a command argument. 'amcp api' calls the management -API (/api/*) with a management-scoped key from AMCP_API_KEY or --key-stdin.`; +API (/api/*) with a management-scoped key from AMCP_API_KEY or --key-stdin. +'amcp invoices email' (AnythingMCP Cloud, workspace admins) shows whether each paid +invoice is emailed as a PDF; 'on'/'off' switch it, --to sends invoices to another +address, --billing-contact back to the subscription's billing email.`; +} + +type ParsedValues = ReturnType>['values']; + +/** + * The instance and management key for the management API. The key never + * comes from argv: env or stdin only, or a management key saved by + * `amcp login --management` for the same instance. A stored MCP key is not + * used for the management API. + */ +async function managementCredentials(values: ParsedValues, stored: Profile | undefined): Promise<{ url: string; key: string }> { + const url = values.url || process.env.AMCP_URL || stored?.url; + if (!url) throw new CliError('Set --url or AMCP_URL, or log in first', 2); + const key = process.env.AMCP_API_KEY || + (values['key-stdin'] ? await readKey(process.stdin, process.stderr, true) : '') || + (stored?.managementKey && stored.url && sameOrigin(url, stored.url) ? stored.managementKey : ''); + if (!key) throw new CliError('Set AMCP_API_KEY, pass --key-stdin, or log in with: amcp login --management', 2); + return { url, key }; +} + +interface InvoiceEmailView { + available: boolean; + enabled: boolean; + billingContact: string | null; + recipient: { kind: 'billing' | 'custom'; address: string | null }; + lastSent: { number: string; at: string; to: string } | null; +} + +function printInvoiceEmail(view: InvoiceEmailView): void { + if (!view.available) { + process.stdout.write('Invoices by email: not available (this workspace has no subscription)\n'); + return; + } + const recipient = view.recipient.address ? singleLine(view.recipient.address) : 'none'; + const kind = view.recipient.kind === 'custom' ? 'another address' : 'billing contact'; + process.stdout.write(`Invoices by email: ${view.enabled ? 'on' : 'off'}\n`); + process.stdout.write(`Recipient: ${recipient} (${kind})\n`); + if (view.recipient.kind === 'custom' && view.billingContact) { + process.stdout.write(`Billing contact: ${singleLine(view.billingContact)}\n`); + } + if (view.lastSent) { + process.stdout.write(`Last invoice sent: ${singleLine(view.lastSent.number)} on ${singleLine(view.lastSent.at.slice(0, 10))} to ${singleLine(view.lastSent.to)}\n`); + } +} + +/** + * amcp invoices email [on|off] [--to
| --billing-contact] + * + * Reads or changes, for the key owner's workspace, whether each paid invoice + * is emailed as a PDF (AnythingMCP Cloud, workspace admins). The workspace is + * the key's own: nothing here names another one. + */ +async function invoicesCommand(positionals: string[], values: ParsedValues, stored: Profile | undefined, json: boolean): Promise { + const [, subcommand, state] = positionals; + const usageText = 'Usage: amcp invoices email [on|off] [--to
| --billing-contact]'; + if (subcommand !== 'email' || positionals.length > 3 || (state !== undefined && state !== 'on' && state !== 'off')) { + throw new CliError(usageText, 2); + } + if (values.to !== undefined && values['billing-contact']) { + throw new CliError('Use either --to or --billing-contact, not both', 2); + } + if (values.to !== undefined && (!values.to.includes('@') || /[\s,;<>]/.test(values.to))) { + throw new CliError('--to must be one email address', 2); + } + if (state === 'off' && (values.to !== undefined || values['billing-contact'])) { + throw new CliError('--to and --billing-contact go with "on", or alone to change the address', 2); + } + const { url, key } = await managementCredentials(values, stored); + const call = async (method: ApiMethod, body?: unknown) => { + let result; + try { + result = await apiRequest({ url, key, method, path: '/api/license/invoice-email', body }); + } catch (error) { + throw classifyConnectionError(error, 'management'); + } + if (!result.ok) { + const reason = serverMessage(result.data); + if (result.status === 401 || result.status === 403) { + const label = result.status === 401 ? 'Authentication failed' : 'Forbidden'; + throw new CliError(`${label} (${result.status})${reason ? `: ${reason}` : ''}`, 3); + } + if (result.status === 404) throw new CliError(reason || 'Invoices by email apply to AnythingMCP Cloud only', 1); + throw new CliError(`Request failed (${result.status})${reason ? `: ${reason}` : ''}`, result.status >= 500 ? 4 : 1); + } + return result.data as InvoiceEmailView; + }; + + const changesAddress = values.to !== undefined || values['billing-contact'] === true; + if (!state && !changesAddress) { + const view = await call('GET'); + print(view, json, () => printInvoiceEmail(view)); + return 0; + } + // Changing only the address keeps the switch as it is. + const enabled = state ? state === 'on' : (await call('GET')).enabled; + const body: { enabled: boolean; to?: string | null } = { enabled }; + if (values.to !== undefined) body.to = values.to.trim(); + else if (values['billing-contact']) body.to = null; + if (!values.yes) { + if (!process.stdin.isTTY) throw new CliError('This changes where invoices are sent; pass --yes in non-interactive mode', 2); + const what = `${enabled ? 'Email' : 'Stop emailing'} each paid invoice${enabled && body.to ? ` to ${singleLine(body.to)}` : enabled && body.to === null ? ' to the billing contact' : ''}`; + if (!await confirmApi('PUT', `/api/license/invoice-email (${what})`)) throw new CliError('Cancelled', 2); + } + const view = await call('PUT', body); + print(view, json, () => printInvoiceEmail(view)); + return 0; } export async function run(args = process.argv.slice(2)): Promise { @@ -412,14 +523,7 @@ export async function run(args = process.argv.slice(2)): Promise { throw new CliError(`Unsupported method: ${singleLine(method)}`, 2); } if (!path.startsWith('/')) throw new CliError('Path must start with /', 2); - const url = values.url || process.env.AMCP_URL || stored?.url; - if (!url) throw new CliError('Set --url or AMCP_URL, or log in first', 2); - // The management key never comes from argv: env or stdin only. A stored - // profile key is an MCP key and is not used for the management API. - const key = process.env.AMCP_API_KEY || - (values['key-stdin'] ? await readKey(process.stdin, process.stderr, true) : '') || - (stored?.managementKey && stored.url && sameOrigin(url, stored.url) ? stored.managementKey : ''); - if (!key) throw new CliError('Set AMCP_API_KEY, pass --key-stdin, or log in with: amcp login --management', 2); + const { url, key } = await managementCredentials(values, stored); const safe = method === 'GET' || method === 'HEAD'; if (safe && values.data !== undefined) { throw new CliError(`--data is not allowed with ${method}; put parameters in the path query string`, 2); @@ -448,6 +552,9 @@ export async function run(args = process.argv.slice(2)): Promise { () => process.stdout.write(`${JSON.stringify(result.data, null, 2)}\n`)); return result.ok ? 0 : result.status >= 500 ? 4 : 1; } + if (command === 'invoices') { + return await invoicesCommand(positionals, values, stored, json); + } if (toolCommand && (subcommand === 'ls' ? positionals.length === 2 : positionals.length === 3)) { const urlOverridden = !!(values.url || process.env.AMCP_URL); const url = values.url || process.env.AMCP_URL || stored?.url || ''; diff --git a/packages/frontend/messages/de.json b/packages/frontend/messages/de.json index 6e973c2e7..28b8b2b07 100644 --- a/packages/frontend/messages/de.json +++ b/packages/frontend/messages/de.json @@ -2445,6 +2445,25 @@ "manage": "Abonnement & Abrechnung verwalten", "cancel": "Abonnement kündigen" }, + "invoiceEmail": { + "title": "Rechnungen per E-Mail", + "body": "Erhalte jede Rechnung als PDF per E-Mail, sobald sie bezahlt ist. Alle Rechnungen findest du außerdem jederzeit im Abrechnungsportal.", + "recipientLabel": "Rechnungen senden an", + "billingContact": "Rechnungskontakt", + "otherAddress": "Andere Adresse", + "otherAddressHint": "Zum Beispiel deine Buchhaltung. Die bisherige Adresse bekommt bei einer Änderung einen kurzen Hinweis.", + "addressPlaceholder": "buchhaltung@firma.de", + "saveRecipient": "Adresse speichern", + "saving": "Wird gespeichert…", + "savedOn": "Ab der nächsten Zahlung kommen die Rechnungen per E-Mail.", + "savedOff": "Rechnungen werden nicht mehr per E-Mail verschickt.", + "savedRecipient": "Adresse gespeichert.", + "invalidAddress": "Gib eine gültige E-Mail-Adresse ein.", + "saveError": "Speichern fehlgeschlagen. Bitte versuch es noch einmal.", + "loadError": "Die Einstellungen für Rechnungen per E-Mail konnten nicht geladen werden. Lade die Seite neu, um es noch einmal zu versuchen.", + "lastSent": "Zuletzt gesendet: {number} am {date} an {to}", + "allInvoices": "Alle Rechnungen: oben unter Abonnement & Abrechnung verwalten." + }, "features": { "title": "Funktionen", "yes": "Ja", diff --git a/packages/frontend/messages/en.json b/packages/frontend/messages/en.json index 6f5d672c8..beabb6b57 100644 --- a/packages/frontend/messages/en.json +++ b/packages/frontend/messages/en.json @@ -2445,6 +2445,25 @@ "manage": "Manage subscription & billing", "cancel": "Cancel subscription" }, + "invoiceEmail": { + "title": "Invoices by email", + "body": "Get every invoice as a PDF by email as soon as it is paid. All invoices are always in the billing portal too.", + "recipientLabel": "Send invoices to", + "billingContact": "Billing contact", + "otherAddress": "Another address", + "otherAddressHint": "For example your accounting team. The previous address gets a short notice when you change it.", + "addressPlaceholder": "accounting@company.com", + "saveRecipient": "Save address", + "saving": "Saving…", + "savedOn": "Invoices will be emailed from the next payment on.", + "savedOff": "Invoices are no longer emailed.", + "savedRecipient": "Address saved.", + "invalidAddress": "Enter one valid email address.", + "saveError": "Could not save. Please try again.", + "loadError": "The invoice email settings could not be loaded. Reload the page to try again.", + "lastSent": "Last invoice sent: {number} on {date} to {to}", + "allInvoices": "All invoices: Manage subscription & billing, above." + }, "features": { "title": "Features", "yes": "Yes", diff --git a/packages/frontend/messages/it.json b/packages/frontend/messages/it.json index b3352e0af..fd82716d5 100644 --- a/packages/frontend/messages/it.json +++ b/packages/frontend/messages/it.json @@ -2445,6 +2445,25 @@ "manage": "Gestisci abbonamento e fatturazione", "cancel": "Annulla l'abbonamento" }, + "invoiceEmail": { + "title": "Fatture via email", + "body": "Ricevi ogni fattura in PDF via email appena viene pagata. Tutte le fatture restano comunque nel portale di fatturazione.", + "recipientLabel": "Invia le fatture a", + "billingContact": "Contatto di fatturazione", + "otherAddress": "Un altro indirizzo", + "otherAddressHint": "Per esempio la tua contabilità. Quando cambi indirizzo, quello precedente riceve un breve avviso.", + "addressPlaceholder": "contabilita@azienda.it", + "saveRecipient": "Salva indirizzo", + "saving": "Salvataggio…", + "savedOn": "Dal prossimo pagamento le fatture arriveranno via email.", + "savedOff": "Le fatture non vengono più inviate via email.", + "savedRecipient": "Indirizzo salvato.", + "invalidAddress": "Inserisci un indirizzo email valido.", + "saveError": "Salvataggio non riuscito. Riprova.", + "loadError": "Non è stato possibile caricare le impostazioni delle fatture via email. Ricarica la pagina per riprovare.", + "lastSent": "Ultima fattura inviata: {number} il {date} a {to}", + "allInvoices": "Tutte le fatture: in alto, Gestisci abbonamento e fatturazione." + }, "features": { "title": "Funzionalità", "yes": "Sì", diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index 40d689149..b2f10bfe7 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -12,6 +12,7 @@ import { Card } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { useEdition, notifyEditionChanged } from '@/lib/use-edition'; import { EditionCard } from '@/components/edition-card'; +import { InvoiceEmailCard } from '@/components/invoice-email-card'; import { cardTrialDisplayEnd, cardTrialEligible, formatTrialEnd } from '@/lib/card-trial'; import { useCardTrialKind } from '@/lib/use-card-trial-eligibility'; import { describeBilling } from '@/lib/billing-summary'; @@ -396,6 +397,9 @@ export default function LicenseSettingsPage() { )} + {/* Invoices by email: each paid invoice as a PDF (Cloud, paid or card-trial subscription) */} + {hasBillableSubscription && token && } + {/* Data Processing Agreement: paid Cloud plans accept it on the licence site */} {isCloud && status?.plan && status.plan !== 'trial' && status.plan !== 'community' && ( diff --git a/packages/frontend/src/components/invoice-email-card.tsx b/packages/frontend/src/components/invoice-email-card.tsx new file mode 100644 index 000000000..0c7c486d2 --- /dev/null +++ b/packages/frontend/src/components/invoice-email-card.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { license, type InvoiceEmailSettings } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; + +/** + * Settings → License (Cloud, admins with a subscription): email each paid + * invoice as a PDF, to the billing contact or to another address. The licence + * site sends the emails; this card only reads and changes the setting. + * Hidden when the workspace has no subscription to send invoices for. + */ +export function InvoiceEmailCard({ token }: { token: string }) { + const t = useTranslations('settingsLicense.invoiceEmail'); + const locale = useLocale(); + const [settings, setSettings] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(''); + const [mode, setMode] = useState<'billing' | 'custom'>('billing'); + const [address, setAddress] = useState(''); + const errorRef = useRef(null); + const switchLabel = useId(); + const addressId = useId(); + + const apply = (next: InvoiceEmailSettings) => { + setSettings(next); + setMode(next.recipient.kind); + setAddress(next.recipient.kind === 'custom' ? (next.recipient.address ?? '') : ''); + }; + + useEffect(() => { + let alive = true; + license + .invoiceEmail(token) + .then((s) => alive && apply(s)) + .catch(() => alive && setLoadFailed(true)); + return () => { + alive = false; + }; + }, [token]); + + useEffect(() => { + if (error) errorRef.current?.focus(); + }, [error]); + + if (loadFailed) { + return ( + +

{t('title')}

+

{t('loadError')}

+
+ ); + } + if (!settings || !settings.available) return null; + + const save = async (change: { enabled: boolean; to?: string | null }, done: string) => { + setError(''); + setSaved(''); + setSaving(true); + try { + apply(await license.setInvoiceEmail(token, change)); + setSaved(done); + } catch (err: any) { + setError(err?.message || t('saveError')); + } finally { + setSaving(false); + } + }; + + const toggle = () => { + const enabled = !settings.enabled; + void save({ enabled }, enabled ? t('savedOn') : t('savedOff')); + }; + + const saveRecipient = () => { + if (mode === 'billing') { + void save({ enabled: settings.enabled, to: null }, t('savedRecipient')); + return; + } + const value = address.trim(); + if (!value || !/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(value)) { + setSaved(''); + setError(t('invalidAddress')); + return; + } + void save({ enabled: settings.enabled, to: value }, t('savedRecipient')); + }; + + const currentCustom = settings.recipient.kind === 'custom' ? settings.recipient.address : null; + const recipientChanged = + mode === 'billing' ? settings.recipient.kind !== 'billing' : address.trim().toLowerCase() !== (currentCustom ?? ''); + const lastSentDate = settings.lastSent + ? new Date(settings.lastSent.at).toLocaleDateString(locale === 'en' ? 'en-GB' : locale, { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + : null; + + return ( + +
+
+

+ {t('title')} +

+

{t('body')}

+
+ +
+ + {settings.enabled && ( +
+ {t('recipientLabel')} + + + {mode === 'custom' && ( +
+ + setAddress(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveRecipient()} + placeholder={t('addressPlaceholder')} + data-testid="invoice-email-address" + className="w-full max-w-sm h-9 rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-3 text-sm text-[var(--text)] outline-none focus:border-[var(--brand)]" + /> +

{t('otherAddressHint')}

+
+ )} + {recipientChanged && ( +
+ +
+ )} +
+ )} + + {settings.enabled && settings.lastSent && ( +

+ {t('lastSent', { number: settings.lastSent.number, date: lastSentDate ?? '', to: settings.lastSent.to })} +

+ )} +

{t('allInvoices')}

+ +
+ {saved &&

{saved}

} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index a7c50ba78..2cc8a7dbe 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -892,6 +892,18 @@ export const adapters = { }>('/api/adapters/starter-pack/install', { method: 'POST', token, body: { slugs } }), }; +/** Invoices by email for the workspace's subscription (Cloud). */ +export interface InvoiceEmailSettings { + /** False when there is 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. */ + lastSent: { number: string; at: string; to: string } | null; +} + /** A paid Cloud licence's Stripe subscription, as the server last saw it. */ export interface LicenseBilling { status: string; @@ -2146,6 +2158,14 @@ export const license = { body: { ...(locale && { locale }) }, token, }), + /** Cloud only (ADMIN): whether each paid invoice is emailed as a PDF, and to which address. */ + invoiceEmail: (token: string) => request('/api/license/invoice-email', { token }), + /** + * Cloud only (ADMIN): switch invoices by email on or off. `to`: an address, + * null for the billing contact, or leave it out to keep the current one. + */ + setInvoiceEmail: (token: string, change: { enabled: boolean; to?: string | null }) => + request('/api/license/invoice-email', { method: 'PUT', body: change, token }), /** 'cancel' opens Stripe's cancellation page, 'payment_method_update' the page to add a card. */ billingPortal: (token: string, returnUrl?: string, flow?: 'cancel' | 'payment_method_update') => request<{ url: string }>('/api/license/billing-portal', { diff --git a/packages/frontend/tests/e2e/invoice-email.spec.ts b/packages/frontend/tests/e2e/invoice-email.spec.ts new file mode 100644 index 000000000..d8439e04f --- /dev/null +++ b/packages/frontend/tests/e2e/invoice-email.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * Settings → License: "Invoices by email" (Cloud). The API is mocked; the + * tests check what the admin sees and what the page sends. + */ + +const DAY = 86_400_000; +const ADMIN = { + id: 'u1', + email: 'admin@example.test', + name: 'Ada Admin', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; +const PAID = { + plan: 'starter', + status: 'active', + expiresAt: null, + features: {}, + billing: { + status: 'active', + cancelling: false, + endsAt: null, + currentPeriodEnd: new Date(Date.now() + 20 * DAY).toISOString(), + trialEnd: null, + amount: 1900, + currency: 'eur', + interval: 'month', + }, +}; +const OFF = { + available: true, + enabled: false, + billingContact: 'billing@acme-gmbh.de', + recipient: { kind: 'billing', address: 'billing@acme-gmbh.de' }, + lastSent: null, +}; + +async function session( + page: Page, + opts: { deploymentMode?: 'cloud' | 'self-hosted'; license?: Record; settings?: Record } = {}, +) { + const puts: unknown[] = []; + let settings: Record = opts.settings ?? OFF; + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, ADMIN); + await page.route(/\/(api|health)\//, async (route) => { + const req = route.request(); + const p = new URL(req.url()).pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (p === '/health/server-info') { + return json({ deploymentMode: opts.deploymentMode ?? 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + } + if (p.endsWith('/license/invoice-email')) { + if (req.method() === 'PUT') { + const change = req.postDataJSON(); + puts.push(change); + if (change.to === 'refused@acme-gmbh.de') return json({ message: 'Invalid email address' }, 400); + const to = change.to === undefined ? settings.recipient.address : (change.to ?? settings.billingContact); + settings = { + ...settings, + enabled: change.enabled, + recipient: { kind: to === settings.billingContact ? 'billing' : 'custom', address: to }, + }; + } + return json(settings); + } + if (p.endsWith('/license/refresh')) return json({ refreshed: false }); + if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: new Date().toISOString() }); + if (p.endsWith('/users/me')) return json(ADMIN); + if (p.endsWith('/license/status')) return json(opts.license ?? PAID); + if (p.endsWith('/license/usage')) { + return json({ plan: 'starter', connectors: { current: 1, max: 5, isOver: false }, mcpServers: { current: 1, max: 3, isOver: false }, users: { current: 1, max: 1, isOver: false }, isOverAny: false }); + } + if (p.endsWith('/connectors') || p.endsWith('/adapters') || p.endsWith('/mcp-servers')) return json([]); + return json({}); + }); + return { puts }; +} + +test.describe('invoices by email (cloud)', () => { + test('switching on sends only the switch, and the billing contact is the default recipient', async ({ page }) => { + const { puts } = await session(page); + await page.goto('/settings/license'); + const toggle = page.getByRole('switch', { name: 'Invoices by email' }); + await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 15_000 }); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(puts).toEqual([{ enabled: true }]); + await expect(page.getByRole('radio', { name: /Billing contact \(billing@acme-gmbh\.de\)/ })).toBeChecked(); + await expect(page.getByText('Invoices will be emailed from the next payment on.')).toBeVisible(); + }); + + test('another address is saved with the switch kept on; a refused one shows an error', async ({ page }) => { + const { puts } = await session(page, { settings: { ...OFF, enabled: true } }); + await page.goto('/settings/license'); + await page.getByRole('radio', { name: 'Another address' }).click({ timeout: 15_000 }); + const input = page.getByTestId('invoice-email-address'); + await input.fill('not an email'); + await page.getByRole('button', { name: 'Save address' }).click(); + await expect(page.getByTestId('invoice-email-card').getByRole('alert')).toHaveText('Enter one valid email address.'); + expect(puts).toEqual([]); + + await input.fill('refused@acme-gmbh.de'); + await page.getByRole('button', { name: 'Save address' }).click(); + await expect(page.getByTestId('invoice-email-card').getByRole('alert')).toHaveText('Invalid email address'); + + await input.fill('Accounting@Acme-GmbH.de'); + await page.getByRole('button', { name: 'Save address' }).click(); + await expect(page.getByText('Address saved.')).toBeVisible(); + expect(puts.at(-1)).toEqual({ enabled: true, to: 'Accounting@Acme-GmbH.de' }); + + await page.getByRole('radio', { name: /Billing contact/ }).click(); + await page.getByRole('button', { name: 'Save address' }).click(); + await expect(page.getByText('Address saved.')).toBeVisible(); + expect(puts.at(-1)).toEqual({ enabled: true, to: null }); + }); + + test('shows the last invoice sent', async ({ page }) => { + await session(page, { + settings: { ...OFF, enabled: true, lastSent: { number: 'VCOAA7SE-1402', at: '2026-11-09T10:00:00.000Z', to: 'bi***@acme-gmbh.de' } }, + }); + await page.goto('/settings/license'); + await expect(page.getByTestId('invoice-email-last-sent')).toHaveText( + 'Last invoice sent: VCOAA7SE-1402 on 9 November 2026 to bi***@acme-gmbh.de', + { timeout: 15_000 }, + ); + }); + + test('is hidden when the workspace has no subscription to invoice', async ({ page }) => { + await session(page, { settings: { ...OFF, available: false } }); + await page.goto('/settings/license'); + await expect(page.getByRole('button', { name: 'Manage subscription & billing' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('invoice-email-card')).toHaveCount(0); + }); + + test('is not offered on a free trial', async ({ page }) => { + await session(page, { license: { plan: 'trial', status: 'active', expiresAt: new Date(Date.now() + 3 * DAY).toISOString(), features: {}, trialDaysLeft: 3 } }); + await page.goto('/settings/license'); + await expect(page.getByText('Trial', { exact: false }).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('invoice-email-card')).toHaveCount(0); + }); +}); diff --git a/scripts/generate-llms-txt.mjs b/scripts/generate-llms-txt.mjs index ca0d00e1d..414c29b54 100644 --- a/scripts/generate-llms-txt.mjs +++ b/scripts/generate-llms-txt.mjs @@ -22,7 +22,7 @@ const SECTIONS = [ ['Getting started', ['README.md', 'docs/deployment.md', 'docs/guides.md']], ['Connectors', ['docs/connectors/*.md', 'docs/tool-definition.md']], ['AI clients', ['docs/integrations/*.md']], - ['Operations and security', ['docs/operations/*.md', 'docs/sso.md', 'docs/scim-entra-setup.md', 'docs/code-mode.md', 'docs/license-faq.md']], + ['Operations and security', ['docs/operations/*.md', 'docs/sso.md', 'docs/scim-entra-setup.md', 'docs/code-mode.md', 'docs/license-faq.md', 'docs/cloud-invoices.md']], ['Reference', ['docs/api-reference.md', 'docs/mcp-resources.md', 'docs/knowledge-graph.md', 'docs/memory.md']], ['Contributing', ['.github/CONTRIBUTING.md', 'AGENTS.md']], ['Optional', ['docs/catalog/*.md', 'docs/guides/*.md', 'docs/ROADMAP.md', 'docs/ADOPTERS.md']], @@ -44,6 +44,7 @@ const DESCRIPTIONS = { 'docs/connectors/file-search.md': 'Search inside the documents of SFTP, FTP/FTPS, WebDAV, S3 and network folders from AI clients, with File Share download links, plan limits and pauses.', 'docs/code-mode.md': 'Run isolated JavaScript that orchestrates several MCP tools in one step: saved code tools, inline code_execute, governance and deployment.', 'docs/license-faq.md': 'Understand the AGPL license, the commercial EE boundary, and contributor licensing.', + 'docs/cloud-invoices.md': 'Find your AnythingMCP Cloud invoices and have each paid invoice emailed as a PDF, from the dashboard or the CLI.', '.github/CONTRIBUTING.md': 'Set up development, test changes, and submit a contribution.', 'AGENTS.md': 'Repository instructions, setup commands, tests, and contribution boundaries for coding agents.', };