Skip to content

Subscription & Payments Sprint Plan — Cross-Functional War Room Output

Context: 47 issues from Subscription & Payments Deep-Dive Generated: 2026-04-06 | Last Updated: 2026-04-07 | Method: Cross-Functional War Room (PM + Engineer + Designer) Participants: PM (business impact), Engineer (effort/risk), Designer (UX impact) Related: Deep-Dive | Review Findings Verification Note: All 47 issues re-verified against current codebase on 2026-04-07. 4 issues resolved (#39 React Query migration, #40 URL fix, #41 console.log removed, #42 stripeProductId fixed). #47 promoted from Backlog → Sprint 1 (Option B): frontend done (⅗ sub-tasks — route fix, success verification page, failure session page), backend remaining (⅖ — verify endpoint, URL unification). SKIP_VERIFICATION flag in success page until backend ready. 42 issues remain actionable.


Comparative Analysis Scorecard (Apr 6)

Every issue verified against the current codebase state:

Phase Items Resolved Still Needed Notes
Ship Blockers 10 0 10 Security + data integrity + runtime crashes
Sprint 1 13 0 (⅗ FE sub-tasks of #47 done) 13 Core payment/subscription flow broken + payment pages
Sprint 2 20 4 16 #39 #40 #41 #42 (frontend cleanup done)
Backlog 4 0 3 #47 moved to Sprint 1; #44-#46 remain
Total 47 4 42 Revenue-critical system — most issues affect money flow (#47 moved to Sprint 1)

Triage Summary

Bucket Criteria Count Effort
Ship Blocker Cannot deploy. Security exploit, data silently lost, runtime crash, or core payment flow broken. 10 ~90 min
Sprint 1 Subscriptions don't renew, coupons don't work, no subscription history, payment pages broken. 13 ~12 hrs
Sprint 2 Dead code, typos, duplicate logic, config cleanup, frontend consistency. 16 ~7 hrs
Backlog Missing UI pages for subscription/coupon management. 3 Ongoing

Ship Blockers (Pre-Release Gate)

Timeline: ~90 minutes, 1 developer Exit criteria: No security vulnerabilities. Payment amounts recorded correctly. All entities have required columns. No runtime crashes on route load.

Order Issue Description File Change Effort
1 #2 IDOR: Customer portal sessioncreateCustomerPortalSession takes customerStripeId from request body. Any authenticated user can access another customer's Stripe billing portal. payment.controller.ts:28 Replace req.body.customerStripeId with lookup from req.userUser.stripeCustomerId. Update validation schema to remove customerStripeId from body. 15 min
2 #1 Hardcoded webhook secret — LemonSqueezy webhook secret "c3b451346788c32" is hardcoded in source code. webhook.controller.ts:80 Replace with config.lemonSqueezy.webhookSecret from env. Add LEMONSQUEEZY_WEBHOOK_SECRET to .env.example. 5 min
3 #6 Wrong enum in createSubscription — Uses Status.ACTIVE (User entity: "active") instead of SubscriptionStatus.ACTIVE (Subscription entity: different value). New subscriptions get wrong status. subscription.service.ts:44 Change Status.ACTIVESubscriptionStatus.ACTIVE. Fix same issue on line 161 (getMySubscriptions). Remove unused Status import. 5 min
4 #14 Double currency conversion — Webhook handler divides metadata amounts by 100 (cents→dollars), but metadata was stored as dollars. Purchase records have 1/100th of actual amount. stripe.handler.ts:65-77 Remove / 100 from Number(metadata.originalPrice), Number(metadata.discountPrice), Number(metadata.amountPaid). Metadata is already in dollars. 5 min
5 #11 Discount always $0discountPrice and amountPaid hardcoded to 0 in createPaymentForSubscription. Coupon code from payload completely ignored. payment.service.ts:107-108 Implement actual discount calculation: look up coupon by payload.couponCode, call calculateDiscount() (currently unused #22), compute discountPrice and amountPaid from package price. 20 min
6 #3 Coupon entity missing 5 columnsapplyCoupon references startTime, endTime, isApplicableToAllPackages, discountValue, stripePromotionCodeId — none exist on entity. All coupon application is broken. Cupon.ts Add missing columns: @Column({ nullable: true }) startTime: Date, @Column({ nullable: true }) endTime: Date, @Column({ default: 0 }) isApplicableToAllPackages: number, @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) discountValue: number, @Column({ nullable: true }) stripePromotionCodeId: string. Create DB migration. 15 min
7 #4 Payment entity orphaned — No userId or packageId columns. createPayament sets these fields but TypeORM silently ignores them. Payment records have no link to who paid or what they bought. Payment.ts Add @Column({ nullable: true }) userId: number and @Column({ nullable: true }) packageId: number. Optionally add @ManyToOne relations. Create DB migration. 10 min
8 #7 Wrong field on PurchaseprocessStripePayment sets couponCode (string) on Purchase, but entity has couponId (number). Field silently ignored by TypeORM. payment.service.ts:176 Change couponCode: payload.couponCode → look up Coupon by code, set couponId: coupon.id. Handle case where coupon not found. 5 min
9 #8 Dangling router.get — Incomplete router.get statement with no path or handler. Potential crash on route file load. purchase.route.ts:9 Delete the dangling router.get line. 1 min
10 #9 PackageCategory CRUD broken — Service references isDeleted boolean that doesn't exist on entity. All filter/delete/restore operations broken. package-category.service.ts:30,49,52,90,96 Replace all isDeleted: false filters with TypeORM soft-delete queries (withDeleted() / softDelete()). Replace entity.isDeleted = true with repo.softDelete(id). 10 min

Verification Checklist

After completing all 10 ship blockers:

  • POST /payments/stripe/customer-portal-session without body returns portal for authenticated user's own Stripe customer
  • LemonSqueezy webhook secret comes from environment variable (grep for hardcoded string returns 0 results)
  • New subscription created via webhook has SubscriptionStatus.ACTIVE value (not User Status.ACTIVE)
  • Purchase records from webhook have correct dollar amounts (not divided by 100 again)
  • POST /subscriptions/subscribe { couponCode: "SAVE20" } creates checkout with discount applied
  • Coupon entity migration adds all 5 missing columns successfully
  • Payment records have userId and packageId populated after webhook processing
  • Purchase records have couponId (number) set correctly, not couponCode (string)
  • yarn start loads all routes without crash (no dangling router.get)
  • GET /package-categories returns categories (no isDeleted filter crash)

Sprint 1: Make Subscriptions Actually Work (Week 1)

Timeline: ~12 hours, 1 developer North Star: A user can subscribe, renew, cancel, re-subscribe, and apply coupons. Payment result pages verify and display real data. Subscription history is preserved. Exit criteria: Complete Stripe subscription lifecycle works end-to-end. Coupons apply correctly. Payment success/failure pages verify session status. Admin can manage packages with all bulk operations.

# Issue(s) Description File(s) Effort
S1-1 #31 Enable subscription renewal. Uncomment handleInvoicePaymentSucceeded in webhook switch statement. Without this, subscriptions expire and never renew — Stripe fires invoice.payment_succeeded on each billing cycle but the app ignores it. Test the fully-implemented handler (lines 193-259). stripe.handler.ts:26 30 min
S1-2 #17 Change Subscription from OneToOne to ManyToOne with User. Current design allows only ONE subscription per user ever. No upgrade/downgrade history. Requires entity change + migration + updating all queries that assume single subscription. Subscription.ts:28-29, User.ts (inverse relation), subscription queries 2 hrs
S1-3 #12 Use Package.durationInDays for subscription duration. Currently hardcodes 30 days (monthly) / 365 days (yearly), ignoring the Package entity's durationInDays field. Trial duration (14 days default) is never used. subscription.service.ts:37-40 30 min
S1-4 #13 Handle cancelled subscription re-subscribe. When handleSubscriptionUpsert finds a CANCELLED subscription for the same user+package, it silently returns. User cannot re-subscribe to the same package after cancellation. Should create a new subscription record (enabled by S1-2's ManyToOne change). stripe.handler.ts:129-132 30 min
S1-5 #5 Add @ManyToOne relation on Purchase → Coupon. couponId is a plain integer column with no referential integrity. Add proper TypeORM relation for cascading, eager loading, and FK constraint. Purchase.ts:29-30 15 min
S1-6 #18 Standardize coupon permission strings. Mixed "cupon" and "coupon" across permission checks: cupon.view, cupon.read, cupon.delete vs coupon.create, coupon.update. Standardize all to coupon.*. Update seed permissions. cupon.service.ts:51,80,90,174,295,356, seed/data/permission.ts 30 min
S1-7 #16 Remove redundant isDeleted from Coupon. Entity has both isDeleted: boolean AND @DeleteDateColumn(). Service uses manual isDeleted = true instead of TypeORM softDelete(). Remove isDeleted column, migrate data, update all service queries to use TypeORM soft-delete. Cupon.ts:64, cupon.service.ts (all isDeleted refs) 1 hr
S1-8 #15 Apply PackageType enum to Package.type column. type is varchar(50) but PackageType enum is defined and unused. Apply enum constraint to column. Create migration. Package.ts:47-48 15 min
S1-9 #10 Use paymentGateway parameter. createPaymentForSubscription ignores the paymentGateway param and hardcodes PaymentFactory.create("stripe"). Pass through to factory. (LemonSqueezy will still throw from factory — that's fine, but the plumbing should be correct.) payment.service.ts:110 15 min
S1-10 #19 Add missing bulk-permanent-delete route for packages. Frontend useBulkPermanentDeletePackages calls DELETE /packages/bulk-permanent-delete which doesn't exist. Add route + controller + service method. package.routes.ts, package.controller.ts, package.service.ts 45 min
S1-11 #25 Fix missing await in subscription expiration handler. Event listener doesn't await the bulk update query — errors silently swallowed. Also remove duplicate standalone function that's never exported. resetSubscribtionStatus.handler.ts:19 15 min
S1-12 #30 Implement or remove handlePaymentIntentFailed. Empty function body — failed payments are silently ignored. At minimum: log the failure, update Payment record status to FAILED, notify user. stripe.handler.ts:86-88 30 min
S1-13 #47 Fix payment success/failure pages (Option B). 5 sub-tasks: (1) Fix route mismatch ✅ DONE — created new /payment/failure/[sessionId] dynamic route matching backend cancel_url. Legacy /payment/failed kept for backwards compat. (2) Unify backend success URLspayment.service.ts:81 uses ?orderId={packageId} while StripeService.ts:38 uses ?session_id={CHECKOUT_SESSION_ID}. Standardize both to ?session_id={CHECKOUT_SESSION_ID}. (3) Add backend verification endpointGET /payments/verify-session/:sessionId that checks Stripe session status and returns payment/subscription details (packageName, amount, currency, billingPeriod). (4) Success page verifies payment ✅ DONE — reads session_id/orderId from URL, calls useVerifyPaymentSession hook, shows spinner → order details → redirect. SKIP_VERIFICATION flag set to true until backend endpoint exists; flip to false when ready. Includes "Verify again" button on error. (5) Failure page shows session ID ✅ DONE — accepts sessionId prop from dynamic route, displays copyable session reference for support, retry/pricing links. BE (remaining): payment.service.ts:81-82, StripeService.ts:38-39, new payment.controller.ts endpoint, payment.route.ts. FE (done): payment-success-content.jsx, payment-failed-content.jsx, api/payment/index.js, failure/[sessionId]/page.js 2.5 hrs (1.5 hrs BE remaining)

Sprint 1 Acceptance Test

1. yarn migration:run               → All new columns/relations migrated
2. POST /subscriptions/subscribe    → Stripe checkout session created with correct price
3. Complete Stripe test payment     → Webhook fires, Payment+Purchase+Subscription created
4. Verify Purchase.couponId         → Correct coupon ID (not string couponCode)
5. Verify Payment.userId            → Links to paying user
6. Stripe fires invoice.succeeded   → Subscription renewed (new start/end dates)
7. Cancel subscription via portal   → Status = CANCELLED, endDate = now
8. Re-subscribe to same package     → New subscription created (not blocked by old one)
9. Apply coupon "SAVE20"            → Discount reflected in checkout + Purchase record
10. Admin: bulk-permanent-delete    → DELETE /packages/bulk-permanent-delete returns 200
11. Cron fires at midnight          → Expired subscriptions updated (with await)
12. Stripe payment fails            → Payment record marked FAILED, not silently ignored
13. Stripe redirects to /payment/success?session_id=cs_test_xxx → Page shows
    spinner → success (currently via SKIP_VERIFICATION flag; once backend adds
    GET /payments/verify-session/:sessionId, flip flag to false for real verification)
14. Stripe cancel redirects to /payment/failure/cs_test_xxx → Page shows
    session ID for support reference, copy button, retry link ✅ (FE done)
15. Both success_url patterns use ?session_id={CHECKOUT_SESSION_ID} consistently
    (backend change still needed — payment.service.ts uses ?orderId currently)

Sprint 2: Clean Foundation (Week 2-3)

Timeline: ~8 hours, 1-2 developers North Star: Clean codebase, no dead code, consistent naming, unified Stripe initialization. Exit criteria: All typos fixed. Dead code removed. Single Stripe init path. Frontend hooks consistent.

Dead Code & Unused Imports (2 hrs)

# Issue(s) Description Effort
S2-1 #20 Remove 8 unused imports from payment.controller.ts (exp, stripe, Package, IsNull, Not, StripeService, ApiError, httpStatus). 5 min
S2-2 #21 Remove/clean 4 unused imports from payment.route.ts (createSubscriptionCheckout, getLemonsqueezy, createCheckout, sendResponse). 5 min
S2-3 #22 Delete calculateDiscount.ts if integrated into Ship Blocker #5, OR wire it into payment flow if not yet used. 10 min
S2-4 #23 Remove duplicate subscriptionList function — keep getSubscriptionList (uses proper QueryBuilder helper). Update all callers. 30 min
S2-5 #24 Remove duplicate PATCH /:id/restore route registration in package.routes.ts. 5 min
S2-6 #32 Remove unused userId variable in subscription.controller.ts:25. 1 min
S2-7 #33 Remove updateSubscriptionValidation stub or differentiate from create validation. 5 min
S2-8 #34 Remove unreachable return after throw in webhook.controller.ts:39. 1 min

Naming & Typos (1.5 hrs)

# Issue(s) Description Effort
S2-9 #27 Fix createPayamentcreatePayment typo. Update all callers. 10 min
S2-10 #28 Rename Cupon.tsCoupon.ts, Cupon/ directory → Coupon/. Update all imports across codebase. 30 min
S2-11 #29 Rename subscribtionExpire.tssubscriptionExpire.ts, resetSubscribtionStatus.handler.tsresetSubscriptionStatus.handler.ts. Update imports + cron registration. 20 min
S2-12 #43 Fix case-sensitive import in routes/v1/index.ts:11../../modules/v1/Cupon path must match actual directory (will be Coupon after S2-10). Verify all module imports are case-correct for Linux deployment. 10 min

Webhook & Config Cleanup (1.5 hrs)

# Issue(s) Description Effort
S2-13 #26 Remove duplicate LemonSqueezy signature verification in webhook.controller.ts:87-93. Keep one timingSafeEqual check. 10 min
S2-14 #35 Move LemonSqueezy redirectUrl from hardcoded localhost:5500 to config.client_url. 5 min
S2-15 #36 Fix getLemonsqueezy.ts:98 — change user_id: email to user_id: userId (parameter already available). 5 min
S2-16 #37 Unify Stripe initialization. Delete config/stripe.ts (static import). Keep config/getStripe.ts (lazy singleton with DB fallback). Update all imports from stripe.ts to use getStripe(). 30 min
S2-17 #38 Document PaymentFactory.ts limitation: add comment that LemonSqueezy is unsupported, OR implement LemonSqueezyService class implementing IPaymentService. 15 min

Frontend Cleanup (1.5 hrs)

# Issue(s) Description Effort
S2-18 #39 Convert package-categories.js from useState/useEffect to React Query hook. RESOLVED — converted to useQuery with backwards-compatible setRefetch wrapper. Matches app-wide React Query pattern. 30 min 0
S2-19 #40 Fix URL construction in package-categories.js:32. RESOLVED — changed from ${NEXT_PUBLIC_API_URL}/package-categories (double-prefixed) to /package-categories (uses axios baseURL). Fixed as part of #39 React Query migration. 10 min 0
S2-20 #41 Remove console.log from api/packages/index.js:70. RESOLVED — verified removed in current codebase. 5 min 0
S2-21 #42 Handle missing stripeProductId in package-details.jsx:80. RESOLVED — changed condition from stripeProductId (doesn't exist) to stripePriceId (exists on entity). Removed phantom Stripe Product ID display. Payment Integration section now visible when stripePriceId is set. 15 min 0

Duplicate Logic (30 min)

# Issue(s) Description Effort
S2-22 #25 Remove standalone expireSubscriptions function (never exported) from resetSubscribtionStatus.handler.ts. Keep only the event listener version. (Await fix done in Sprint 1 S1-11.) 10 min

Backlog (Ongoing)

Fix opportunistically or when building related features. No dedicated sprint time needed.

Issue(s) Description Fix When
#44 Build subscription management UI (view/cancel/upgrade) When building customer dashboard
#45 Build purchase history UI for end users When building customer dashboard
#46 Build coupon management UI (admin CRUD + user apply-coupon flow) When coupon feature is product-prioritized
#47 Payment success/failure pages MOVED TO SPRINT 1 (S1-13) — pages exist but are non-functional stubs: no payment verification, route mismatch (/failure vs /failed), inconsistent URL params, no order details. Option B fix scoped for Sprint 1. BacklogSprint 1

Key Decisions Log

Decision PM Engineer Designer Outcome
Fix order for ship blockers Security first (#2 IDOR is worst), then data integrity Agrees — IDOR is 1-line lookup change, highest ROI Portal access is user-facing, most visible Security → data integrity → runtime
Coupon entity: add columns or rewrite? Add columns — feature exists in Stripe, users expect it Add columns + migration — rewrite is Sprint 2+ scope Coupons are revenue-critical UX Add missing columns now
Subscription OneToOne → ManyToOne when Sprint 1 — blocks upgrade/downgrade flow Sprint 1 — moderate migration risk but foundational Sprint 1 — users expect plan history Sprint 1
Enable invoice.payment_succeeded when Sprint 1 — without this, subscriptions expire after 1 cycle Sprint 1 — code is fully implemented, just commented out Invisible to users but critical for retention Sprint 1 (uncomment + test)
LemonSqueezy: fix or remove? Remove placeholder — creates false expectation Sprint 2 cleanup — non-functional, hardcoded secrets Users shouldn't see broken payment option Sprint 2 cleanup, document as unsupported
Rename Cupon → Coupon when Sprint 2 — cosmetic but builds trust Sprint 2 — many file moves, needs careful import updates N/A Sprint 2
Missing frontend pages (#44-46) Backlog — no user demand yet for self-service portal Backlog — backend APIs exist, frontend is pure UI work Would love to design these, but not blocking launch Backlog
Payment pages #47: Backlog or Sprint 1? Sprint 1 — showing "success" without verifying is a trust/support liability Sprint 1 — route mismatch causes 404 on cancel, verification endpoint is ~30 min Sprint 1 — users need to see what they paid for, not a generic checkmark Sprint 1 Option B (verify + details + route fix)
calculateDiscount.ts: wire or delete? Wire it — we need discount logic for Ship Blocker #5 Wire into payment flow during #5, or delete if we write new logic N/A Wire into Ship Blocker #5

Sprint Board Visualization

┌─────────────────────────────────────────────────────────────────┐
│                    SHIP BLOCKERS (pre-release)                  │
│                    10 items · ~90 min · 1 developer             │
│                    STATUS: 0/10 resolved                        │
│                                                                 │
│  [SEC] #2(IDOR) #1(hardcoded-secret)                           │
│  [DATA] #6(wrong-enum) #14(double-convert) #11(discount=0)    │
│  [SCHEMA] #3(coupon-cols) #4(payment-cols) #7(wrong-field)     │
│  [BUG] #8(dangling-route) #9(isDeleted-broken)                │
│                                                                 │
│  EXIT: No security holes. Payment amounts correct. No crashes.  │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    SPRINT 1 — "Payments Work" (week 1)         │
│                    13 items · ~12 hrs · 1 developer             │
│                    STATUS: 0/13 resolved                        │
│                                                                 │
│  [CRITICAL] #31(renewal!) #17(OneToOne→ManyToOne) #12(duration)│
│  [FLOW]     #13(re-subscribe) #5(coupon-FK) #18(permissions)   │
│  [CLEANUP]  #16(isDeleted) #15(enum) #10(gateway)              │
│  [FE+BE]    #19(bulk-delete) #25(await) #30(fail-handler)      │
│  [PAGES]    #47(FE done, BE remaining: verify endpoint+URLs)   │
│                                                                 │
│  EXIT: Full Stripe lifecycle works. Payment pages verify data. │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    SPRINT 2 — "Clean Foundation" (week 2-3)    │
│                    16 items · ~7 hrs · 1-2 developers           │
│                    STATUS: 4/20 resolved (FE cleanup done)      │
│                                                                 │
│  [DEAD]   #20 #21 #22 #23 #24 #32 #33 #34                    │
│  [TYPO]   #27 #28 #29 #43                                      │
│  [CONFIG] #26 #35 #36 #37 #38                                  │
│  [FE]     ~~#39~~✓ ~~#40~~✓ ~~#41~~✓ ~~#42~~✓                 │
│  [DUP]    #25(standalone-fn)                                    │
│                                                                 │
│  EXIT: No dead code. Consistent naming. Single Stripe init.    │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    BACKLOG (ongoing)                             │
│                    3 items · build when feature-prioritized      │
│                                                                 │
│  #44(sub-mgmt-UI) #45(purchase-UI) #46(coupon-UI)              │
└─────────────────────────────────────────────────────────────────┘

Attack Scenarios Addressed

Attack Vector Ship Blocker Status
IDOR — access another user's billing portal #2 Blocked by looking up stripeCustomerId from auth context
Webhook secret in source code #1 Blocked by moving to env variable
Wrong subscription status enum #6 Blocked by using correct SubscriptionStatus enum
Purchase amount manipulation #14 Blocked by removing double-conversion
Discount bypass (always $0) #11 Blocked by implementing actual discount calculation
Orphaned payment records #4, #7 Blocked by adding userId/packageId + correct couponId

Total Effort Estimate

Phase Items Effort Developers Timeline
Ship Blockers 10 ~1.5 hrs 1 Day 1 (before any deployment)
Sprint 1 13 ~12 hrs 1 Week 1
Sprint 2 16 ~7 hrs 1-2 Week 2-3
Backlog 3 ~6 hrs 1 Ongoing (when features prioritized)
Total 42 ~26.5 hrs ~3 weeks

5 issues resolved (#39 React Query migration, #40 URL fix, #41 console.log removed, #42 stripeProductId fixed). #47 moved from Backlog → Sprint 1 (Option B: verify + details + route fix). Net: 47 → 42 actionable.


Generated by BMAD Cross-Functional War Room workflow — 2026-04-06 Cross-reference: Deep-Dive: Subscription & Payments