UMMAH FLOWS · 06 Verified against code · Aug 2026

Recurring giving · plans, links & the billing tick

Subscriptions & Giving

How a merchant turns one-off donors into recurring givers: the Plan, Add-on and Coupon catalogue, the hosted subscribe link, and the worker tick that charges every due subscription through the same split machinery as a one-off payment — with dunning, lifecycle controls, donor invoices and card upkeep. The design principle throughout: the worker only schedules; the backend moves the money. Every claim below was verified in the platform code.

Section 1

The engine in one view

Subscriptions are Ummah's recurring-giving engine. A merchant publishes a catalogue of Plans (with optional Add-ons and Coupons), a donor joins either through the merchant's own server (POST /v1/subscriptions) or through a hosted subscribe link at /sub/[token], and from then on a worker job wakes every minute, finds subscriptions whose nextChargeAt has fallen due, and asks the backend — over an HMAC-signed internal call — to charge the saved card. Each successful charge books its splits at authorisation exactly like a one-off payment and issues the donor an invoice; each failure enters dunning.

The recurring loop: catalogue → subscription → tick → charge

FIG 1 · engine
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  cat["Plans · Add-ons · Coupons
merchant catalogue"]:::plain join["Subscribe
POST /v1/subscriptions
or hosted /sub/:token"]:::plain subn["Subscription
ACTIVE · nextChargeAt"]:::ummah tick["subscription-tick worker
minute job · Redis lock"]:::ummah chg["Backend internal tick
MIT charge · frozen splits"]:::ummah ok["SubscriptionCharge
Invoice emailed to donor"]:::sub dun["Dunning
DunningAttempt retries"]:::warn fin["Cancelled"]:::danger cat --> join --> subn subn -->|"nextChargeAt due"| tick tick -->|"X-Internal-Signature"| chg chg -->|"paid"| ok ok -->|"advance nextChargeAt"| subn chg -->|"declined"| dun dun -->|"retry"| chg dun -->|"hard or exhausted"| fin classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
Ummah-owned action money landed / success caution / retrying terminal failure neutral step
The scheduling half (worker) and the money half (backend) are deliberately different services: a stuck or duplicated tick can never double-charge, because the backend's internal endpoint is idempotent per billing period.

Plan

The recurring template

A merchant-owned preset: amountMinor, currency (default GBP), an interval (DAILY | WEEKLY | MONTHLY | YEARLY with an every multiplier), and an end behaviour — run UNTIL_CANCELLED or stop after a FIXED_COUNT of charges. Plans archive in place; they are never hard-deleted, because live subscriptions reference them historically.

Subscription

The living agreement

One donor's commitment to one plan, carrying the saved card and the single most important field in this page: nextChargeAt. The tick selects on status ACTIVE and nextChargeAt <= now — nothing else drives billing. Lifecycle verbs pause, resume and cancel exist on the API, portal and back-office surfaces alike.

SubscriptionCharge

The period ledger

Every billing period lands a SubscriptionCharge row. Success also issues an Invoice — a donor receipt with an atomic per-merchant number. Failure opens a DunningAttempt trail with hard/soft categorisation and configurable retry.

One distinction worth pinning early: the plans module prices what donors pay merchants, not what merchants pay Ummah. Platform economics — commission, Client cuts, per-scheme rates — are resolved and frozen by the split machinery described in Pricings, Splits & Fees; nothing on this page changes them.

ummah-platform-backend/prisma/schema.prisma:1677-1822, 1699, 1746, 1851, 1938, 1986, 2021, 2070 · ummah-platform-worker/src/workers/subscription-tick.processor.ts:9-16

Section 2

The vocabulary

Ten nouns that carry the whole feature
TermWhat it isKey fields / rules
PlanMerchant-owned recurring-charge template shown to donorsamountMinor (BigInt), currency default "GBP", interval + every (bi-weekly = WEEKLY every 2), endBehavior, totalCharges (required for FIXED_COUNT), lineItems preset basket whose sum must equal amountMinor, status ACTIVE | ARCHIVED
AddOnA per-period extra layered on top of a planManaged beside plans in portal and /v1; coupon targeting can name add-ons explicitly
CouponA discount code donors redeem at subscribe timekind PERCENT | FIXED; duration ONCE | REPEATING | FOREVER; appliesToPlanIds / appliesToAddOnIds (empty = all); maxRedemptions; code unique per merchant, uppercase
CouponRedemptionThe audit row behind every applied couponTracks each use so maxRedemptions can be enforced
SubscriptionLinkHosted subscribe page token, sibling of payment linksRaw token shown once; only an env-bound peppered hash persists — re-copying means rotating
SubscriptionDonor + plan + saved card + schedulestatus, nextChargeAt; verbs pause / resume / cancel / retry-now
SubscriptionChargeRow per billing period chargeWritten by the backend's internal tick endpoint, idempotent per period
DunningAttemptRetry ledger for failed chargesDunningCategory hard / soft, DunningOutcome, configurable retry schedule
InvoiceDonor receipt for a successful chargeAtomic per-merchant invoiceCounter, line snapshot (basket + negative coupon line), PDF on demand, hash-tokenised public download
PaymentMethodUpdateLinkHosted card-refresh token at /u/[token]Zero-value auth replaces the stored card without charging — the upkeep path for expiring cards

Section 3

The catalogue: plans, add-ons & coupons

Merchant (portal or sk_ key) Backend plans module

The catalogue is dual-surfaced: a merchant manages it interactively at api/portal/plans, api/portal/add-ons and api/portal/coupons (JWT, @RequireScope('MERCHANT')), or programmatically at /v1/plans and /v1/coupons behind the sk_-key ApiKeyGuard. Portal screens live under subscriptions/plans, subscriptions/links and subscriptions/invoices.

Plan shape

  • Amount and cadence. amountMinor in minor units, currency defaulting to GBP, an interval of DAILY | WEEKLY | MONTHLY | YEARLY and an every multiplier defaulting to 1 — a fortnightly plan is WEEKLY with every: 2.
  • End behaviour. UNTIL_CANCELLED (the default) runs until someone stops it; FIXED_COUNT requires totalCharges and stops after that many charges — the natural shape for "£30 over three months" appeals.
  • Preset basket. lineItems is an optional JSON basket whose lines must sum exactly to amountMinor; the same snapshot later reappears on invoices.
  • Archive, never delete. PlanStatus is ACTIVE | ARCHIVED. Archiving hides a plan from new subscribers while existing subscriptions keep charging against it; POST :id/restore brings it back.

Coupon rules

A coupon is PERCENT or FIXED (CouponKind), and lasts ONCE, REPEATING or FOREVER (CouponDuration). Targeting is via appliesToPlanIds and appliesToAddOnIds — empty lists mean the coupon applies to everything. Codes are unique per merchant and uppercase; maxRedemptions caps total uses, and every application writes a CouponRedemption row. Merchant servers can pre-check a code with POST /v1/coupons/validate; the hosted subscribe page has its own public twin (Section 4).

Worked example — coupons on a £10.00 monthly plan
CouponKindDurationFirst chargeLater charges
RAMADAN20PERCENT 20ONCE£8.00£10.00
FRIEND250FIXED £2.50FOREVER£7.50£7.50

The discount changes what the donor pays the merchant; the platform's cut of each charge is still resolved from the store's split configuration at charge time — see Pricings, Splits & Fees for that arithmetic.

ummah-platform-backend/src/modules/plans/portal-plans.controller.ts:24-165 · ummah-platform-backend/src/modules/plans/v1-plans.controller.ts:20-140 · ummah-platform-backend/src/modules/plans/v1-coupons.controller.ts:26-96 · ummah-platform-backend/prisma/schema.prisma:1677-1822

Section 4

Flow: becoming a subscriber

Donor browser Checkout app /sub/[token] Backend public-subscribe Adyen

There are two doors in. A merchant's own server can create a subscription directly with POST /v1/subscriptions against a saved card. The self-serve door is the hosted SubscriptionLink: the merchant mints a link in the portal (subscriptions/links), shares the /sub/[token] URL, and the checkout app walks the donor through plan, coupon, card and confirmation.

The hosted walk is four public endpoints on public-subscribe.controller.ts, all keyed by the token:

  • GET checkout/subscribe/:token resolves the link — plan, basket and merchant branding.
  • POST checkout/subscribe/:token/validate-coupon previews a code against the coupon rules before any money moves.
  • POST checkout/subscribe/:token/session mints a checkout session for the initial payment plus card tokenisation — the split plan for that first charge is resolved and frozen here, exactly as for a one-off payment.
  • POST checkout/subscribe/:token/complete creates the subscription once the initial payment stands, setting nextChargeAt for the following period.

Hosted subscribe: token → coupon → session → complete

FIG 2 · subscribe link
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
  participant D as Donor browser
  participant H as Hosted page /sub/[token]
  participant B as Backend
  participant A as Adyen
  D->>H: open subscribe link
  H->>B: GET checkout/subscribe/:token
  B-->>H: plan, basket, branding
  opt donor enters a code
    H->>B: POST :token/validate-coupon
    B-->>H: discount preview or rejection
  end
  H->>B: POST :token/session
  Note over B: initial-payment splits resolved and frozen
  B-->>H: checkout session
  H->>B: card details via the bundled checkout
  B->>A: payment with frozen splits + tokenisation
  A-->>B: Authorised
  A-->>B: RECURRING_CONTRACT webhook
  Note over B: StoredPaymentMethod saved for future MIT charges
  H->>B: POST :token/complete
  B-->>H: subscription created, nextChargeAt set
The RECURRING_CONTRACT webhook arrives asynchronously — the stored card the tick will later charge is landed by the webhook pipeline, not by the browser flow itself.

Token hygiene matches payment links: the raw /sub/[token] value is returned once at creation and only an env-bound peppered hash is stored, so a lost URL cannot be recovered — regenerating mints a new token and kills the old one. Because each charge session freezes its splits server-side, a shared subscribe link can circulate publicly without exposing any pricing surface.

Verdict · OKA clean two-door design: the API door for integrated merchants, the hosted door for everyone else, both converging on the same subscription row and the same frozen-split charge machinery. Irrecoverable tokens are the right trade for shareable links.

ummah-platform-backend/src/modules/subscription-links/public-subscribe.controller.ts:24-51 · ummah-platform-checkout/app/sub/[token]/SubscribeFlow.tsx · ummah-platform-backend/src/modules/subscriptions/v1-subscriptions.controller.ts:33-92 · ummah-platform-backend/src/modules/payment-links/payment-links.service.ts:38-133

Section 5

Flow: the billing tick

Worker subscription-tick Redis Backend internal/subscriptions Adyen

Billing is driven by one BullMQ repeatable job that fires every minute. Its jobId is deterministic, so a redeployed or restarted worker re-registers the same job instead of stacking duplicates. Each run takes a Redis lock (only one tick works at a time across the fleet), selects subscriptions with status ACTIVE and nextChargeAt <= now, and — for each one — calls the backend at POST internal/subscriptions/:id/tick.

That internal hop is the trust boundary. The endpoint is excluded from Swagger and guarded by internal-hmac.guard.ts: the worker signs the raw request body with the shared INTERNAL_API_SECRET and sends the digest as X-Internal-Signature. The endpoint is idempotent per billing period — a replay after success (crashed worker, retried job, overlapping tick) returns safely without charging twice. Behind it, the backend runs the full merchant-initiated-transaction machinery: saved card via ContAuth, a split session with the fee plan resolved and frozen per charge, and the resulting authorisation booking payee net, Client fee and Ummah commission just as Pricings, Splits & Fees describes.

The tick: worker schedules, backend charges

FIG 3 · billing tick
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
  participant W as Worker subscription-tick
  participant R as Redis
  participant B as Backend internal/subscriptions
  participant A as Adyen
  Note over W: BullMQ repeatable job, every minute, deterministic jobId
  W->>R: acquire tick lock
  R-->>W: lock held
  W->>W: select ACTIVE subs with nextChargeAt due
  loop each due subscription
    W->>B: POST internal/subscriptions/:id/tick
    Note over W,B: X-Internal-Signature = HMAC of raw body, INTERNAL_API_SECRET
    B->>A: MIT charge — saved card, frozen splits
    A-->>B: result
    B-->>W: 200, idempotent per billing period
  end
  Note over B: success writes SubscriptionCharge + Invoice · failure opens dunning
The worker never touches Adyen for subscriptions. If the HMAC secret drifts between services the tick fails closed — subscriptions stop charging rather than charging unsigned.
Verdict · OKThree independent safety nets — deterministic jobId, Redis lock, per-period idempotency — mean the worst failure mode of the scheduler is a late charge, never a double one. This is the same schedule/charge separation the payout processor uses.

ummah-platform-worker/src/workers/subscription-tick.processor.ts:9-16 · ummah-platform-backend/src/modules/subscriptions/internal-subscriptions.controller.ts:1-10 · ummah-platform-backend/src/modules/subscriptions/internal-hmac.guard.ts · ummah-platform-backend/src/modules/checkout/checkout.controller.ts:40-57

Section 6

Lifecycle & dunning

Merchant (v1 / portal) Backend subscriptions module Ummah staff (admin)

Three lifecycle verbs — pause, resume, cancel — exist in triplicate: on the /v1 API (v1-subscriptions.controller.ts), in the merchant portal, and on the back-office admin surface (admin-subscriptions.controller.ts, screen app/(app)/subscriptions). Pausing takes a subscription out of the tick's selection; resuming puts it back; cancelling is terminal.

When a period charge fails, the subscription enters dunning instead of dying. Each retry is recorded as a DunningAttempt; the decline is classified by DunningCategory as hard (the card is gone — stolen, closed, never going to work) or soft (insufficient funds, issuer wobble — worth retrying), and each attempt closes with a DunningOutcome. The retry schedule is configurable rather than hard-coded. A merchant who has fixed the underlying problem — typically after the donor updates their card via a pm-update link (Section 8) — can force an immediate retry with POST /v1/subscriptions/:id/retry-now rather than waiting for the next scheduled attempt.

Subscription states and what moves them

FIG 4 · lifecycle
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  act["ACTIVE"]:::sub
  pau["Paused"]:::warn
  chgn["Period charge"]:::ummah
  dun["In dunning
DunningAttempt trail"]:::warn done["Complete
totalCharges reached"]:::plain cxl["Cancelled"]:::danger act -->|"pause"| pau pau -->|"resume"| act act -->|"cancel"| cxl act -->|"nextChargeAt due"| chgn chgn -->|"success"| act chgn -->|"FIXED_COUNT limit"| done chgn -->|"soft decline"| dun dun -->|"scheduled retry or retry-now"| chgn dun -->|"hard decline / retries exhausted"| cxl classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
billing normally charge machinery paused / retrying terminal
ACTIVE is the only status the tick selects, which is what makes pause genuinely free: no flags to check downstream, the paused subscription simply never appears in the due query. The precise terminal handling after exhausted retries follows the configured dunning policy.

Refunding a subscription charge is not a subscription concern at all: a SubscriptionCharge is an ordinary transaction underneath, so it flows through the standard approval pipeline in Refunds & Approvals.

Verdict · Check configThe hard/soft split is the donor-experience lever: soft declines quietly retry, hard declines need a human (or a pm-update link) in the loop. Because the retry schedule is configuration, confirm the deployed defaults before writing donor-facing comms about "we'll try again".

ummah-platform-backend/src/modules/subscriptions/v1-subscriptions.controller.ts:33-92 · ummah-platform-backend/src/modules/subscriptions/admin-subscriptions.controller.ts · ummah-platform-backoffice/app/(app)/subscriptions · ummah-platform-backend/prisma/schema.prisma:1986, 2021

Section 7

Invoices: donor receipts

Backend invoices module SQS notification pipeline Donor inbox

Every successful charge issues an Invoice — and only successful ones; failed periods produce dunning rows, not paper. Numbering comes from an atomic per-merchant invoiceCounter, so each merchant gets a gapless, race-safe sequence regardless of how many charges land in the same tick.

  • Line snapshot. The invoice freezes the basket as charged — the plan's lineItems, any add-ons, and the coupon as a negative line — so later plan edits never rewrite a donor's history.
  • PDF on demand. Nothing is pre-rendered; the PDF is generated when the download is requested.
  • Hash-tokenised download. The donor's emailed link carries a public, hash-tokenised URL — no portal login required, same peppered-hash token discipline as the other hosted surfaces. The email itself rides the platform's SQS notification pipeline.
  • Failure isolation. Invoice issuance failures never break charging — a receipt hiccup must not stop a donation, so the charge stands and the invoice path is retried or repaired separately.

These are donor receipts, not platform fee invoices.

An Ummah Invoice documents what a donor paid a merchant. It says nothing about what the merchant paid Ummah — commission and fee economics are booked by the split machinery and surfaced through statements, per Pricings, Splits & Fees. Merchants expecting a VAT-style invoice for platform fees will not find it here.

Verdict · OKThe atomic counter and the never-block-charging rule are exactly the right priorities: money first, paperwork always catches up, sequence integrity guaranteed.

ummah-platform-backend/src/modules/invoices/invoices.service.ts · ummah-platform-backend/prisma/schema.prisma:2070 · ummah-platform-notification/src/consumer/notifications.consumer.ts:7-145

Section 8

Card upkeep & the tips placeholder

Merchant server (sk_) Donor browser /u/[token] Backend pm-update module

A recurring engine lives or dies on card freshness. When a stored card is expiring — or dunning has flagged a hard decline — the merchant mints a PaymentMethodUpdateLink via the /v1 surface (pm-update-api.controller.ts) and sends the donor to the hosted page at /u/[token]. The walk mirrors the subscribe link, minus the money:

  • GET checkout/pm-update/:token resolves the link;
  • POST checkout/pm-update/:token/session opens a session that runs a zero-value authorisation — the new card is verified and tokenised without charging a penny;
  • POST checkout/pm-update/:token/complete swaps the stored card, and the return page confirms.

The refreshed card is then simply what the next tick charges — no subscription edit, no re-subscribe, and a merchant can pair it with POST /v1/subscriptions/:id/retry-now to clear a dunning state on the spot.

Donor tips: honestly, not shipped

The portal navigation shows a subscriptions/tips screen, but app/(app)/subscriptions/tips/page.tsx renders a ComingSoon placeholder ("Voluntary donor tips… configure from the SDK setup page"), and the subscription-links backend module has no tip fields at all. Voluntary donor tips are roadmap, not a live feature — nothing in the session or split path carries a tip today.

ummah-platform-backend/src/modules/payment-method-updates/pm-update-public.controller.ts:21-39 · ummah-platform-checkout/app/u/[token]/UpdateCard.tsx · ummah-platform-backend/prisma/schema.prisma:2121 · ummah-platform-merchant-portal/app/(app)/subscriptions/tips/page.tsx

Section 9

Implementation notes

The endpoint surface, by door
EndpointAuth / guardPurpose
api/portal/plans · api/portal/add-ons · api/portal/couponsJWT + RolesGuard, @RequireScope('MERCHANT')Catalogue CRUD, archive + POST :id/restore
/v1/plans · /v1/coupons (+ POST /v1/coupons/validate)ApiKeyGuard (sk_)Programmatic catalogue management and coupon pre-checks
POST /v1/subscriptionsApiKeyGuard (sk_)Create a subscription server-side against a saved card
GET checkout/subscribe/:token → validate-coupon → session → completePublic, token-resolved (peppered hash)Hosted subscribe walk: resolve, discount preview, initial payment + tokenisation, create
POST internal/subscriptions/:id/tickX-Internal-Signature HMAC (internal-hmac.guard.ts), Swagger-excludedCharge one due subscription; idempotent per billing period
POST /v1/subscriptions/:id/pause · resume · cancelApiKeyGuard (sk_); portal + admin twinsLifecycle verbs, mirrored across all three surfaces
POST /v1/subscriptions/:id/retry-nowApiKeyGuard (sk_)Force an immediate dunning retry
pm-update-api.controller.ts mint + GET checkout/pm-update/:token → session → completesk_ mint; public token walkZero-value-auth card refresh at /u/[token]
admin-subscriptions.controller.tsJWT + RolesGuard, @RequireScope('STAFF')Back-office oversight at app/(app)/subscriptions

Data model notes

  • Catalogue: Plan (amount, cadence, end behaviour, basket, archive-in-place), AddOn, Coupon + CouponRedemption — schema region 1677-1822.
  • Runtime: SubscriptionLink (token hash), Subscription (status, nextChargeAt), SubscriptionCharge per period, DunningAttempt with DunningCategory/DunningOutcome, Invoice with per-merchant invoiceCounter — schema lines 1851-2070.
  • Cards: Customer (schema 1612) and StoredPaymentMethod (schema 1637) landed by the RECURRING_CONTRACT webhook; PaymentMethodUpdateLink (schema 2121) for refresh.
  • Money: subscription charges reuse the checkout split machinery — frozen per-charge fee plans, MIT via ContAuth — so nothing subscription-specific ever computes a fee. Economics live in Pricings, Splits & Fees.

ummah-platform-backend/src/modules/subscriptions/ · ummah-platform-backend/src/modules/subscription-links/ · ummah-platform-backend/src/modules/plans/ · ummah-platform-backend/src/modules/invoices/ · ummah-platform-backend/src/modules/payment-method-updates/ · ummah-platform-worker/src/workers/subscription-tick.processor.ts · ummah-platform-backend/prisma/schema.prisma:1612-2121

Section 10

Gaps & recommendations

The engine itself is complete and defensively built. The gaps are at the edges: a placeholder screen, a receipts-versus-invoices ambiguity, and upkeep that waits to be asked.

P1

No platform-fee invoicing on this surface

Invoice rows are donor receipts only. Merchants who need a formal document for the platform fees they pay have nothing to download — statements are the nearest artefact. Decide whether platform-fee invoicing is roadmap or statements-are-enough, and label the portal invoices screen so support isn't fielding the confusion.

P1

Dunning policy is invisible to merchants

Retry behaviour is configurable, but the maps show no merchant-facing surface that displays the schedule in force. A merchant cannot tell a donor when the next attempt will run. Expose a read-only dunning policy view in the portal alongside the subscription detail.

P2

Expiring-card upkeep is pull-based

pm-update links are minted on demand; no automated expiry sweep appears in the maps. A scheduled job that finds cards expiring before their subscription's next few charges and emails pm-update links proactively would convert silent hard declines into quiet self-service fixes.

P2

Donor tips ships as a ComingSoon shell

subscriptions/tips renders a placeholder and the subscription-links backend has no tip fields. Keep it labelled roadmap; when built, tips must flow through the session amount and the frozen split plan — never as a side-channel adjustment.