AUTH4D-43 — Billing email templates

This ticket adds internal transactional billing emails to @auth4/email. It adds no public routes. The billing service decides when to send a billing email.

Templates

Template Sent when Data
billing-payment-receipt A payment completes reference, plan name, amount, paid time
billing-payment-failed A payment fails reference, plan name, amount, failed time, PaymentFailureReason
billing-renewal-due Ahead of a subscription renewal plan name, amount, renewal time
billing-subscription-downgraded A tenant moves to a lower plan previous plan name, plan name, effective time, reason

Every template also takes workspaceName, billingUrl (an HTTPS console link, or loopback HTTP for local use) and an optional productName (default auth4.dev). renderBillingEmail returns a subject plus plain-text and HTML bodies. Both bodies show the same content.

  • Amounts are Money values in integer minor units. formatMoney renders ZAR as R1,234.56 using integer arithmetic only, and rejects fractional, negative or unsafe amounts. Templates also reject zero amounts.
  • Dates use the UTC calendar date, for example 4 November 2026.
  • Workspace and plan names are limited to 80 characters. Control characters are removed. HTML output is escaped.
  • reference must be a human-readable reference that matches ^[A-Z0-9][A-Z0-9-]{2,39}$, for example an invoice number. The pattern rejects prefixed internal ids such as inv_... and pay_.... Never use provider payment ids or internal ids as references. Emails contain no other identifiers.

Delivery through the outbox

enqueueBillingEmail uses the existing encrypted outbox and email queue. Nothing is sent directly.

  1. The billing data is validated and normalized. It is then stored with the recipient as { kind: "billing", recipient, email }, encrypted under the same tenant-bound delivery purpose as verification emails. The D1 template column records the template name. This name is not secret. The recipient column stays redacted.
  2. expiresAt must be later than the enqueue time and no more than seven days after it. Messages that are not delivered before then expire.
  3. Pass messageId: await billingEmailMessageId(template, sourceKey), where sourceKey is stable, for example a payment id. The id is a SHA-256 hash, so the source key is not exposed. Processing the same billing event again reuses the existing row and provider idempotency key. Reusing the id with different content raises EmailOutboxConflictError.
  4. The queue consumer chooses the renderer from the authenticated payload. Verification payloads have no kind field and are delivered as before. If a payload fails validation, it is dead-lettered as invalid_payload.

The outbox row needs the tenantId, clientId and requestId required by the existing email_outbox schema. client_id has a foreign key to clients, so the caller must supply a client that exists in that tenant.

Source: docs/api-spec/auth4d-43.md