§ unswayed-backend
Subscriptions & billing (Phase 10)
Subscriptions & billing (Phase 10)
Phase 10 is the paid-plan lifecycle on Stripe — eleven authenticated endpoints under
§44, one public webhook under §45, and a daily cron. It lives in src/subscriptions/;
ADR-0034 records the decisions.
There is no subscriptions table
The single most load-bearing decision is inherited and embraced: the active
subscription state lives on User — nine denormalized columns (subscription_plan
holds the plan name: Freemium, 30-Day Trial, Expired, or a real plan name;
trial and period bounds; the Stripe ids; a JSON array of reminder-dedupe keys) — and
every state change is audited in the append-only SubscriptionHistory (with a
denormalized plan_name so history survives plan deletion). The legacy codebase also
contained a subscriptions table and model with zero references — dead scaffolding,
deliberately not ported. Every "what is this user's subscription?" read flows through
one SubscriptionInfoService, which ports the legacy HasSubscription trait exactly:
the frozen 7-key wire shape, whole-day-floored days_remaining, trial-versus-paid
branching.
Plans are reference data: the exact 9 legacy rows (3 applicant, 6 employer; monthly
and yearly variants share a name and differ by slug + billing_period) seed via the
idempotent seed:subscription-plans CLI. Two pricing subtleties are frozen: price is
display-only — what Stripe actually charges is stripe_unit_amount in cents, with
the 15% annual discount baked into the yearly amounts (e.g. 50990, 178500), not a
coupon — and every serialized plan appends a stripe_amount alias the frontend
depends on.
One port to Stripe, with the version pinned
StripeService (behind the abstract BillingPort) is the only file that knows the
SDK. The client is built lazily with the API version pinned to the SDK's own
literal — typed so an SDK upgrade that moves the pin becomes a compile error, not a
silent behavior change. That matters because of the phase's flagship fix: the legacy
read current_period_start/end off the top-level subscription object, but newer Stripe
API versions moved those onto items.data[0] — so subscription dates silently came
back null. The rebuild's period mapper reads item-level first with a top-level
fallback, fixture-tested against both shapes; under the pinned version the item-level
read is load-bearing.
Everything else ports verbatim: the lazy customer (metadata user_id/user_type is
the real linkage — the cosmetic name is omitted since the rebuilt User has no name
columns), the checkout session (the ?session_id={CHECKOUT_SESSION_ID} template,
remaining trial days carried into Stripe, the June-promo coupon gate), both cancel
branches, the prorated plan swap, the card mapping, SetupIntents, the Billing Portal.
Unconfigured keys are a feature: every Stripe-touching endpoint degrades to its
exact legacy 500 string, so the app boots and the entire test pyramid runs without
credentials.
The eleven endpoints, and a third pagination dialect
/api/subscriptions/{plans,current,history,checkout,verify-checkout,cancel,resume, change-plan,customer-portal,payment-methods,setup-intent} — all behind JWT plus the
account-status guard, both user types (plans filter by the caller's type; buying a
plan for the wrong type is the exact 403 This plan is not available for your account type.). Notable frozen shapes:
historyreproduces the full Laravel API-resource paginator —data/links{first,last,prev,next}/meta{current_page,from,last_page,links[],path, per_page,to,total}, complete with the UrlWindow page-link slider and«labels. That's the third pagination dialect in the contract (after the repo-widepaginationblock and §7/§8'smeta), isolated in one reusablelaravelPaginatehelper. History items carryamount_paidas a 2-decimal string and the nestedsubscription_planrelation.verify-checkoutis the idempotent convenience fallback — it activates only when the session's subscription id differs from the user's stored one; the webhook remains the source of truth.cancelfinally persists thereasonthe legacy validated and then threw away (intoSubscriptionHistory.notes, on the immediate branch — at-period-end writes no row; the eventual webhookdeletedevent owns it).
The webhook: raw bytes, raw bodies, replay-safe
POST /api/stripe/webhook is public and signature-verified over the raw request
bytes (rawBody: true app-wide). Its §45 response contract deliberately bypasses the
standard envelope: once the signature verifies it always answers the raw
200 {"status":"success"} — including unhandled event types — and only a signature
failure gets 400 {"error":"Invalid signature"}. Seven events drive the exact legacy
mutations and history rows (active, upgraded, cancelled, renewed with the
cents→dollars conversion). Two legacy TODO stubs are now real: invoice.payment_failed
queues a dunning email and trial_will_end a trial-ending email — both through the
Phase 1 mail queue, best-effort, never breaking the 200.
The rebuild adds event-id idempotency (ProcessedStripeEvent): a Stripe redelivery
of an already-processed event skips cleanly (still 200), and the id is recorded only
after successful processing — so a handler failure stays retryable.
The cron that never ran
The legacy shipped a subscriptions:check-expirations command — with reminder
thresholds, dedupe keys, downgrade logic — and never scheduled it. No reminder ever
fired in production; no lapsed trial was ever downgraded automatically. The rebuild
registers a real @nestjs/schedule daily job (09:00 UTC) running an idempotent
SubscriptionExpiryService: 14/7/3/1-day reminders (keys
14_days/7_days/3_days/24_hours, marked sent before dispatch, one per user
per run, queued email with the dynamic subject), expired trials → Expired, expired
non-Stripe paid subs → Expired/Freemium by type — and Stripe-managed
subscriptions left strictly alone, because the webhooks own them.
Auth integration, and a cross-phase fix
Employer registration (manual and social) now seeds the 30-day trial — the user
fields plus a status: 'trial' history row with the exact legacy notes — and answers
with the 3-key {plan, trial_ends_at, days_remaining: 30} shape. Applicant
registration keeps subscription: null (the frozen §3 sample governs). Every login,
social login, and complete-profile response embeds the full 7-key info object.
Building this phase also surfaced a gap worth recording: the legacy wrapped every
authed route group in checkAccountStatus, but the Phase 7–9 controllers had shipped
with the JWT guard only — a paused or deactivated account could still post, chat, and
share. The AccountStatusGuard sweep (ADR-0034 §7) closed that across all twelve
affected controllers; a non-active account now answers 403 Account is not active
everywhere, as legacy did.
Testing
Six TDD slices (core → Stripe port → endpoints, webhook, cron, auth integration, all
at 100% file coverage with a fully mocked SDK); two e2e suites — subscriptions
(keyless 500 contract, the Laravel paginator deep-equals, trial-on-register) and
stripe-webhook (real offline signatures via the SDK's test-header helper, all
seven events, replay idempotency, the raw bodies) — through the adversarial
author→review→patch pipeline; and a Postman 19-subscriptions folder (the run boots
with the seeded 9 plans via the CLI).
Subpages