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;
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
| Term | What it is | Key fields / rules |
|---|---|---|
Plan | Merchant-owned recurring-charge template shown to donors | amountMinor (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 |
AddOn | A per-period extra layered on top of a plan | Managed beside plans in portal and /v1; coupon targeting can name add-ons explicitly |
Coupon | A discount code donors redeem at subscribe time | kind PERCENT | FIXED; duration ONCE | REPEATING | FOREVER; appliesToPlanIds / appliesToAddOnIds (empty = all); maxRedemptions; code unique per merchant, uppercase |
CouponRedemption | The audit row behind every applied coupon | Tracks each use so maxRedemptions can be enforced |
SubscriptionLink | Hosted subscribe page token, sibling of payment links | Raw token shown once; only an env-bound peppered hash persists — re-copying means rotating |
Subscription | Donor + plan + saved card + schedule | status, nextChargeAt; verbs pause / resume / cancel / retry-now |
SubscriptionCharge | Row per billing period charge | Written by the backend's internal tick endpoint, idempotent per period |
DunningAttempt | Retry ledger for failed charges | DunningCategory hard / soft, DunningOutcome, configurable retry schedule |
Invoice | Donor receipt for a successful charge | Atomic per-merchant invoiceCounter, line snapshot (basket + negative coupon line), PDF on demand, hash-tokenised public download |
PaymentMethodUpdateLink | Hosted 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
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.
amountMinorin minor units,currencydefaulting toGBP, anintervalofDAILY | WEEKLY | MONTHLY | YEARLYand aneverymultiplier defaulting to 1 — a fortnightly plan isWEEKLYwithevery: 2. - End behaviour.
UNTIL_CANCELLED(the default) runs until someone stops it;FIXED_COUNTrequirestotalChargesand stops after that many charges — the natural shape for "£30 over three months" appeals. - Preset basket.
lineItemsis an optional JSON basket whose lines must sum exactly toamountMinor; the same snapshot later reappears on invoices. - Archive, never delete.
PlanStatusisACTIVE | ARCHIVED. Archiving hides a plan from new subscribers while existing subscriptions keep charging against it;POST :id/restorebrings 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).
| Coupon | Kind | Duration | First charge | Later charges |
|---|---|---|---|---|
RAMADAN20 | PERCENT 20 | ONCE | £8.00 | £10.00 |
FRIEND250 | FIXED £2.50 | FOREVER | £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
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/:tokenresolves the link — plan, basket and merchant branding.POST checkout/subscribe/:token/validate-couponpreviews a code against the coupon rules before any money moves.POST checkout/subscribe/:token/sessionmints 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/completecreates the subscription once the initial payment stands, settingnextChargeAtfor 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
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.
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
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
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
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;
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.
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
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.
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
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/:tokenresolves the link;POST checkout/pm-update/:token/sessionopens a session that runs a zero-value authorisation — the new card is verified and tokenised without charging a penny;POST checkout/pm-update/:token/completeswaps 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
| Endpoint | Auth / guard | Purpose |
|---|---|---|
api/portal/plans · api/portal/add-ons · api/portal/coupons | JWT + 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/subscriptions | ApiKeyGuard (sk_) | Create a subscription server-side against a saved card |
GET checkout/subscribe/:token → validate-coupon → session → complete | Public, token-resolved (peppered hash) | Hosted subscribe walk: resolve, discount preview, initial payment + tokenisation, create |
POST internal/subscriptions/:id/tick | X-Internal-Signature HMAC (internal-hmac.guard.ts), Swagger-excluded | Charge one due subscription; idempotent per billing period |
POST /v1/subscriptions/:id/pause · resume · cancel | ApiKeyGuard (sk_); portal + admin twins | Lifecycle verbs, mirrored across all three surfaces |
POST /v1/subscriptions/:id/retry-now | ApiKeyGuard (sk_) | Force an immediate dunning retry |
pm-update-api.controller.ts mint + GET checkout/pm-update/:token → session → complete | sk_ mint; public token walk | Zero-value-auth card refresh at /u/[token] |
admin-subscriptions.controller.ts | JWT + 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),SubscriptionChargeper period,DunningAttemptwithDunningCategory/DunningOutcome,Invoicewith per-merchantinvoiceCounter— schema lines 1851-2070. - Cards:
Customer(schema 1612) andStoredPaymentMethod(schema 1637) landed by theRECURRING_CONTRACTwebhook;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.
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.
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.
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.
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.