Skip to content

Subscription & Payments System - Deep Dive Documentation

Generated: 2026-02-13 Scope: Full-stack — Backend modules, entities, shared services, config, events, cron, frontend Files Analyzed: 48 Lines of Code: ~4,200+ Workflow Mode: Exhaustive Deep-Dive Issues Found: 47

Overview

The Subscription & Payments system is the core revenue engine of the SaaS boilerplate. It handles package/plan management, Stripe checkout sessions, webhook processing, subscription lifecycle, purchase records, and coupon/discount codes.

Purpose: Enable subscription-based and one-time payment monetization via Stripe (primary) and LemonSqueezy (placeholder, non-functional).

Key Responsibilities: - Package/plan CRUD with pricing tiers and billing cycles - Stripe Checkout Session creation for subscriptions - Webhook processing for payment events and subscription lifecycle - Purchase record keeping with payment references - Coupon/discount code management with Stripe coupon sync - Subscription expiration via daily cron job - Customer portal session management

Integration Points: User entity (stripeCustomerId), Auth middleware, Permission system, Event system, Cron scheduler


Critical Issues Summary

Severity: CRITICAL (will cause runtime errors or security vulnerabilities)

# Category Location Issue
1 Security webhook.controller.ts:80 Hardcoded LemonSqueezy webhook secret "c3b451346788c32" — must be in env config
2 Security payment.controller.ts:28 createCustomerPortalSession takes customerStripeId from request body — user can access another customer's billing portal
3 Schema Cupon.ts vs cupon.service.ts Entity missing 5+ columns referenced in service (startTime, endTime, isApplicableToAllPackages, discountValue, stripePromotionCodeId). applyCoupon is completely broken
4 Schema Payment.ts No userId or packageId columns — createPayament sets non-existent fields. TypeORM silently ignores them
5 Schema Purchase.tsCupon.ts couponId FK column exists but no @ManyToOne(() => Coupon) relation. No referential integrity
6 Bug subscription.service.ts:44 Uses Status.ACTIVE from User entity instead of SubscriptionStatus.ACTIVE from Subscription entity — wrong enum value
7 Bug payment.service.ts:176 processStripePayment sets couponCode on Purchase, but entity has couponId (number). Field silently ignored
8 Bug purchase.route.ts:9 Dangling router.get statement with no route path or handler — potential crash on load
9 Bug package-category.service.ts References isDeleted property throughout — PackageCategory entity has NO isDeleted column. All filter/delete operations broken

Severity: HIGH (incorrect behavior, data integrity issues)

# Category Location Issue
10 Logic payment.service.ts:110 createPaymentForSubscription ignores paymentGateway parameter — hardcodes Stripe. LemonSqueezy validation option in schema is dead
11 Logic payment.service.ts:107-108 discountPrice = 0 and amountPaid = 0 hardcoded — coupon code from payload is completely ignored. No discount ever applied
12 Logic subscription.service.ts:37-40 createSubscription ignores durationInDays from Package entity. Hardcodes 30 days (monthly) / 365 days (yearly). Trial duration (14 days from entity) never used
13 Logic stripe.handler.ts:129-132 When subscription is CANCELLED, silently returns. If user re-subscribes later, old cancelled record blocks new subscription on same package
14 Logic stripe.handler.ts:65-77 handleCheckoutSessionCompleted divides metadata amounts by 100 (cents→dollars) but metadata was stored as dollars. Double-conversion
15 Data Package.ts:48-49 type column is varchar(50) but PackageType enum is defined and never applied to the column
16 Data Coupon.ts Redundant soft-delete: has both isDeleted: boolean AND @DeleteDateColumn(). Service uses manual isDeleted = true instead of TypeORM softDelete
17 Data Subscription.ts:28 @OneToOne(() => User) — a user can only have ONE subscription ever. No subscription history. Upgrading overwrites
18 Naming cupon.service.ts Permission strings mix "cupon" and "coupon": cupon.view, cupon.read, cupon.delete vs coupon.create, coupon.update
19 API Frontend useBulkPermanentDeletePackages Calls DELETE /packages/bulk-permanent-delete — route does NOT exist on backend

Severity: MEDIUM (code quality, unused code, inconsistencies)

