From ea668c45bb569edda42487bade8dfcf5e1b73f00 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" Date: Mon, 9 Nov 2026 10:01:28 +0100 Subject: [PATCH 1/2] Email each paid invoice as a PDF on Cloud (#1604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → License gets an "Invoices by email" switch for workspace admins, with the billing contact or another address as recipient. The Cloud relays the choice to the licence site (service token), which stores it on the workspace's licence and sends the emails. GET/PUT /api/license/invoice-email take the workspace and licence from the session only; the CLI gets amcp invoices email [on|off] [--to | --billing-contact]. Self-hosted: 404, hidden. --- content/guides/de/anythingmcp-cli.mdx | 13 ++ content/guides/en/anythingmcp-cli.mdx | 13 ++ content/guides/it/anythingmcp-cli.mdx | 13 ++ docs/cloud-invoices.md | 50 +++++ .../src/audit/security-event.service.ts | 2 + .../src/license/invoice-email.service.ts | 146 +++++++++++++ .../backend/src/license/invoice-email.spec.ts | 202 ++++++++++++++++++ .../backend/src/license/license.controller.ts | 95 +++++++- .../backend/src/license/license.module.ts | 2 + packages/cli/src/cli.test.ts | 64 +++++- packages/cli/src/completion.ts | 7 + packages/cli/src/generated/operations.ts | 20 ++ packages/cli/src/index.ts | 125 ++++++++++- packages/frontend/messages/de.json | 19 ++ packages/frontend/messages/en.json | 19 ++ packages/frontend/messages/it.json | 19 ++ .../src/app/settings/license/page.tsx | 4 + .../src/components/invoice-email-card.tsx | 201 +++++++++++++++++ packages/frontend/src/lib/api.ts | 20 ++ .../frontend/tests/e2e/invoice-email.spec.ts | 150 +++++++++++++ 20 files changed, 1172 insertions(+), 12 deletions(-) create mode 100644 docs/cloud-invoices.md create mode 100644 packages/backend/src/license/invoice-email.service.ts create mode 100644 packages/backend/src/license/invoice-email.spec.ts create mode 100644 packages/frontend/src/components/invoice-email-card.tsx create mode 100644 packages/frontend/tests/e2e/invoice-email.spec.ts 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/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); + }); +}); From 4cf47fe281dfe72b87a5b8a04260e58066eaa73a Mon Sep 17 00:00:00 2001 From: "helpcode.ai" Date: Mon, 9 Nov 2026 10:11:19 +0100 Subject: [PATCH 2/2] List the Cloud invoices doc in llms.txt (#1604) --- llms-full.txt | 57 +++++++++++++++++++++++++++++++++++ llms.txt | 1 + scripts/generate-llms-txt.mjs | 3 +- 3 files changed, 60 insertions(+), 1 deletion(-) 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/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.', };