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
Moneyvalues in integer minor units.formatMoneyrenders ZAR asR1,234.56using 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.
referencemust 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 asinv_...andpay_.... 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.
- 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 D1templatecolumn records the template name. This name is not secret. The recipient column staysredacted. expiresAtmust be later than the enqueue time and no more than seven days after it. Messages that are not delivered before then expire.- Pass
messageId: await billingEmailMessageId(template, sourceKey), wheresourceKeyis 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 raisesEmailOutboxConflictError. - The queue consumer chooses the renderer from the authenticated payload. Verification payloads
have no
kindfield and are delivered as before. If a payload fails validation, it is dead-lettered asinvalid_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