# Category Location Issue
20 Unused payment.controller.ts 8 unused imports: exp, stripe, Package, IsNull, Not, StripeService, ApiError, httpStatus
21 Unused payment.route.ts 4 unused imports: createSubscriptionCheckout, getLemonsqueezy, createCheckout, sendResponse
22 Unused calculateDiscount.ts Never imported anywhere in the payment flow
23 Duplicate subscription.service.ts Two functions doing the same thing: getSubscriptionList (QueryBuilder) and subscriptionList (manual QB)
24 Duplicate package.routes.ts:42-50 PATCH /:id/restore route registered twice
25 Duplicate resetSubscribtionStatus.handler.ts Standalone expireSubscriptions function (never exported) + identical event listener
26 Duplicate webhook.controller.ts:87-93 LemonSqueezy signature verified twice (duplicate timingSafeEqual check)
27 Typo payment.service.ts:216 Function named createPayament (missing 'y')
28 Typo File Cupon.ts, directory Cupon/ Should be "Coupon" — entity class is correctly named Coupon but file/directory use "Cupon"
29 Typo subscribtionExpire.ts, resetSubscribtionStatus.handler.ts "Subscribtion" should be "Subscription"
30 Dead stripe.handler.ts:86-88 handlePaymentIntentFailed — empty function body
31 Dead stripe.handler.ts:26 handleInvoicePaymentSucceeded fully implemented (lines 193-259) but commented out in switch
32 Dead subscription.controller.ts:25 Unused userId variable
33 Dead subscription.validation.ts:15 updateSubscriptionValidation is just a copy of subscriptionValidation — stub
34 Dead webhook.controller.ts:39 return after throw — unreachable code
35 Config getLemonsqueezy.ts:107 Hardcoded redirectUrl: "http://localhost:5500/..." — won't work in production
36 Config getLemonsqueezy.ts:98 Sets user_id: email instead of actual user ID
37 Config stripe.ts vs getStripe.ts Dual Stripe initialization paths (static import vs lazy DB-backed singleton). Both used across codebase
38 Pattern PaymentFactory.ts Only supports "stripe""lemonsqueezy" throws "Unsupported payment provider"
39 Frontend package-categories.js Uses useState/useEffect pattern instead of React Query (inconsistent with rest of app)
40 Frontend package-categories.js:32 Constructs full URL with NEXT_PUBLIC_API_URL prefix instead of using axios base URL
41 Frontend api/packages/index.js:70 console.log("formattedData", ...) left in production code
42 Frontend package-details.jsx:80 References selectedPackage.stripeProductId — field doesn't exist on Package entity
43 Path routes/v1/index.ts:5 Import ../../modules/v1/Package/... (capital P) but actual dir is lowercase package/. Fails on case-sensitive Linux

Severity: LOW (architectural notes, missing features)

# Category Location Issue
44 Missing Frontend No subscription management UI — no pages for viewing/managing subscriptions
45 Missing Frontend No purchase history UI for end users
46 Missing Frontend No coupon management UI (admin CRUD or user apply-coupon flow)
47 Missing Frontend No payment success/failure pages despite URLs referenced in Stripe session config

Complete File Inventory

Entities (6 files)

saas-boilerplate/src/entity/Subscription.ts

Purpose: Represents a user's subscription to a package. Links user to package with billing dates and Stripe subscription ID. LOC: 68 What Contributors Must Know: Uses @OneToOne with User — each user can only have ONE active subscription. No subscription history is maintained. Upgrading/downgrading overwrites the existing record. The status enum (ACTIVE, EXPIRED, PENDING, CANCELLED) is separate from the User entity's Status enum — do not confuse them. Exports: SubscriptionStatus (enum), Subscription (entity class) Relations: OneToOne → User (owns FK userId), ManyToOne → Package (packageId) Columns: id, userId (indexed), packageId (indexed), startDate, endDate, status (enum, default ACTIVE), stripeSubscriptionId (nullable), createdAt, updatedAt, deletedAt (soft-delete) Risks: OneToOne constraint means no subscription history. If business requires plan change tracking, this needs redesign to OneToMany.

saas-boilerplate/src/entity/Package.ts

