Subscription Lifecycle
Trial → active → expired, with manual re-subscribe. Renewal is user-driven, not webhook-driven. A 5-minute cron expires stale trials/subs and (conditionally) unpublishes the catalog.
1. Overview
Every User may have at most one UserSubscription. A new OWNER is auto-assigned a 14-day trial on signup. The subscription-cron.ts scheduler runs every 5 minutes; update-expired-trials.ts flips trial/active/cancelled rows to expired when trialEndsAt or currentPeriodEnd is past. Razorpay in this codebase is for the seller's buyer checkout — there is no Razorpay subscription webhook. A user renews by manually re-subscribing, which creates a new row.
2. Architecture
3. Data model
status enum at schema.prisma:L1352. past_due is defined but unused. SubscriptionPayment is written but only read for admin revenue reporting.
4. Key flows
4.1 Signup → trial → subscribe
4.2 Cron expiry
5. Lifecycle
6. Key files
- backend/routes/subscriptions.ts —
/subscribe,/verify,/cancel; L123-L138 blocks new subscribe when a cancelled row still hascurrentPeriodEnd > now; L153 trial eligibility check - backend/middleware/subscription.ts —
requireSubscription(onlyactive|trialpass); L118 atomic orders counter via raw SQLUPDATE ... WHERE ordersCreated < limit - backend/lib/subscription-limits.ts — plan limits + duplicate
incrementUsage(NOT race-safe) - backend/schedulers/subscription-cron.ts:L30 — cron
*/5 * * * *, TZprocess.env.TZ || 'UTC' - backend/jobs/update-expired-trials.ts — expiry worker
- backend/jobs/send-trial-reminders.ts — 7-day + 1-day windows; not wired into cron
- backend/prisma/seed-plans.ts — creates
SubscriptionPlanrows
7. Env vars & config
| Var | Required | Purpose |
|---|---|---|
RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET | yes | Order + signature verify |
TZ | recommended | Cron timezone (defaults UTC) |
SMTP_* / mail provider | yes (for reminders) | Trial reminder emails |
8. Gotchas & troubleshooting
- No Razorpay subscription webhook → Renewal = user manually re-subscribes. If they don't, the cron expires them. Don't look for
/webhooks/razorpay/subscription; it doesn't exist. - Cancelled + in-period users are LOCKED OUT of gated routes →
requireSubscriptiononly acceptsactive|trial. UI may promise grace-after-cancel; backend disagrees. Real UX gotcha. - Trial reminders not wired into the scheduler →
send-trial-reminders.tsexists but nothing calls it on a cron. Users get no heads-up before expiry. Wire intosubscription-cron.ts. - Two
incrementUsageimpls → middleware/subscription.ts:L118 uses raw SQL, race-safe. lib/subscription-limits.ts does read-modify-write, NOT race-safe. Always prefer the middleware. - Race between cron and re-subscribe → A re-subscribe exactly at the cron tick can see the row flipped mid-flow. Defense at subscriptions.ts:L123-L138 blocks new subs while a cancelled row still has
currentPeriodEnd > now. - Trial eligibility check → subscriptions.ts:L153 checks for any prior sub with non-null
trialEndsAt. No user flag. Deleting old sub rows = trial abuse. - Catalog auto-unpublishes on expiry → If the plan included Shareable Catalog,
update-expired-trials.tsflipsStore.published=false. Shopper gets 404. past_dueis dead → Enum value exists, no code sets it.
9. Extension points
- Recurring auto-renewal → integrate Razorpay Subscriptions API (separate from Orders API). Add webhook handler.
- Grace period → relax
requireSubscriptionto allowcancelledwithcurrentPeriodEnd > now. One-line; mind UX consistency. - Wire trial reminders → call
send-trial-reminders.tsfromsubscription-cron.tsdaily. - Prorated downgrades → not supported today; users expire and re-subscribe at new plan.