Skip to content

ADR-0016: Subscription notice emails are logged rows, started by the webhook and a daily job

Status

Accepted

Date

2026-10-07

Context

Client counsel requires two emails to paying subscribers in the US, Canada, UK and Europe (CSL-383, CSL-425):

  • A renewal reminder before a renewal charge: 3 working days before a monthly charge, about every six months, and 21 and 10 working days before an annual renewal.
  • A cooling-off notice on the day a 12-month-or-longer subscription renews, stating the amount charged and the 14-day cooling-off dates.

The stores and RevenueCat send neither for in-app purchases. Both tickets ask for a record of who got what and when, and for failed sends to be retried or flagged.

Two facts constrain the design. First, RevenueCat's current-state API (the sync built in CSL-355) gives billing periods but no prices; only the purchase and renewal webhooks carry the amount charged. Second, RevenueCat gives up on a webhook after five failed deliveries, so a webhook can be lost for good.

Decision Drivers

  • Must keep a durable record of each email: recipient, exact content, send time. The recipient is kept until the reader's account is deleted; after that the Firebase UID is the only identifier left (CSL-478), and the record shows a notice went to the address on file.
  • Must state the real amount charged in the cooling-off notice, on the renewal day.
  • Must retry or flag a failed send, never drop it silently.
  • Must ship dark: the release date depends on store review.
  • Should survive a lost webhook.

Considered Options

Option A: Daily batch only, from the synced subscription state

  • Pros: one code path; matches the original "daily batch job" direction in CSL-383.
  • Cons: the sync carries no price, so a cooling-off notice can't state the amount charged; a daily run can land a day late for "on the renewal day".

Option B: Webhook only

  • Pros: the renewal webhook carries the amount and arrives on the day.
  • Cons: a lost webhook means a missed legal notice that nobody sees; reminders have no event to start from, since they go out before the charge.

Option C: Log table, written by the webhook and a daily job

Every notice is a SubscriptionNotice row, rendered and saved before sending. A SubscriptionCharge ledger stores each charge from the purchase and renewal webhooks. The renewal webhook queues and sends the cooling-off notice straight away. A daily send_subscription_notices job queues due reminders from the synced billing period, recovers the renewal transaction (with its real amount) from RevenueCat for any annual renewal whose webhook never arrived or came without an amount, and sends or retries every pending row.

  • Pros: notices state real amounts; a lost webhook is caught next day; the row is the compliance record and the retry queue at once; a dark flag only gates the send.
  • Cons: two entry points to keep consistent; the backstop depends on RevenueCat's transactions endpoint, so a failed fetch delays the notice to the next day.

Decision

We will use Option C. A cooling-off notice is keyed by the store transaction id, and the backstop recovers that same transaction, so the two entry points can't send twice; a row lock around each send stops the webhook and the job sending the same row.

Consequences

Positive

  • Support and counsel can read every email exactly as sent, in the Django admin.
  • SUBSCRIPTION_NOTICES_ENABLED off still records and renders notices, so QA can check content in dev before anything is sent.
  • The schedule lives in PaywallSettings as working-day fields, served to the app too, so the paywall disclosure and the job read the same numbers.

Negative

  • Any user deletion blanks the recipient's email (a pre_delete receiver) but keeps the row under the Firebase UID (SET_NULL), matching the UID-keyed ledger CSL-478 leaves behind. The evidence is then "sent to the address on file for this UID", plus the SES message id.
  • Dates are UTC: we store no subscriber timezone.
  • A reminder states the last amount charged; it can't know a future price change (CSL-500).

Risks & Mitigations

  • Risk: notices pile up while the flag is off, then all send when it is turned on. → Mitigation: each row has a send_by date; a pending row past it is marked expired, not sent.
  • Risk: SES is down for days. → Mitigation: five attempts, then failed with an error log (Sentry); the daily command exits non-zero when every send fails.
  • ADR-0012: Amazon SES for transactional email.
  • .context/27-dmcc-subscription-reminders.md — the requirement, counsel's answers, open questions.
  • .context/33-payments-entitlements.md — the RevenueCat sync this builds on.