Purpose: Defines subscription/service packages with pricing tiers, billing cycles, and payment provider integration IDs. LOC: 87 What Contributors Must Know: The PackageType enum is defined but NOT applied to the type column (it's varchar). The PackageCategory import exists but there is NO FK relation — this was an abandoned feature. durationInDays defaults to 14 but the subscription service hardcodes its own duration logic ignoring this field. Exports: BillingCycle (enum: TRIAL, MONTHLY, YEARLY), PackageType (enum — unused), Package (entity class) Relations: OneToMany → Subscription Columns: id, name (indexed), slug, price (decimal 10,2), durationInDays (default 14, indexed), type (varchar 50 nullable — should use PackageType enum), isFree (default false), description, isActive (default true, indexed), features (JSON array), billingCycle (enum), stripePriceId, lemonVariantId, createdAt, updatedAt, deletedAt Missing Columns: stripeProductId (referenced in stripe.service.ts and package-details.jsx), isDeleted (referenced in stripe.service.ts), packageCategoryId / category (referenced in package.service.ts)

saas-boilerplate/src/entity/PackageCategory.ts

Purpose: Categories for organizing packages (e.g., "free", "premium", "enterprise"). LOC: 31 What Contributors Must Know: This entity is standalone — Package has NO FK to it. The PackageCategory service references an isDeleted boolean that does NOT exist on this entity. The service code is broken for all operations that filter by isDeleted. Exports: PackageCategory (entity class) Columns: id, name (unique, indexed), slug, createdAt, updatedAt, deletedAt Missing Columns: isDeleted (referenced in service)

saas-boilerplate/src/entity/Payment.ts

Purpose: Records individual payment transactions with amount, currency, method, status, and gateway reference ID. LOC: 62 What Contributors Must Know: This entity has NO relation to User or Package — it's a standalone ledger. The method column uses @Column() without type: "enum" — may store as varchar depending on DB driver. The metadata JSON column stores rich Stripe session data. Exports: PaymentStatus (enum: PENDING, COMPLETED, FAILED, REFUNDED), PaymentMethod (enum: STRIPE, PAYPAL, LEMONSQUEEZY), Payment (entity class) Columns: id, amount (decimal 12,2), currency (varchar 3), status (enum, indexed), method (indexed), referenceId (Stripe paymentIntentId), paymentDate (timestamp, indexed), metadata (JSON), createdAt, updatedAt, deletedAt Missing Columns: userId, packageId (referenced in createPayament function — silently ignored by TypeORM)

saas-boilerplate/src/entity/Purchase.ts

Purpose: Links User + Package + Payment together. Records the actual purchase with pricing breakdown (original, discount, paid). LOC: 65 What Contributors Must Know: This is the join table that ties the payment flow together. couponId is a plain column with no ManyToOne relation to Coupon — no referential integrity enforced. Price columns use decimal(12,5) — 5 decimal places is unusual for currency (standard is 2). Exports: Purchase (entity class) Relations: ManyToOne → User, ManyToOne → Package, ManyToOne → Payment Columns: id, packageId (indexed), paymentId, couponId (nullable, indexed), originalPrice (decimal 12,5), discountAmount (decimal 12,5), amountPaid (decimal 12,5), userId (indexed), createdAt, updatedAt, deletedAt

saas-boilerplate/src/entity/Cupon.ts

Purpose: Coupon/discount code management with Stripe coupon synchronization. LOC: 75 What Contributors Must Know: File and directory named "Cupon" (typo) but entity class is correctly Coupon. Entity is outdated and needs columns added — the service references startTime, endTime, isApplicableToAllPackages, discountValue, stripePromotionCodeId which do not exist. Has redundant isDeleted boolean alongside @DeleteDateColumn. Exports: CouponType (enum: PERCENT, FIXED), CouponDuration (enum: ONCE, REPEATING, FOREVER), Coupon (entity class) Columns: id, code (unique, indexed), description, discountType (enum), amountOff (decimal 10,2), currency, duration (enum), durationInMonths, stripeCouponId, isActive (default true), isDeleted (default false — redundant), createdAt, updatedAt, deletedAt Missing Columns: startTime (Date), endTime (Date), isApplicableToAllPackages (number/boolean), discountValue (alias for amountOff?), stripePromotionCodeId (string)


Backend Modules

Subscription Module (5 files)

modules/v1/Subscription/subscription.service.ts

Purpose: Business logic for creating subscriptions and listing them with pagination/filtering. LOC: 216 What Contributors Must Know: createSubscription uses the WRONG enum (Status.ACTIVE from User, not SubscriptionStatus.ACTIVE). It also hardcodes duration instead of using the Package's durationInDays. Contains two duplicate list functions (getSubscriptionList via QueryBuilder and subscriptionList via manual QB). The subscription creation is triggered by PaymentService, not directly. Exports: SubscriptionService{ createSubscription, subscriptionList, getMySubscriptions, getSubscriptionList } Dependencies: Subscription entity, User entity, Package entity, getDbRepository, ApiError, getPaginationParams, getDataSource, findSubscriptions, checkPermissionAndThrow, getQueryBuilder, getSortQuery Side Effects: DB writes (subscription creation) Bugs: Wrong enum on line 44, hardcoded duration on lines 37-40, duplicate functions, getMySubscriptions hardcodes Status.ACTIVE filter (line 163)

modules/v1/Subscription/subscription.controller.ts

Purpose: HTTP handlers for subscription endpoints. LOC: 47 What Contributors Must Know: initiateSubscription delegates to PaymentService.createPaymentForSubscription (cross-module dependency). Unused userId variable on line 25. Exports: SubscriptionController{ initiateSubscription, getSubscriptionList, getMySubscriptions } Dependencies: PaymentService, SubscriptionService, catchAsync

modules/v1/Subscription/subscription.routes.ts

Purpose: Route definitions with auth middleware and Stripe requirement. LOC: 21 Routes: GET / (list, auth), GET /me (my subs, user guard), POST /subscribe (requireStripe + validation) Dependencies: auth, authGuard, validateRequest, contextMiddleware, requireStripe

modules/v1/Subscription/subscription.validation.ts

Purpose: Zod validation for subscription creation. LOC: 22 Schema: body: { packageId: number, couponCode?: string, paymentGateway: "stripe" | "lemonsqueezy" } Note: updateSubscriptionValidation is a dead stub (identical copy of create schema)

modules/v1/Subscription/subscription.helpers.ts

Purpose: Custom QueryBuilder for subscription listing with joined user and package data. LOC: 115 Exports: findSubscriptions(dataSource, filters, pagination, search, sort?) Pattern: Manual TypeORM QueryBuilder with ILIKE search across user.name, user.email, package.name

Payment Module (4 files)

modules/v1/Payment/payment.service.ts

Purpose: Core payment processing — Stripe checkout creation, webhook payment processing, payment record CRUD. LOC: 256 What Contributors Must Know: Contains the most critical business logic. createPaymentForSubscription is the main entry point but it IGNORES the paymentGateway parameter and hardcodes Stripe. Coupon discount logic is completely absent (hardcodes 0). processStripePayment uses a DB transaction which is good. createPayament has a typo AND references non-existent Payment entity columns. Exports: PaymentService{ createPaymentForSubscription, processStripePayment, getAllPayments, createPayament } Dependencies: Package, Payment, Purchase, Subscription entities; stripe config; StripeService; PaymentFactory; getStripe; createSubscriptionCheckout (unused) Side Effects: Stripe API calls (checkout session creation), DB writes (Payment + Purchase in transaction) Bugs: Lines 107-108 hardcode discount=0 ignoring couponCode; line 216 function name typo; lines 236-246 reference non-existent Payment columns; line 176 wrong property name on Purchase

modules/v1/Payment/payment.controller.ts

Purpose: HTTP handlers for payment list and Stripe customer portal. LOC: 54 What Contributors Must Know: 8 unused imports. createCustomerPortalSession is a security risk — accepts customerStripeId from client body instead of looking it up from authenticated user. Exports: PaymentController{ getAllPayments, createCustomerPortalSession }

modules/v1/Payment/payment.route.ts

Purpose: Route definitions. LOC: 39 Routes: GET / (auth + permission), POST /stripe/customer-portal-session (auth + validation) Note: Contains commented-out LemonSqueezy checkout route and 4 unused imports

modules/v1/Payment/payment.validation.ts

Purpose: Zod validation for customer portal. LOC: 16 Schema: body: { customerStripeId: string (1-255 chars) }

Purchase Module (4 files)

modules/v1/Purchase/purchase.service.ts

Purpose: Purchase history listing with pagination. LOC: 97 Exports: PurchaseService{ purchaseList, myPurchaseHistory } What Contributors Must Know: Read-only service. Uses qb.cache(true) for QueryBuilder caching. Joins User, Package, and Payment to build complete purchase records.

modules/v1/Purchase/purchase.controller.ts

Purpose: HTTP handlers for purchase list and user history. LOC: 35

modules/v1/Purchase/purchase.route.ts

Purpose: Route definitions. LOC: 14 Routes: GET / (auth, admin list), GET /me (authGuard user, personal history) Bug: Line 9 — dangling router.get with no arguments

modules/v1/Purchase/purchase.utils.ts

Purpose: Shared QueryBuilder factory for purchase queries with filters. LOC: 78 Exports: buildPurchaseQueryBuilder(dataSource, query, extraFilters?)

Package Module (4 files)

modules/v1/package/package.service.ts

Purpose: Full CRUD for packages with soft-delete, restore, bulk operations, and Stripe product integration. LOC: 368 What Contributors Must Know: createPackage references category and packageCategoryId properties that don't exist on the Package entity (abandoned feature). Duration calculation is duplicated in both create and restore paths. getBillingCycle helper can return undefined for unknown values. Slug generation uses generateUniqueSlug utility. Exports: PackageService{ createPackage, updatePackage, deletePackage, getAllPackages, getPackageById, deletePackageByIds, restorePackage, bulkPackageRestore, permanentlyDeletePackage, getPackageByName, getSinglePackage } Dependencies: Package, PackageCategory entities; StripeService; generateUniqueSlug; checkPermissionAndThrow

modules/v1/package/package.controller.ts

Purpose: HTTP handlers for all package CRUD operations. LOC: 143

modules/v1/package/package.routes.ts

Purpose: Route definitions with mixed public/protected access. LOC: 61 Routes: GET / (PUBLIC), GET /:id (PUBLIC), POST / (auth), PATCH /bulk-delete (auth), PATCH /bulk-restore (auth), PATCH /:id (auth), PATCH /:id/restore (auth — registered twice), DELETE /:id (auth), DELETE /:id/permanent (auth) Note: GET routes are intentionally public (for pricing pages). Missing DELETE /bulk-permanent-delete route (frontend expects it).

modules/v1/package/package.validation.ts

Purpose: Zod validation schemas. LOC: 36 Note: packageCategoryId commented out (abandoned feature). durationInDays not in schema (auto-calculated from billingCycle).

PackageCategory Module (4 files)

modules/v1/PackageCategory/package-category.service.ts

Purpose: CRUD for package categories. LOC: 122 What Contributors Must Know: ALL operations reference isDeleted property that does NOT exist on the entity. deletePackageCategory manually sets isDeleted = true instead of using TypeORM softDelete(). getPackageCategories filters by isDeleted: false — this filter is broken. bulkPackageCategoryRestore uses TypeORM restore() correctly but other methods don't. Exports: PackageCategoryService{ createPackageCategory, updatePackageCategory, deletePackageCategory, getPackageCategories, bulkPackageCategoryRestore }

Other files: Standard controller (72 LOC), routes (41 LOC), validation (19 LOC)

Routes: All protected — GET /, POST /, PATCH /bulk-restore, PATCH /:id, DELETE /:id

Webhook Module (3 files)

modules/v1/Webhook/stripe.handler.ts

Purpose: Dispatches Stripe webhook events to appropriate handlers. Manages subscription lifecycle. LOC: 259 What Contributors Must Know: This is the critical webhook processing pipeline. handleCheckoutSessionCompleted processes payments and creates Purchase+Payment records. handleSubscriptionUpsert creates/updates subscriptions from Stripe events. handleInvoicePaymentSucceeded is fully implemented but COMMENTED OUT in the switch statement — uncomment to enable subscription renewal on invoice payment. handlePaymentIntentFailed is empty. Exports: handleStripeEvent, handleCheckoutSessionCompleted, handlePaymentIntentFailed, handleSubscriptionDeleted Handled Events: checkout.session.completed, payment_intent.payment_failed (empty), customer.subscription.created/updated, customer.subscription.deleted NOT Handled: invoice.payment_succeeded (commented out) Bugs: CANCELLED subscriptions silently skipped on upsert (line 129-132); metadata amount double-conversion (line 65-77)

modules/v1/Webhook/webhook.controller.ts

Purpose: Webhook endpoint handlers for Stripe and LemonSqueezy. LOC: 147 What Contributors Must Know: Stripe webhook properly verifies signatures. LemonSqueezy webhook has hardcoded secret and duplicate signature verification. LemonSqueezy handler only processes subscription_created — all other events are console.log stubs. Security Issues: Hardcoded secret (line 80), duplicate verification (lines 87-93), Stripe error leaks internal data to response (line 54)

modules/v1/Webhook/webhook.route.ts

Purpose: Webhook routes with raw body parsing. LOC: 10 Routes: POST /stripe (raw JSON body), POST /lemonsqueezy (raw JSON body) Note: No auth middleware (correct — webhooks are called by payment providers)

Coupon Module (4 files)

modules/v1/Cupon/cupon.service.ts

Purpose: Coupon CRUD with Stripe coupon synchronization and coupon application logic. LOC: 376 What Contributors Must Know: Creates Stripe coupons and promotion codes alongside DB records. applyCoupon function is COMPLETELY BROKEN — references 5 non-existent entity properties. Update handles Stripe limitations (can't change discount value/type). Delete uses manual isDeleted = true instead of TypeORM soft-delete. Permission names are inconsistent ("cupon" vs "coupon"). Exports: CuponService{ createCupon, updateCupon, deleteCupon, getAllCupons, getCuponById, applyCoupon, getAllActiveCoupons }

Other files: Controller (86 LOC), routes (35 LOC), validation (98 LOC)

Validation: Well-structured with superRefine for conditional rules (percent max 100, repeating requires durationInMonths)

Stripe Service (modules/v1/stripe.service.ts)

Purpose: Centralized Stripe API operations — product/price creation, customer management, coupon creation, billing portal config. LOC: ~200 What Contributors Must Know: getStripeAllProducts references pkg.stripeProductId and pkg.isDeleted which don't exist on Package entity. stripeBillingPortalConfig creates a new portal configuration on EVERY call — should cache. loadStripeKeyFromDb enables DB-stored encrypted Stripe keys via AppConfig. Exports: StripeService{ createOrUpdateProduct, createPrice, getOrCreateStripeCustomer, getStripeAllProducts, createStripeCoupon, stripeBillingPortalConfig, createStripePromotionCode, loadStripeKeyFromDb }


Shared Payment Services (4 files)

shared/payment/payment.type.ts

Purpose: Interface for payment checkout options. LOC: 9 Exports: IPaymentOptions{ amount?, currency, type: "one_time" | "recurring", planId?, metadata?, customerId?, products? }

shared/payment/paymentService.ts

Purpose: Interface contract for payment service implementations. LOC: 9 Exports: IPaymentService{ createCheckout(options): Promise<{checkoutUrl, sessionId}>, verify(sessionId): Promise<{paid, data?}> }

shared/payment/PaymentFactory.ts

Purpose: Factory pattern to create payment service instances. LOC: 15 What Contributors Must Know: Only supports "stripe". Passing "lemonsqueezy" throws "Unsupported payment provider". To add LemonSqueezy, implement IPaymentService for LS and add to switch. Exports: PaymentFactory{ create(provider): IPaymentService }

shared/payment/StripeService.ts

Purpose: Stripe implementation of IPaymentService. Handles checkout session creation and verification. LOC: 108 What Contributors Must Know: This is the FACTORY version of Stripe service (not the module-level stripe.service.ts). Uses lazy-initialized Stripe instance from getStripe(). Supports both one-time and recurring payments. Success/cancel URLs use config.client_url. Also includes isValidPriceId utility. Exports: StripeService (class implementing IPaymentService)


Config & Middleware (4 files)

config/stripe.ts

Purpose: Static Stripe instance created at import time from env variable. LOC: 14 Note: Legacy approach. getStripe.ts is the newer dynamic version. Both are imported across the codebase.

config/getStripe.ts

Purpose: Lazy singleton Stripe initialization with DB-key fallback. LOC: 36 What Contributors Must Know: Returns cached instance after first init. Falls back to encrypted DB-stored key via StripeService.loadStripeKeyFromDb()decrypt(). This is the preferred initialization path.

config/getLemonsqueezy.ts

Purpose: LemonSqueezy SDK setup and checkout creation. LOC: 116 What Contributors Must Know: Non-functional placeholder. redirectUrl hardcoded to localhost:5500. Sets user_id: email instead of actual user ID. Config requires LEMONSQUEEZY_API_KEY and LEMONSQUEEZY_STORE_ID env vars.

middlewares/requireStripe.ts

Purpose: Express middleware that ensures Stripe is configured before allowing payment operations. LOC: 24 Note: Tries static import first, falls back to getStripe(). Used on POST /subscribe route.


Events & Cron (2 files)

events/handlers/resetSubscribtionStatus.handler.ts

Purpose: Expires active subscriptions past their end date by bulk-updating status to EXPIRED. LOC: 25 What Contributors Must Know: Contains two implementations: a standalone expireSubscriptions() function (never exported/used) and an event listener on RESET_SUBSCRIPTION_STATUS. The event listener does NOT await the query execution — errors silently swallowed. Bug: Missing await on line 19. Duplicate logic.

cron/subscribtionExpire.ts

Purpose: Daily cron job (midnight) that emits subscription expiration event. LOC: 13 What Contributors Must Know: Simply emits EVENT_TYPES.RESET_SUBSCRIPTION_STATUS. The actual logic is in the event handler. Typo in filename ("subscribtion").


Frontend (8 files)

api/packages/index.js

Purpose: React Query hooks for all package CRUD operations. LOC: 243 Exports: usePackages, usePackageById, useCreatePackage, useUpdatePackage, useDeletePackage, useRestorePackage, usePermanentDeletePackage, useBulkDeletePackages, useBulkRestorePackages, useBulkPermanentDeletePackages What Contributors Must Know: useBulkPermanentDeletePackages calls DELETE /packages/bulk-permanent-delete which doesn't exist on backend. usePackageById has a console.log left in production (line 70). Uses keepPreviousData: true and configurable cache time.

api/packages/package-categories.js

Purpose: Custom hook for fetching package categories. LOC: 61 What Contributors Must Know: Uses old useState/useEffect pattern instead of React Query — inconsistent with every other API hook. Constructs full URL ${NEXT_PUBLIC_API_URL}/package-categories instead of using axios base URL (double-prefixed). Exports: usePackageCategories

components/pages/package/packages-page.jsx

Purpose: Admin package list page with DataTable, filtering, sorting, bulk operations. LOC: 878 What Contributors Must Know: Comprehensive admin UI with search, tab filters (all/active/inactive/trash), sorting, bulk select, view modal, delete/restore/permanent-delete confirmations. Category filter is commented out. Permission-gated throughout.

components/pages/package/package-form.jsx

Purpose: Create/edit package form with react-hook-form. LOC: 511 What Contributors Must Know: packageCategoryId was commented out (abandoned). Type options are hardcoded as ["Subscription Based", "Product Based"]. Duration input is commented out (auto-calculated from billingCycle on backend). Features managed via useFieldArray.

components/pages/package/package-create-page.jsx

Purpose: Wrapper for PackageForm in create mode. LOC: 22

components/pages/package/package-edit-page.jsx

Purpose: Wrapper for PackageForm in edit mode with data fetching. LOC: 30

components/pages/package/package-details.jsx

Purpose: Read-only package detail view for modal display. LOC: 103 Bug: References selectedPackage.stripeProductId (line 80) — field doesn't exist on entity.

constants/package.js

Purpose: Billing cycle options for dropdowns. LOC: 5 Exports: billingCycleOptions[{monthly}, {yearly}, {trial}]


Database Schema Analysis

Entity Relationship Diagram

User (1) ──── OneToOne ────→ (1) Subscription ←── ManyToOne ── Package (1..*)
  │                                                              │
  │ stripeCustomerId                                            │ stripePriceId
  │ lemonSqueezyCustomerId                                     │ lemonVariantId
  │                                                              │
  ├── ManyToOne ──→ Purchase ←── ManyToOne ── Payment          │
  │                    │                                         │
  │                    │ couponId (plain FK, no relation)        │
  │                    ↓                                         │
  │                  Coupon                                      │
  │                                                              │
  │                                              PackageCategory (orphaned — no FK from Package)

Schema Integrity Issues

  1. Package → PackageCategory: No foreign key. Package imports PackageCategory but never uses it. Abandoned feature.
  2. Purchase → Coupon: couponId is a plain integer column with no @ManyToOne decorator. No cascading, no eager loading, no referential integrity.
  3. Payment: Fully orphaned — no FK to User, Package, or Purchase. Only linked via Purchase.paymentId.
  4. Subscription → User: OneToOne means one subscription per user. No history tracking.
  5. Coupon entity: Missing 5+ columns used by service code. Needs migration.
Priority 1 (Critical — broken functionality):
- Add missing Coupon columns: startTime, endTime, discountValue, isApplicableToAllPackages, stripePromotionCodeId
- Add @ManyToOne(() => Coupon) on Purchase entity for couponId
- Remove redundant isDeleted from Coupon (use only DeleteDateColumn)
- Fix PackageCategory service to use softDelete/DeleteDateColumn instead of non-existent isDeleted

Priority 2 (Data integrity):
- Apply PackageType enum to Package.type column (@Column({ type: "enum", enum: PackageType }))
- Add @Column({ type: "enum", enum: PaymentMethod }) to Payment.method
- Change Purchase price columns from decimal(12,5) to decimal(12,2) for currency

Priority 3 (Architecture):
- Consider changing Subscription from OneToOne to ManyToOne with User to support subscription history
- Add userId FK to Payment entity for direct payment lookups

Data Flow Analysis

Flow 1: Subscription Purchase (Stripe)

Frontend (not implemented yet)
  → POST /subscriptions/subscribe { packageId, couponCode, paymentGateway }
    → auth() + contextMiddleware + requireStripe
    → SubscriptionController.initiateSubscription
      → PaymentService.createPaymentForSubscription
        1. Check no existing active subscription for same user+package
        2. Fetch package data (price, stripePriceId)
        3. ⚠️ IGNORE paymentGateway param, hardcode PaymentFactory.create("stripe")
        4. ⚠️ IGNORE couponCode, hardcode discountPrice=0, amountPaid=0
        5. Get/create Stripe customer via StripeService.getOrCreateStripeCustomer
        6. Create Stripe Checkout Session (mode: subscription)
        7. Return { url, sessionId } → Frontend redirects to Stripe

  → User completes payment on Stripe Hosted Checkout

  → Stripe fires webhook: checkout.session.completed
    → POST /webhooks/stripe (raw body)
    → WebhookController.stripeWebhook
      → Verify signature
      → handleStripeEvent → handleCheckoutSessionCompleted
        → PaymentService.processStripePayment (in DB transaction):
          1. Create Payment record (status: COMPLETED)
          2. Create Purchase record (links user, package, payment)
          3. ⚠️ couponCode mapped to wrong field (Purchase has couponId not couponCode)

  → Stripe fires webhook: customer.subscription.created
    → handleSubscriptionUpsert
      1. Find user by stripeCustomerId
      2. Find package by stripePriceId
      3. Check existing subscription
      4. If no existing → SubscriptionService.createSubscription
         ⚠️ Uses wrong enum (Status.ACTIVE instead of SubscriptionStatus.ACTIVE)
         ⚠️ Ignores Package.durationInDays, hardcodes 30/365

  → Success URL redirects to: {client_url}/payment/success?orderId={packageId}
    ⚠️ No success page exists in frontend

Flow 2: Subscription Renewal (Stripe)

  → Stripe fires: invoice.payment_succeeded
    ⚠️ Handler is COMMENTED OUT in switch statement
    → If enabled: updates subscription start/end dates from invoice period
    → Currently: silently ignored → subscriptions expire without renewal

Flow 3: Subscription Cancellation

  → Stripe fires: customer.subscription.deleted
    → handleSubscriptionDeleted
      1. Find user by stripeCustomerId
      2. Find subscription by userId + stripeSubscriptionId
      3. Set status = CANCELLED, endDate = now
      4. Save

  → Daily cron (midnight): subscribtionExpire
    → Emits RESET_SUBSCRIPTION_STATUS event
    → Bulk updates: all ACTIVE subscriptions with endDate < now → EXPIRED

Flow 4: LemonSqueezy Subscription (Placeholder)

  → Not triggered from frontend (no integration)
  → Webhook: POST /webhooks/lemonsqueezy
    → Verify signature (hardcoded secret)
    → On subscription_created:
      1. Save LemonSqueezy customer ID on User
      2. Create Subscription record
      3. Create Payment record (⚠️ sets non-existent columns)
    → All other events: console.log only

Flow 5: Coupon Application

  → POST /coupons/apply (not exposed via routes — no route for this!)
    → CuponService.applyCoupon
      1. Find package
      2. Find coupon by code
      3. ⚠️ References non-existent entity fields: startTime, endTime, isApplicableToAllPackages, discountValue, type
      → COMPLETELY BROKEN — will throw runtime errors

Flow 6: Package Management (Admin)

  Frontend: /dashboard/packages
  → PackagesPage (list with DataTable)
    → usePackages → GET /packages (public)
    → useCreatePackage → POST /packages (auth)
    → useUpdatePackage → PATCH /packages/:id (auth)
    → useDeletePackage → DELETE /packages/:id (auth, soft-delete)
    → useRestorePackage → PATCH /packages/:id/restore (auth)
    → usePermanentDeletePackage → DELETE /packages/:id/permanent (auth)
    → useBulkDeletePackages → PATCH /packages/bulk-delete (auth)
    → useBulkRestorePackages → PATCH /packages/bulk-restore (auth)
    → useBulkPermanentDeletePackages → DELETE /packages/bulk-permanent-delete
      ⚠️ Route does NOT exist on backend

Dependency Graph

Entry Points (not imported by others in scope)

  • subscription.routes.ts → registered at /subscriptions
  • payment.route.ts → registered at /payments
  • purchase.route.ts → registered at /purchases
  • package.routes.ts → registered at /packages
  • package-category.routes.ts → registered at /package-categories
  • webhook.route.ts → registered at /webhooks
  • cupon.routes.ts → registered at /coupons
  • subscribtionExpire.ts → registered in cron scheduler

Leaf Nodes (don't import others in scope)

  • payment.type.ts
  • paymentService.ts (interface only)
  • payment.validation.ts
  • subscription.validation.ts
  • cupon.validation.ts
  • package.validation.ts
  • package-category.validation.ts
  • calculateDiscount.ts (unused)
  • constants/package.js

Cross-Module Dependencies

SubscriptionController → PaymentService (subscription creates payment)
PaymentService → PackageService (fetches package for pricing)
PaymentService → StripeService (customer management)
PaymentService → PaymentFactory → shared/StripeService (checkout creation)
WebhookController → stripe.handler → PaymentService + SubscriptionService
WebhookController → UserService (LemonSqueezy customer ID save)
CuponService → StripeService (coupon sync)
PackageService → StripeService (Stripe product/price sync — not yet used)

Circular Dependencies

  • None detected

Contributor Checklist

Risks & Gotchas

  • Coupon service applyCoupon will crash at runtime — entity is missing 5 columns
  • PackageCategory CRUD operations silently fail on isDeleted filter (field doesn't exist)
  • Subscription creation uses wrong status enum — may work by coincidence if string values match, but type-unsafe
  • Stripe webhook metadata amounts may be double-divided by 100
  • LemonSqueezy payments write to non-existent Payment columns (silently ignored by TypeORM)
  • PATCH /:id/restore on packages will match the first registered handler (second is dead)
  • router.get dangling on purchase routes may cause Express errors on certain Node versions

Pre-change Verification Steps

  1. Run yarn dev and check for TypeORM schema sync warnings
  2. Test POST /subscriptions/subscribe with a test Stripe price ID
  3. Verify webhook signatures work with Stripe CLI: stripe listen --forward-to localhost:5500/api/v1/webhooks/stripe
  4. Check coupon creation creates both Stripe coupon AND DB record
  5. Verify package soft-delete and restore work correctly

Suggested Tests Before PR

  • Unit test: createSubscription with monthly/yearly/trial packages — verify correct endDate
  • Unit test: processStripePayment — verify Payment + Purchase created in transaction
  • Unit test: applyCoupon — currently will fail, verify after entity migration
  • Integration test: Full subscription flow (checkout → webhook → subscription created)
  • Integration test: Package CRUD with soft-delete and restore
  • Integration test: Coupon create → apply → verify discount

Architecture & Design Patterns

Code Organization

Standard module pattern: each domain has controller, service, routes, validation. The Payment/Subscription flow spans multiple modules with PaymentService acting as the orchestrator.

Design Patterns

  • Factory Pattern: PaymentFactory creates payment service instances (currently only Stripe)
  • Service Layer: Business logic isolated in *.service.ts, controllers are thin HTTP adapters
  • Repository Pattern: getDbRepository() provides typed TypeORM repositories
  • Query Builder Pattern: Reusable builders (getQueryBuilder, buildPurchaseQueryBuilder) for complex queries
  • Event-Driven: Subscription expiration uses EventEmitter pattern with cron trigger
  • Middleware Chain: Auth → Context → Stripe → Validation → Controller

Testing Strategy

No test files exist. No test framework configured. See "Suggested Tests Before PR" above for recommended test plan.


Similar Patterns Elsewhere

  • Blog module: Same CRUD pattern (controller/service/routes/validation) — reference for adding coupon management UI
  • User module: Has working soft-delete with isDeleted + DeleteDateColumn — shows how to reconcile the pattern
  • Auth module: Token management pattern — similar lifecycle to subscription management

Reusable Utilities Available

  • calculateDiscount.ts — exists but unused. Should be integrated into createPaymentForSubscription
  • generateUniqueSlug — already used in Package, can be used for Coupon codes
  • checkPermissionAndThrow — already used throughout
  • catchAsync — standard async error wrapper
  • getQueryBuilder / BaseQueryBuilder — used for paginated lists

Patterns to Follow

  • Bulk operations: Package module has working bulk-delete/restore — reference for adding bulk operations to other modules
  • Permission gating: Use checkPermissionAndThrow in services + PermissionWrapper in frontend
  • Form pattern: package-form.jsx with useFormSubmit hook — reference for coupon/subscription forms

Implementation Notes

TODOs and Future Work

  • No explicit TODO comments found in payment-related files
  • invoice.payment_succeeded handler is implemented but needs to be uncommented and tested
  • handlePaymentIntentFailed needs implementation (user notification, retry logic)

Known Issues

See Critical Issues Summary table above (47 issues total across 4 severity levels)

Technical Debt

  1. Dual Stripe initialization (stripe.ts static + getStripe.ts lazy) — migrate all consumers to getStripe()
  2. Inconsistent soft-delete — some modules use isDeleted boolean, others use DeleteDateColumn, some use both
  3. Inconsistent permission naming — "cupon" vs "coupon" in permission strings
  4. Dead code accumulation — unused imports, commented-out code, duplicate functions
  5. No TypeScript strict mode — many as any casts and untyped parameters
  6. No input sanitization — Stripe metadata stored as-is in DB JSON column

Optimization Opportunities

  1. Cache Stripe billing portal config — currently creates new config on every call
  2. Batch subscription expiration — current implementation updates all at once. For large datasets, consider batch processing
  3. Use TypeORM softDelete consistently — replace manual isDeleted = true patterns
  4. Add database indexes on stripeSubscriptionId, stripeCustomerId, stripePriceId for webhook lookups

Modification Guidance

To Add New Payment Provider (e.g., LemonSqueezy)

  1. Create LemonSqueezyService implementing IPaymentService in shared/payment/
  2. Add "lemonsqueezy" case in PaymentFactory.create()
  3. Update createPaymentForSubscription to use paymentGateway parameter instead of hardcoding Stripe
  4. Add LemonSqueezy webhook event handlers in webhook.controller.ts
  5. Move hardcoded secret to env config: LEMONSQUEEZY_WEBHOOK_SECRET
  6. Fix getLemonsqueezy.ts redirect URL to use config.client_url

To Fix Coupon System

  1. Add missing columns to Cupon.ts entity: startTime, endTime, isApplicableToAllPackages, discountValue (or alias from amountOff), stripePromotionCodeId
  2. Run TypeORM migration to add columns
  3. Remove redundant isDeleted boolean — use only DeleteDateColumn
  4. Fix applyCoupon to use correct property names
  5. Integrate calculateDiscount into createPaymentForSubscription
  6. Add coupon management UI to frontend

To Add Subscription History

  1. Change Subscription from @OneToOne to @ManyToOne with User
  2. Update User entity's subscription relation from OneToOne to OneToMany
  3. When upgrading: set old subscription to CANCELLED, create new one
  4. Update getMySubscriptions to show all (active and historical)

Testing Checklist for Changes

  • Verify TypeORM schema sync matches entity definitions
  • Test full subscription purchase flow (checkout → webhook → subscription)
  • Test subscription expiration cron (set endDate in past, run handler)
  • Test coupon creation syncs to Stripe
  • Test package CRUD including soft-delete and restore
  • Test webhook signature verification for both providers
  • Verify no console.log statements in production code
  • Verify all route imports match actual filesystem paths (case sensitivity)
  • Check all permission strings are consistent (cupon vs coupon)
  • Validate Purchase price columns store correct values

Generated by document-project workflow (deep-dive mode) Base Documentation: docs/index.md Scan Date: 2026-02-13 Analysis Mode: Exhaustive