refactor: Clean up subscription admin override and Stripe integration #205
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Refactor the subscription system to cleanly separate admin-managed tier overrides from Stripe-managed billing. The current implementation uses an
admin_override_placeholder in thestripe_customer_idcolumn, which violates column semantics, has caused bugs (#58), and was implemented without approval. This refactor also hardens the dual-table tier sync to prevent future drift.Related Issues
Background
Problem 1:
admin_override_Placeholder PatternWhen an admin overrides a user's tier, the system generates a fake Stripe customer ID:
This is stored in
subscriptions.stripe_customer_id(VARCHAR(255) UNIQUE NOT NULL). Every code path that touches this column must check for theadmin_override_prefix:resolveStripeCustomerId()detects it during upgrades and creates a real Stripe customergetInvoices()returns[]when it detects the prefixRoot cause: The
stripe_customer_idcolumn has aNOT NULLconstraint, forcing a placeholder value when Stripe is not involved.Problem 2: Non-Blocking Tier Sync
The
syncTierToUserProfile()method (service lines 804-816) swallows errors:While the admin override uses a transaction for atomic sync, non-admin paths (webhooks, upgrades, downgrades) use this non-blocking sync. If
user_profiles.subscription_tierfails to update, the auth context reads stale tier data.Price IDs vs Product IDs (Non-Issue)
The system uses
price_IDs (notproduct_IDs) when creating Stripe subscriptions. Per Stripe's official documentation, this is correct behavior. Stripe Products define what you sell; Prices define how much and how often.subscriptions.createrequires apriceparameter. ThegetPriceId()method mapping tier+billingCycle to environment variables is the standard pattern.Proposed Changes
1. Make
stripe_customer_idNULLABLEMigration: ALTER
subscriptions.stripe_customer_idto drop NOT NULL constraint:admin_override_*values to NULLresolveStripeCustomerId()lazy resolution methodfindByStripeCustomerId()- NULL records will never match (correct behavior)2. Make
syncTierToUserProfile()BlockingChange from non-blocking (error swallowed) to blocking (throw on failure):
This is safe because:
3. Clean Admin Override Flow
Remove
createForAdminOverride()placeholder logic. The admin flow becomes:stripe_customer_id = NULL,stripe_subscription_id = NULLFiles Affected
Backend:
backend/src/features/subscriptions/data/subscriptions.repository.ts- RemovecreateForAdminOverride(), update queries for nullable stripe_customer_idbackend/src/features/subscriptions/domain/subscriptions.service.ts- RemoveresolveStripeCustomerId(), updateadminOverrideTier(), makesyncTierToUserProfile()blockingbackend/src/features/subscriptions/api/subscriptions.controller.ts- Update getInvoices/payment-method to check for null instead of prefixbackend/src/features/subscriptions/migrations/- New migration for nullable stripe_customer_idFrontend (minor):
frontend/src/features/subscription/pages/SubscriptionPage.tsx- Conditional rendering for invoices/payment when no Stripe customerAcceptance Criteria
stripe_customer_idcolumn is NULLABLE (migration applied)admin_override_*values migrated to NULLresolveStripeCustomerId()method removedcreateForAdminOverride()creates records with NULL stripe_customer_idsyncTierToUserProfile()throws on failure (blocking)[]for users without Stripe customerTest Plan
Admin Override Flow:
Stripe Upgrade After Admin Override:
Blocking Sync:
Regression:
Decision Critic Results
Decisions stress-tested via Decision Critic skill. Key findings:
admin_override_entirely: VERIFIED - NULLABLE stripe_customer_id correctly isolates admin subscriptions from Stripe webhooksPlan: Clean up subscription admin override and Stripe integration
Phase: Planning | Agent: Orchestrator | Status: APPROVED
Codebase Analysis Summary
Analyzed all affected files across backend and frontend. Key findings:
Repository (
subscriptions.repository.ts):createForAdminOverride()(lines 576-611) generatesadmin_override_{userId}_{timestamp}placeholdermapSubscriptionRow()(lines 622-638) mapsstripe_customer_idas always-present stringSubscriptiontype hasstripeCustomerId: string(not optional)findByStripeCustomerId()(lines 90-113) does exact match - NULL records never match (correct)Service (
subscriptions.service.ts):resolveStripeCustomerId()(lines 173-198) - lazy Stripe customer creation on admin_override_ detectionsyncTierToUserProfile()(lines 804-816) - swallows all errors (line 814 comment: "Don't throw")adminOverrideTier()(lines 899-960) uses explicit transaction for atomic dual-table updategetInvoices()(line 981) checksadmin_override_prefixupgradeSubscription()andupdatePaymentMethod()callresolveStripeCustomerId()Controller (
subscriptions.controller.ts):cancel/reactivateendpoints have NO admin_override_ guards (risk of Stripe errors for admin-set users)Migration (
001_subscriptions_tables.sql):stripe_customer_id VARCHAR(255) UNIQUE NOT NULL(line 29)idx_subscriptions_stripe_customer_id{3-digit}_{snake_case}.sqlFrontend: Zero references to
admin_override_orstripeCustomerIdin UI. Already handles empty invoices gracefully. Type-only change needed.Grace Period Job (
grace-period.job.ts): Does NOT referencestripe_customer_idor callsyncTierToUserProfile(). Uses inline SQL in transaction. Admin override records (stripe_subscription_id = NULL) are naturally excluded from expiration logic.Admin Controller (
admin/api/users.controller.ts): CallsadminOverrideTier()at line 288. Clean delegation, no changes needed.Feature Context
Admin override subscriptions enable manual tier assignment without Stripe billing. Use cases: internal testing accounts, partner/sponsor accounts, emergency access grants, transition period before payment setup. Admin overrides coexist with paid subscriptions in a single table, enabling a seamless upgrade path when the user adds a payment method.
Planning Context
Decision Log
admin_override_placeholder violates column semantics -> caused bug #58 -> every code path must check prefix -> NULL correctly represents "no Stripe customer" -> eliminates all prefix checksRejected Alternatives
Constraints & Assumptions
stripe_subscription_idis already NULLABLE in the schema - this refactor alignsstripe_customer_idto the same patternstripeCustomerIdanywhere - type change only, no UI impactGET /api/subscriptionsresponse fieldstripeCustomerIdchanges from always-string (includingadmin_override_*values) tostring | null. Frontend does not display this field, so no UI impact. Single-tenant application with no external API consumers.resolveStripeCustomerId(). Webhook handlers already handle "not found" gracefully. Stripe webhooks are delayed by seconds. Not introduced or worsened by this refactor.resolveStripeCustomerId().Milestone 1: Database migration + type definitions
Sub-issue: #207
refactor: Database migration for nullable stripe_customer_id (#205)Files:
backend/src/features/subscriptions/migrations/002_nullable_stripe_customer_id.sql(NEW)backend/src/features/subscriptions/domain/subscriptions.types.tsfrontend/src/features/subscription/types/subscription.types.tsChanges:
New migration
002_nullable_stripe_customer_id.sql:Backend type (
subscriptions.types.ts):Subscriptioninterface: changestripeCustomerId: stringtostripeCustomerId: string | nullSubscriptionResponsetype: changestripeCustomerId: stringtostripeCustomerId: string | nullUpdateSubscriptionDatatype: changestripeCustomerId?: stringtostripeCustomerId?: string | nullFrontend type (
subscription.types.ts):stripeCustomerId: stringtostripeCustomerId: string | nullTests: Verify migration runs without errors. Verify admin_override_* values become NULL after migration. Verify type compilation in both backend and frontend.
Acceptance criteria: Migration runs cleanly, all admin_override_* values become NULL, all types (Subscription, SubscriptionResponse, UpdateSubscriptionData) compile with nullable stripeCustomerId.
Milestone 2: Repository layer updates
Sub-issue: #208
refactor: Repository updates for nullable stripe_customer_id (#205)Files:
backend/src/features/subscriptions/data/subscriptions.repository.tsChanges:
createForAdminOverride()(lines 576-611):admin_override_{userId}_{timestamp})stripe_customer_id = NULLinsteadmapSubscriptionRow()(lines 622-638):stripeCustomerId: row.stripe_customer_id ?? nullcreate()method (lines 30-57):stripeCustomerIdoptional in the parameter typeTests: Verify createForAdminOverride() inserts NULL for stripe_customer_id. Verify mapSubscriptionRow() returns null (not undefined) for NULL db values. Verify create() works with and without stripeCustomerId.
Acceptance criteria: Admin override creates records with NULL stripe_customer_id. Mapper handles NULL correctly using
??. Existing Stripe-based creates still work.Milestone 3: Service - admin override + sync hardening
Sub-issue: #209
refactor: Remove resolveStripeCustomerId and harden tier sync (#205)Files:
backend/src/features/subscriptions/domain/subscriptions.service.tsChanges:
Delete
resolveStripeCustomerId()(lines 173-198):ensureStripeCustomer()in Milestone 4Update
adminOverrideTier()(lines 899-960):createForAdminOverride()creates with NULL (handled by Milestone 2)Make
syncTierToUserProfile()blocking (lines 804-816):Tests: Verify resolveStripeCustomerId() is fully removed (no references, no tests - tests dir is empty). Verify syncTierToUserProfile() propagates errors. Verify adminOverrideTier() transaction commits with NULL stripe_customer_id.
Acceptance criteria:
resolveStripeCustomerId()deleted.syncTierToUserProfile()is blocking (throws on failure). Admin override flow works with NULL stripe_customer_id.Milestone 4: Service - Stripe flows + null handling
Sub-issue: #210
refactor: Update Stripe flows for nullable stripe_customer_id (#205)Files:
backend/src/features/subscriptions/domain/subscriptions.service.tsbackend/src/features/subscriptions/api/subscriptions.controller.ts(comment cleanup only)Changes:
Extract
ensureStripeCustomer()private method (replacesresolveStripeCustomerId()):upgradeSubscription()(lines 203-280):resolveStripeCustomerId()call (line 220) with:updatePaymentMethod()(lines 965-973):resolveStripeCustomerId()call withensureStripeCustomer()getInvoices()(line 981):Edge case hardening for
cancelSubscription()andreactivateSubscription()(service layer):stripeCustomerIdis null, throw BadRequestError with message: "Cannot cancel/reactivate subscription without active Stripe billing"Controller comment cleanup: Remove any stale comments in
subscriptions.controller.tsreferencingadmin_override_patternTests: Verify ensureStripeCustomer() creates Stripe customer when stripeCustomerId is null. Verify ensureStripeCustomer() cleans up orphaned customer on DB failure. Verify ensureStripeCustomer() returns existing customer when stripeCustomerId is set. Verify getInvoices() returns [] for null stripeCustomerId. Verify cancelSubscription() throws BadRequestError for null stripeCustomerId. Verify reactivateSubscription() throws BadRequestError for null stripeCustomerId.
Mobile + Desktop Validation: Verify SubscriptionPage and SubscriptionMobileScreen render correctly at 320px, 768px, and 1920px viewports when user has NULL stripe_customer_id (no invoices section, no payment method errors, tier display correct).
Acceptance criteria:
ensureStripeCustomer()extracted with orphan cleanup. Upgrade from admin-set tier creates real Stripe customer. Payment method update works for admin-set users. Invoices return[]for null stripe_customer_id. Cancel/reactivate throw BadRequestError for null stripeCustomerId (service layer). Controller comments updated. Mobile and desktop render correctly. All linters pass. All tests pass.Invisible Knowledge
Feature Context: Admin override subscriptions enable manual tier assignment without Stripe billing. Use cases: testing accounts, partner accounts, emergency access. Admin overrides coexist with paid subscriptions in a single table, enabling seamless upgrade when user adds payment.
Admin-to-Stripe Transition Path: Admin-set subscriptions (stripe_customer_id = NULL) live in the same
subscriptionstable as Stripe-managed subscriptions. When a user with an admin-set tier upgrades via Stripe,ensureStripeCustomer()creates a real Stripe customer and updates the record in-place. This single-table design enables seamless transition without data migration.Webhook Isolation: Stripe webhooks use
findByStripeCustomerId()to locate subscriptions. Since admin-set subscriptions have NULL stripe_customer_id, they are naturally invisible to webhook handlers. No additional guards needed in webhook code paths.Dual-Table Sync Pattern: The subscription tier is stored in two tables:
subscriptions.tier(billing source of truth) anduser_profiles.subscription_tier(auth context cache). All non-admin flows usesyncTierToUserProfile()which is blocking and throws on failure. The admin flow (adminOverrideTier()) uses an explicit transaction to update both tables atomically.NULL vs Sentinel Values: PostgreSQL UNIQUE constraint allows multiple NULLs but fails on duplicate sentinel strings. NULL is the database-native representation of "no value" vs. a string hack (
admin_override_*) that violates type semantics and requires prefix checks in every code path. This was the root cause of bug #58.Dependency Chain
Risk Assessment
Files NOT Changed (Confirmed Safe)
backend/src/features/subscriptions/jobs/grace-period.job.ts- No stripe_customer_id referencesbackend/src/features/admin/api/users.controller.ts- Clean delegation to adminOverrideTier()backend/src/features/subscriptions/api/subscriptions.routes.ts- Route definitions unchangedbackend/src/features/subscriptions/external/stripe/- Stripe client unchangedReview History
??operator, cancel/reactivate location, UpdateSubscriptionData type, temporal contamination, API contract changeVerdict: APPROVED | Next: Create branch and begin execution
QR Review: plan-completeness
Phase: Plan-Review | Agent: Quality Reviewer | Status: PASS_WITH_CONCERNS
VERDICT: PASS_WITH_CONCERNS
Findings
RULE 1 HIGH: Missing Mobile + Desktop Validation in Milestones
RULE 1 HIGH: Missing Test Coverage in Plan
RULE 1 HIGH: Missing Decision Log Section
RULE 1 HIGH: Missing Invisible Knowledge Section
RULE 2 SHOULD_FIX: Incomplete Edge Case Coverage
RULE 2 SHOULD_FIX: Payment Method Endpoint Ambiguity
Considered But Not Flagged
Verdict: PASS_WITH_CONCERNS | Next: Revise plan, then continue review cycle
TW Review: plan-scrub
Phase: Plan-Review | Agent: Technical Writer | Status: MINOR_EDITS
TW VERDICT: MINOR_EDITS
The plan is well-structured with clear decision chains, accurate code references, and sufficient detail for developer implementation. The QR plan-completeness findings were addressed comprehensively. Seven findings below -- all minor precision issues that can be resolved with targeted edits.
Findings
1. Language Imprecision: mapSubscriptionRow NULL Handling
stripeCustomerId: row.stripe_customer_id || nullbut the current code at line 626 isstripeCustomerId: row.stripe_customer_id(no fallback). The||operator will coerce empty string""tonull, which may be the intent, but this is an implicit behavior change beyond the stated goal. Additionally, the existing mapper uses|| undefinedfor other nullable fields (e.g.,stripeSubscriptionIdat line 627), creating an inconsistency: some nullable Stripe fields will returnundefined, whilestripeCustomerIdwill returnnull.??) instead of logical OR (||) to only coercenull/undefinedtonull, not empty strings. Also note: consider whetherstripeSubscriptionIdshould follow the same?? nullpattern for consistency (currently|| undefined). If intentionally different, state why.2. Ambiguity: cancel/reactivate Error Handling Location
stripeCustomerIdis null, throw BadRequestError" but does not specify whether this guard goes in the service methods (cancelSubscription()at line 285,reactivateSubscription()at line 332) or the controller methods. Currently,cancelSubscription()already checks!currentSubscription.stripeSubscriptionId(line 295) and throwsnew Error(...). The plan's guard is for a different field (stripeCustomerId), and the error type changes fromErrortoBadRequestError. The sub-issue #210 body says "handle null gracefully" which contradicts the plan comment's "throw BadRequestError."stripeCustomerIdnull check before the existingstripeSubscriptionIdcheck.3. Sub-Issue #210 Body Diverges from Plan Comment
upgradeSubscription(), explicitBadRequestErrorspecification for cancel/reactivate, mobile+desktop viewport validation, and test specifications. Sub-issue #210's body has none of these -- it still uses the pre-revision text "handle null gracefully" and lacks the tests section and mobile validation section that were added to the plan comment.upgradeSubscription()inline logic4. Missing Specification: repository.update() Call Signature
await this.repository.update(currentSubscription.id, { stripeCustomerId }). Looking at the repositoryupdate()method (line 144),UpdateSubscriptionDataalready hasstripeCustomerId?: string. However, after Milestone 1, the type changes tostring | null. The plan does not state whetherUpdateSubscriptionData.stripeCustomerIdalso needs updating. If the developer follows only the M1 type changes (Subscription interface), they may miss updating UpdateSubscriptionData.UpdateSubscriptionData.stripeCustomerIdfromstringtostring | nullto match the Subscription interface change."5. Temporal Contamination (Minor)
6. Imprecise Scope: Controller Comment at Line 223
// Update payment method via service (handles admin_override_ customer IDs)which should be updated during implementation. This stale comment is not called out in any milestone's file changes. Since the controller is listed under "Files NOT Changed (Confirmed Safe)", the developer will skip it.7. Missing Specification: SubscriptionResponse Type Alignment
SubscriptionResponsetype, but the currentSubscriptionResponse(types file line 77) hasstripeCustomerId: string(non-nullable). The plan mentions this change but does not address themapToResponse()method in the service (line 997-1013) which assignsstripeCustomerId: subscription.stripeCustomerIddirectly. After the type change,SubscriptionResponse.stripeCustomerIdbecomesstring | null, and the mapper will pass throughnullto the API response. This is technically correct but the plan should explicitly state that the API contract changes:stripeCustomerIdfield in JSON responses will benullinstead of anadmin_override_*string.stripeCustomerId: nullfor admin-set subscriptions instead ofstripeCustomerId: 'admin_override_...'. Frontend already has no dependency on this value -- confirmed by codebase analysis."Documentation Readiness
Assessment: READY with minor gaps
The plan provides sufficient material for code comments and README generation:
Decision Log: All 4 decisions have clear reasoning chains that translate directly to JSDoc comments. The "Reasoning Chain" column format is particularly effective for inline
// Decision: ...comments.Invisible Knowledge: The 3 items (Admin-to-Stripe Transition Path, Webhook Isolation, Dual-Table Sync Pattern) are well-written and README-ready. The Dual-Table Sync Pattern item should remove "now" as noted in Finding #5.
Code Comment Gap: The plan does not specify what JSDoc or inline comment should replace the deleted
resolveStripeCustomerId()method's documentation. The inline Stripe customer creation inupgradeSubscription()andupdatePaymentMethod()should carry forward the "why" context. Suggest adding a comment template to Milestone 4:README Update Scope: The subscriptions feature README (
backend/src/features/subscriptions/README.md) should be updated to document the NULL stripeCustomerId semantics. This is not called out in any milestone. Recommend adding a documentation task to Milestone 4 or as a separate follow-up.Verdict: MINOR_EDITS | Next: Address findings, then QR plan-code -> QR plan-docs
TW Review: plan-scrub
Phase: Plan-Review | Agent: Technical Writer | Status: MINOR_EDITS
VERDICT: MINOR_EDITS
Findings
1. mapSubscriptionRow NULL Handling Precision
||operator for null coercion but should use??(nullish coalescing) to avoid coercing empty strings. Inconsistency:stripeSubscriptionIduses|| undefinedwhile proposedstripeCustomerIduses|| null.row.stripe_customer_id ?? nullconsistently.2. cancel/reactivate Error Location Unspecified
3. Sub-Issue #210 Body Diverges from Plan
4. Missing UpdateSubscriptionData Type Change
Subscription.stripeCustomerIdbut does not mentionUpdateSubscriptionData.stripeCustomerIdin same types file.5. Temporal Contamination (3 instances)
6. Stale Controller Comment
7. API Contract Change Not Documented
nullinstead ofadmin_override_*strings. This is an API contract change.Documentation Readiness: READY with minor gaps
Decision Log and Invisible Knowledge sufficient for code comments and README generation. Subscriptions feature README should be listed for update post-implementation.
Verdict: MINOR_EDITS | Next: Apply fixes, then QR plan-code
QR Review: plan-code
Phase: Plan-Review | Agent: Quality Reviewer | Status: NEEDS_CHANGES
VERDICT: NEEDS_CHANGES
Findings
RULE 0 CRITICAL: Unhandled Stripe customer creation failure in upgrade flow
ensureStripeCustomer()private method with cleanup logic. Addresses both failure handling and RULE 2 duplication concern.RULE 0 CRITICAL: Race condition between upgrade and webhook payment processing
resolveStripeCustomerId(). Webhook handlers already handle "not found" gracefully with warning logs. Stripe webhooks are typically delayed by seconds, not milliseconds. Not introduced by this refactor.RULE 0 HIGH: Blocking syncTierToUserProfile breaks graceful degradation
RULE 1 HIGH: Missing TypeScript type updates in API layer
RULE 2 SHOULD_FIX: Code duplication for Stripe customer creation
ensureStripeCustomer()private method (also addresses CRITICAL #1).RULE 2 SHOULD_FIX: Dead code removal incomplete
QR Review: plan-docs
Phase: Plan-Review | Agent: Quality Reviewer | Status: PASS_WITH_CONCERNS
VERDICT: PASS_WITH_CONCERNS
Findings
Missing NULL vs Sentinel Value trade-off
Grace period job implications not explained
API contract change needs breaking change highlight
Feature Context section for README
Verdict: Revise plan, then APPROVED | Next: Final plan revision
Milestone: Execution Complete
Phase: Execution | Agent: Orchestrator | Status: PASS
Milestone 1: Database migration + type definitions (#207)
002_nullable_stripe_customer_id.sql- DROP NOT NULL + cleanup admin_override_* to NULLSubscription.stripeCustomerId: string | null(backend)SubscriptionResponse.stripeCustomerId: string | null(backend)UpdateSubscriptionData.stripeCustomerId?: string | null(backend)Subscription.stripeCustomerId: string | null(frontend)Milestone 2: Repository layer updates (#208)
createForAdminOverride()now INSERTs withstripe_customer_id = NULL(placeholder removed)mapSubscriptionRow()usesrow.stripe_customer_id ?? null(nullish coalescing)create()parameter type updated to accept optionalstripeCustomerIdMilestone 3: Service - admin override + sync hardening (#209)
resolveStripeCustomerId()deleted entirely (30 lines removed)syncTierToUserProfile()made blocking - try-catch with error swallowing removedMilestone 4: Service - Stripe flows + null handling (#210)
ensureStripeCustomer()extracted as private method with orphaned Stripe customer cleanupupgradeSubscription()usesensureStripeCustomer()instead of deleted methodupdatePaymentMethod()usesensureStripeCustomer()instead of deleted methodgetInvoices()simplified from prefix check to!subscription?.stripeCustomerIdcancelSubscription()+reactivateSubscription()- null guard added for stripeCustomerIddeleteCustomer()added to StripeClient for cleanup logicValidation
Commits
af11b49refactor: add migration and nullable types for stripe_customer_id (refs #207)a6eea6crefactor: update repository for nullable stripe_customer_id (refs #208)93e79d1refactor: replace resolveStripeCustomerId with ensureStripeCustomer, harden sync (refs #209, refs #210)Verdict: PASS | Next: QR post-implementation review, then PR