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 session — createCustomerPortalSession 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.user → User.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.ACTIVE → SubscriptionStatus.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 $0 — discountPrice 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 columns — applyCoupon references startTime, endTime, isApplicableToAllPackages, discountValue, stripePromotionCodeId — none exist on entity. All coupon application is broken.
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 Purchase — processStripePayment 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).
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.
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.
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.
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.
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 URLs — payment.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 endpoint — GET /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
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)
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.
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.
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.
Remove standalone expireSubscriptions function (never exported) from resetSubscribtionStatus.handler.ts. Keep only the event listener version. (Await fix done in Sprint 1 S1-11.)
Build coupon management UI (admin CRUD + user apply-coupon flow)
When coupon feature is product-prioritized
#47
Payment success/failure pagesMOVED 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.