Refactor the subscription system to cleanly separate admin-managed tier overrides from Stripe-managed billing. The current implementation uses an admin_override_ placeholder in the stripe_customer_id column, 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.
This is stored in subscriptions.stripe_customer_id (VARCHAR(255) UNIQUE NOT NULL). Every code path that touches this column must check for the admin_override_ prefix:
resolveStripeCustomerId() detects it during upgrades and creates a real Stripe customer
getInvoices() returns [] when it detects the prefix
Payment method endpoints must skip Stripe calls for these records
Root cause: The stripe_customer_id column has a NOT NULL constraint, forcing a placeholder value when Stripe is not involved.
Problem 2: Non-Blocking Tier Sync
The syncTierToUserProfile() method (service lines 804-816) swallows errors:
// Don't throw - we don't want to fail the subscription operation if sync fails
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_tier fails to update, the auth context reads stale tier data.
Price IDs vs Product IDs (Non-Issue)
The system uses price_ IDs (not product_ 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.create requires a price parameter. The getPriceId() method mapping tier+billingCycle to environment variables is the standard pattern.
Proposed Changes
1. Make stripe_customer_id NULLABLE
Migration: ALTER subscriptions.stripe_customer_id to drop NOT NULL constraint:
Remove the resolveStripeCustomerId() lazy resolution method
When user upgrades from admin-set tier, create Stripe customer directly in the upgrade flow
Webhook handlers already use findByStripeCustomerId() - NULL records will never match (correct behavior)
2. Make syncTierToUserProfile() Blocking
Change from non-blocking (error swallowed) to blocking (throw on failure):
privateasyncsyncTierToUserProfile(userId: string,tier: SubscriptionTier):Promise<void>{awaitthis.userProfileRepository.updateSubscriptionTier(userId,tier);logger.info('Subscription tier synced to user profile',{userId,tier});// Errors now propagate to caller
}
This is safe because:
Single-row UPDATE on indexed column takes <1ms
No Stripe webhook timeout risk
Prevents tier drift in ALL code paths (upgrades, downgrades, webhooks)
3. Clean Admin Override Flow
Remove createForAdminOverride() placeholder logic. The admin flow becomes:
Check if subscription record exists
If not, create with stripe_customer_id = NULL, stripe_subscription_id = NULL
Set tier and status atomically in transaction (both tables)
When user later pays via Stripe, upgrade flow creates the Stripe customer
Files Affected
Backend:
backend/src/features/subscriptions/data/subscriptions.repository.ts - Remove createForAdminOverride(), update queries for nullable stripe_customer_id
backend/src/features/subscriptions/domain/subscriptions.service.ts - Remove resolveStripeCustomerId(), update adminOverrideTier(), make syncTierToUserProfile() blocking
backend/src/features/subscriptions/api/subscriptions.controller.ts - Update getInvoices/payment-method to check for null instead of prefix
backend/src/features/subscriptions/migrations/ - New migration for nullable stripe_customer_id
Frontend (minor):
frontend/src/features/subscription/pages/SubscriptionPage.tsx - Conditional rendering for invoices/payment when no Stripe customer
Acceptance Criteria
stripe_customer_id column is NULLABLE (migration applied)
All existing admin_override_* values migrated to NULL
resolveStripeCustomerId() method removed
createForAdminOverride() creates records with NULL stripe_customer_id
Admin can set any tier (free/pro/enterprise) without Stripe interaction
User with admin-set tier can later upgrade via Stripe payment (new Stripe customer created in upgrade flow)
User with admin-set tier that is downgraded can still pay for upgrade via Stripe
syncTierToUserProfile() throws on failure (blocking)
Webhook handlers continue to function (NULL stripe_customer_id records are never matched)
Invoices endpoint returns [] for users without Stripe customer
UUID migration: SEPARATED into independent issue (not bundled here to reduce risk)
## 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 the `stripe_customer_id` column, 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
- Original Stripe implementation: #55
- Tier gating system: #8
- Tier sync bug (fixed): #58
## Background
### Problem 1: `admin_override_` Placeholder Pattern
When an admin overrides a user's tier, the system generates a fake Stripe customer ID:
```typescript
// subscriptions.repository.ts:583
const placeholderCustomerId = `admin_override_${userId}_${Date.now()}`;
```
This is stored in `subscriptions.stripe_customer_id` (VARCHAR(255) UNIQUE NOT NULL). Every code path that touches this column must check for the `admin_override_` prefix:
- `resolveStripeCustomerId()` detects it during upgrades and creates a real Stripe customer
- `getInvoices()` returns `[]` when it detects the prefix
- Payment method endpoints must skip Stripe calls for these records
**Root cause**: The `stripe_customer_id` column has a `NOT NULL` constraint, forcing a placeholder value when Stripe is not involved.
### Problem 2: Non-Blocking Tier Sync
The `syncTierToUserProfile()` method (service lines 804-816) swallows errors:
```typescript
// Don't throw - we don't want to fail the subscription operation if sync fails
```
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_tier` fails to update, the auth context reads stale tier data.
### Price IDs vs Product IDs (Non-Issue)
The system uses `price_` IDs (not `product_` 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.create` requires a `price` parameter. The `getPriceId()` method mapping tier+billingCycle to environment variables is the standard pattern.
## Proposed Changes
### 1. Make `stripe_customer_id` NULLABLE
**Migration**: ALTER `subscriptions.stripe_customer_id` to drop NOT NULL constraint:
```sql
ALTER TABLE subscriptions ALTER COLUMN stripe_customer_id DROP NOT NULL;
```
- Clean up existing `admin_override_*` values to NULL
- Remove the `resolveStripeCustomerId()` lazy resolution method
- When user upgrades from admin-set tier, create Stripe customer directly in the upgrade flow
- Webhook handlers already use `findByStripeCustomerId()` - NULL records will never match (correct behavior)
### 2. Make `syncTierToUserProfile()` Blocking
Change from non-blocking (error swallowed) to blocking (throw on failure):
```typescript
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
logger.info('Subscription tier synced to user profile', { userId, tier });
// Errors now propagate to caller
}
```
This is safe because:
- Single-row UPDATE on indexed column takes <1ms
- No Stripe webhook timeout risk
- Prevents tier drift in ALL code paths (upgrades, downgrades, webhooks)
### 3. Clean Admin Override Flow
Remove `createForAdminOverride()` placeholder logic. The admin flow becomes:
1. Check if subscription record exists
2. If not, create with `stripe_customer_id = NULL`, `stripe_subscription_id = NULL`
3. Set tier and status atomically in transaction (both tables)
4. When user later pays via Stripe, upgrade flow creates the Stripe customer
## Files Affected
**Backend**:
- `backend/src/features/subscriptions/data/subscriptions.repository.ts` - Remove `createForAdminOverride()`, update queries for nullable stripe_customer_id
- `backend/src/features/subscriptions/domain/subscriptions.service.ts` - Remove `resolveStripeCustomerId()`, update `adminOverrideTier()`, make `syncTierToUserProfile()` blocking
- `backend/src/features/subscriptions/api/subscriptions.controller.ts` - Update getInvoices/payment-method to check for null instead of prefix
- `backend/src/features/subscriptions/migrations/` - New migration for nullable stripe_customer_id
**Frontend** (minor):
- `frontend/src/features/subscription/pages/SubscriptionPage.tsx` - Conditional rendering for invoices/payment when no Stripe customer
## Acceptance Criteria
- [ ] `stripe_customer_id` column is NULLABLE (migration applied)
- [ ] All existing `admin_override_*` values migrated to NULL
- [ ] `resolveStripeCustomerId()` method removed
- [ ] `createForAdminOverride()` creates records with NULL stripe_customer_id
- [ ] Admin can set any tier (free/pro/enterprise) without Stripe interaction
- [ ] User with admin-set tier can later upgrade via Stripe payment (new Stripe customer created in upgrade flow)
- [ ] User with admin-set tier that is downgraded can still pay for upgrade via Stripe
- [ ] `syncTierToUserProfile()` throws on failure (blocking)
- [ ] Webhook handlers continue to function (NULL stripe_customer_id records are never matched)
- [ ] Invoices endpoint returns `[]` for users without Stripe customer
- [ ] Payment method endpoint gracefully handles NULL stripe_customer_id
- [ ] All linters pass
- [ ] All tests pass
- [ ] Mobile + desktop responsive validation
## Test Plan
1. **Admin Override Flow**:
- Admin sets user from Free to Pro - verify both tables updated, no placeholder in stripe_customer_id
- Admin sets user from Pro to Free - verify downgrade, vehicle selection prompt if needed
- Admin sets user from Free to Enterprise - verify upgrade
2. **Stripe Upgrade After Admin Override**:
- Admin sets user to Pro (no Stripe)
- Admin downgrades to Free
- User pays for Pro via Stripe - verify new Stripe customer created, subscription active
- Verify webhooks work for this user going forward
3. **Blocking Sync**:
- Verify tier changes fail atomically if user_profiles update fails
- Verify webhook-triggered tier changes sync correctly
4. **Regression**:
- Normal Stripe upgrade flow (no admin involvement) still works
- Normal Stripe downgrade flow still works
- Webhook processing still works
- Grace period job still processes correctly
## Decision Critic Results
Decisions stress-tested via Decision Critic skill. Key findings:
- Eliminating `admin_override_` entirely: **VERIFIED** - NULLABLE stripe_customer_id correctly isolates admin subscriptions from Stripe webhooks
- Blocking sync: **VERIFIED** - Safe, <1ms operation, prevents drift in all paths
- Manual tier change only: **VERIFIED** - Seamless upgrade path preserved
- UUID migration: **SEPARATED** into independent issue (not bundled here to reduce risk)
upgradeSubscription() and updatePaymentMethod() call resolveStripeCustomerId()
Controller (subscriptions.controller.ts):
8 endpoints, all authenticated. Controller delegates to service - admin_override_ checks are in service layer
cancel/reactivate endpoints 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)
Index exists: idx_subscriptions_stripe_customer_id
Migration numbering: {3-digit}_{snake_case}.sql
PostgreSQL UNIQUE allows multiple NULLs (safe for this refactor)
Frontend: Zero references to admin_override_ or stripeCustomerId in UI. Already handles empty invoices gracefully. Type-only change needed.
Grace Period Job (grace-period.job.ts): Does NOT reference stripe_customer_id or call syncTierToUserProfile(). 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): Calls adminOverrideTier() 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
Decision
Reasoning Chain
Verification
NULLABLE stripe_customer_id
admin_override_ placeholder violates column semantics -> caused bug #58 -> every code path must check prefix -> NULL correctly represents "no Stripe customer" -> eliminates all prefix checks
Decision Critic: VERIFIED. PostgreSQL UNIQUE allows multiple NULLs. Webhook handlers use findByStripeCustomerId() which returns null for no match - NULL records correctly isolated.
Blocking syncTierToUserProfile()
Non-blocking swallows errors silently -> user_profiles tier drifts from subscriptions.tier -> auth context reads stale tier -> user sees wrong features
Decision Critic: VERIFIED. Single-row UPDATE on indexed column <1ms. No network calls in sync path. No webhook timeout risk. Only failure mode (DB connection loss) would also fail the preceding subscription update.
Extract ensureStripeCustomer() method
Inline Stripe customer creation in upgrade + updatePaymentMethod is duplicated -> extract private method with cleanup logic for orphaned Stripe customers if repository.update() fails
QR plan-code: ACCEPTED. Addresses RULE 0 (failure handling) and RULE 2 (duplication).
cancel/reactivate throw BadRequestError for NULL
Admin-set tiers have no Stripe subscription to cancel/reactivate -> calling Stripe API would fail with cryptic error -> explicit error message guides user to correct action
Aligns with existing pattern: other endpoints already return clear errors for missing resources. Guard placed in service layer.
Rejected Alternatives
Alternative
Why Rejected
UUID migration for subscription IDs
Large scope, independent risk profile. Separated into independent issue to reduce blast radius.
Separate admin_subscriptions table
Over-engineers the solution. Same table with NULL stripe fields cleanly separates the two modes. Admin users can transition to Stripe seamlessly.
Keep non-blocking sync with retry
Adds complexity. The sync operation is <1ms on indexed column. Blocking is simpler and prevents drift.
Non-blocking sync with escalated logging
QR suggested keeping try-catch with alert flags. Rejected: the only failure mode (DB down) would also fail the preceding subscription update, making the non-blocking path unreachable. Blocking is simpler and correct.
stripe_subscription_id is already NULLABLE in the schema - this refactor aligns stripe_customer_id to the same pattern
No GET payment method endpoint exists - only PUT (updatePaymentMethod). Confirmed by codebase analysis.
Frontend does not render stripeCustomerId anywhere - type change only, no UI impact
Grace period job uses inline SQL, not syncTierToUserProfile() - no impact from blocking change. Admin override records (stripe_subscription_id = NULL) are naturally excluded from expiration.
API contract change: GET /api/subscriptions response field stripeCustomerId changes from always-string (including admin_override_* values) to string | null. Frontend does not display this field, so no UI impact. Single-tenant application with no external API consumers.
Race condition between upgrade and webhook: Pre-existing in resolveStripeCustomerId(). Webhook handlers already handle "not found" gracefully. Stripe webhooks are delayed by seconds. Not introduced or worsened by this refactor.
Tests directory for subscriptions feature is empty - no existing tests to update when removing resolveStripeCustomerId().
Milestone 1: Database migration + type definitions
Sub-issue: #207refactor: Database migration for nullable stripe_customer_id (#205)
-- Drop NOT NULL constraint on stripe_customer_id
ALTERTABLEsubscriptionsALTERCOLUMNstripe_customer_idDROPNOTNULL;-- Clean up existing admin_override_ placeholder values
UPDATEsubscriptionsSETstripe_customer_id=NULLWHEREstripe_customer_idLIKE'admin_override_%';
Index preserved for query performance on non-NULL values
Backend type (subscriptions.types.ts):
Subscription interface: change stripeCustomerId: string to stripeCustomerId: string | null
SubscriptionResponse type: change stripeCustomerId: string to stripeCustomerId: string | null
UpdateSubscriptionData type: change stripeCustomerId?: string to stripeCustomerId?: string | null
Frontend type (subscription.types.ts):
Change stripeCustomerId: string to stripeCustomerId: string | null
Tests: 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: #208refactor: Repository updates for nullable stripe_customer_id (#205)
Change line 626 to use nullish coalescing: stripeCustomerId: row.stripe_customer_id ?? null
This avoids coercing empty strings and is consistent with the null type
create() method (lines 30-57):
Make stripeCustomerId optional in the parameter type
Default to NULL in INSERT when not provided
Tests: 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: #209refactor: Remove resolveStripeCustomerId and harden tier sync (#205)
Replacement: ensureStripeCustomer() in Milestone 4
Update adminOverrideTier() (lines 899-960):
The method already uses a transaction and syncs both tables atomically
Only change: createForAdminOverride() creates with NULL (handled by Milestone 2)
No logic changes needed in this method - it delegates correctly
Make syncTierToUserProfile() blocking (lines 804-816):
Remove the try-catch that swallows errors
Let errors propagate to caller
// After:
privateasyncsyncTierToUserProfile(userId: string,tier: SubscriptionTier):Promise<void>{awaitthis.userProfileRepository.updateSubscriptionTier(userId,tier);logger.info('Subscription tier synced to user profile',{userId,tier});}
Safe because: single-row UPDATE on indexed column (<1ms), no webhook timeout risk
All 4 callers (upgradeSubscription, downgradeSubscription, handleSubscriptionUpdated, handleSubscriptionDeleted) fail atomically if sync fails
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: #210refactor: Update Stripe flows for nullable stripe_customer_id (#205)
// Create or return existing Stripe customer for a subscription.
// Handles cleanup of orphaned Stripe customer if DB update fails.
privateasyncensureStripeCustomer(subscription: Subscription,email: string):Promise<string>{if(subscription.stripeCustomerId){returnsubscription.stripeCustomerId;}conststripeCustomer=awaitthis.stripeClient.createCustomer(email);try{awaitthis.repository.update(subscription.id,{stripeCustomerId: stripeCustomer.id});logger.info('Created Stripe customer for subscription',{subscriptionId: subscription.id,stripeCustomerId: stripeCustomer.id,});returnstripeCustomer.id;}catch(error){// Attempt cleanup of orphaned Stripe customer
try{awaitthis.stripeClient.deleteCustomer(stripeCustomer.id);logger.warn('Rolled back orphaned Stripe customer after DB update failure',{stripeCustomerId: stripeCustomer.id,});}catch(cleanupError: any){logger.error('Failed to cleanup orphaned Stripe customer',{stripeCustomerId: stripeCustomer.id,cleanupError: cleanupError.message,});}throwerror;}}
Same pattern: replace resolveStripeCustomerId() call with ensureStripeCustomer()
getInvoices() (line 981):
Change from prefix check to null check:
if(!subscription?.stripeCustomerId){return[];}
Edge case hardening for cancelSubscription() and reactivateSubscription() (service layer):
Add null guard in service methods: if stripeCustomerId is null, throw BadRequestError with message: "Cannot cancel/reactivate subscription without active Stripe billing"
Controller comment cleanup: Remove any stale comments in subscriptions.controller.ts referencing admin_override_ pattern
Tests: 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 subscriptions table 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) and user_profiles.subscription_tier (auth context cache). All non-admin flows use syncTierToUserProfile() 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.
Added: NULL vs Sentinel Knowledge, Feature Context, grace period implications
Verdict: APPROVED | Next: Create branch and begin execution
## Plan: 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) generates `admin_override_{userId}_{timestamp}` placeholder
- `mapSubscriptionRow()` (lines 622-638) maps `stripe_customer_id` as always-present string
- `Subscription` type has `stripeCustomerId: 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_ detection
- `syncTierToUserProfile()` (lines 804-816) - **swallows all errors** (line 814 comment: "Don't throw")
- Called from 4 locations: upgradeSubscription (253), downgradeSubscription (453), handleSubscriptionUpdated (607), handleSubscriptionDeleted (642)
- `adminOverrideTier()` (lines 899-960) uses explicit transaction for atomic dual-table update
- `getInvoices()` (line 981) checks `admin_override_` prefix
- `upgradeSubscription()` and `updatePaymentMethod()` call `resolveStripeCustomerId()`
**Controller** (`subscriptions.controller.ts`):
- 8 endpoints, all authenticated. Controller delegates to service - admin_override_ checks are in service layer
- `cancel`/`reactivate` endpoints 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)
- Index exists: `idx_subscriptions_stripe_customer_id`
- Migration numbering: `{3-digit}_{snake_case}.sql`
- PostgreSQL UNIQUE allows multiple NULLs (safe for this refactor)
**Frontend**: Zero references to `admin_override_` or `stripeCustomerId` in UI. Already handles empty invoices gracefully. Type-only change needed.
**Grace Period Job** (`grace-period.job.ts`): Does NOT reference `stripe_customer_id` or call `syncTierToUserProfile()`. 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`): Calls `adminOverrideTier()` 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
| Decision | Reasoning Chain | Verification |
|----------|----------------|--------------|
| NULLABLE stripe_customer_id | `admin_override_` placeholder violates column semantics -> caused bug #58 -> every code path must check prefix -> NULL correctly represents "no Stripe customer" -> eliminates all prefix checks | Decision Critic: VERIFIED. PostgreSQL UNIQUE allows multiple NULLs. Webhook handlers use findByStripeCustomerId() which returns null for no match - NULL records correctly isolated. |
| Blocking syncTierToUserProfile() | Non-blocking swallows errors silently -> user_profiles tier drifts from subscriptions.tier -> auth context reads stale tier -> user sees wrong features | Decision Critic: VERIFIED. Single-row UPDATE on indexed column <1ms. No network calls in sync path. No webhook timeout risk. Only failure mode (DB connection loss) would also fail the preceding subscription update. |
| Extract ensureStripeCustomer() method | Inline Stripe customer creation in upgrade + updatePaymentMethod is duplicated -> extract private method with cleanup logic for orphaned Stripe customers if repository.update() fails | QR plan-code: ACCEPTED. Addresses RULE 0 (failure handling) and RULE 2 (duplication). |
| cancel/reactivate throw BadRequestError for NULL | Admin-set tiers have no Stripe subscription to cancel/reactivate -> calling Stripe API would fail with cryptic error -> explicit error message guides user to correct action | Aligns with existing pattern: other endpoints already return clear errors for missing resources. Guard placed in service layer. |
#### Rejected Alternatives
| Alternative | Why Rejected |
|-------------|-------------|
| UUID migration for subscription IDs | Large scope, independent risk profile. Separated into independent issue to reduce blast radius. |
| Separate admin_subscriptions table | Over-engineers the solution. Same table with NULL stripe fields cleanly separates the two modes. Admin users can transition to Stripe seamlessly. |
| Keep non-blocking sync with retry | Adds complexity. The sync operation is <1ms on indexed column. Blocking is simpler and prevents drift. |
| Non-blocking sync with escalated logging | QR suggested keeping try-catch with alert flags. Rejected: the only failure mode (DB down) would also fail the preceding subscription update, making the non-blocking path unreachable. Blocking is simpler and correct. |
#### Constraints & Assumptions
- PostgreSQL UNIQUE constraint allows multiple NULL values (SQL standard behavior, verified)
- `stripe_subscription_id` is already NULLABLE in the schema - this refactor aligns `stripe_customer_id` to the same pattern
- No GET payment method endpoint exists - only PUT (updatePaymentMethod). Confirmed by codebase analysis.
- Frontend does not render `stripeCustomerId` anywhere - type change only, no UI impact
- Grace period job uses inline SQL, not syncTierToUserProfile() - no impact from blocking change. Admin override records (stripe_subscription_id = NULL) are naturally excluded from expiration.
- **API contract change**: `GET /api/subscriptions` response field `stripeCustomerId` changes from always-string (including `admin_override_*` values) to `string | null`. Frontend does not display this field, so no UI impact. Single-tenant application with no external API consumers.
- Race condition between upgrade and webhook: Pre-existing in `resolveStripeCustomerId()`. Webhook handlers already handle "not found" gracefully. Stripe webhooks are delayed by seconds. Not introduced or worsened by this refactor.
- Tests directory for subscriptions feature is empty - no existing tests to update when removing `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.ts`
- `frontend/src/features/subscription/types/subscription.types.ts`
**Changes**:
1. **New migration** `002_nullable_stripe_customer_id.sql`:
```sql
-- Drop NOT NULL constraint on stripe_customer_id
ALTER TABLE subscriptions ALTER COLUMN stripe_customer_id DROP NOT NULL;
-- Clean up existing admin_override_ placeholder values
UPDATE subscriptions SET stripe_customer_id = NULL
WHERE stripe_customer_id LIKE 'admin_override_%';
```
- UNIQUE constraint preserved (PostgreSQL allows multiple NULLs)
- Index preserved for query performance on non-NULL values
2. **Backend type** (`subscriptions.types.ts`):
- `Subscription` interface: change `stripeCustomerId: string` to `stripeCustomerId: string | null`
- `SubscriptionResponse` type: change `stripeCustomerId: string` to `stripeCustomerId: string | null`
- `UpdateSubscriptionData` type: change `stripeCustomerId?: string` to `stripeCustomerId?: string | null`
3. **Frontend type** (`subscription.types.ts`):
- Change `stripeCustomerId: string` to `stripeCustomerId: string | null`
**Tests**: 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.ts`
**Changes**:
1. **`createForAdminOverride()`** (lines 576-611):
- Remove placeholder generation (`admin_override_{userId}_{timestamp}`)
- INSERT with `stripe_customer_id = NULL` instead
2. **`mapSubscriptionRow()`** (lines 622-638):
- Change line 626 to use nullish coalescing: `stripeCustomerId: row.stripe_customer_id ?? null`
- This avoids coercing empty strings and is consistent with the null type
3. **`create()` method** (lines 30-57):
- Make `stripeCustomerId` optional in the parameter type
- Default to NULL in INSERT when not provided
**Tests**: 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.ts`
**Changes**:
1. **Delete `resolveStripeCustomerId()`** (lines 173-198):
- Remove the entire method
- Replacement: `ensureStripeCustomer()` in Milestone 4
2. **Update `adminOverrideTier()`** (lines 899-960):
- The method already uses a transaction and syncs both tables atomically
- Only change: `createForAdminOverride()` creates with NULL (handled by Milestone 2)
- No logic changes needed in this method - it delegates correctly
3. **Make `syncTierToUserProfile()` blocking** (lines 804-816):
- Remove the try-catch that swallows errors
- Let errors propagate to caller
```typescript
// After:
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
logger.info('Subscription tier synced to user profile', { userId, tier });
}
```
- Safe because: single-row UPDATE on indexed column (<1ms), no webhook timeout risk
- All 4 callers (upgradeSubscription, downgradeSubscription, handleSubscriptionUpdated, handleSubscriptionDeleted) fail atomically if sync fails
**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.ts`
- `backend/src/features/subscriptions/api/subscriptions.controller.ts` (comment cleanup only)
**Changes**:
1. **Extract `ensureStripeCustomer()` private method** (replaces `resolveStripeCustomerId()`):
```typescript
// Create or return existing Stripe customer for a subscription.
// Handles cleanup of orphaned Stripe customer if DB update fails.
private async ensureStripeCustomer(
subscription: Subscription,
email: string
): Promise<string> {
if (subscription.stripeCustomerId) {
return subscription.stripeCustomerId;
}
const stripeCustomer = await this.stripeClient.createCustomer(email);
try {
await this.repository.update(subscription.id, { stripeCustomerId: stripeCustomer.id });
logger.info('Created Stripe customer for subscription', {
subscriptionId: subscription.id,
stripeCustomerId: stripeCustomer.id,
});
return stripeCustomer.id;
} catch (error) {
// Attempt cleanup of orphaned Stripe customer
try {
await this.stripeClient.deleteCustomer(stripeCustomer.id);
logger.warn('Rolled back orphaned Stripe customer after DB update failure', {
stripeCustomerId: stripeCustomer.id,
});
} catch (cleanupError: any) {
logger.error('Failed to cleanup orphaned Stripe customer', {
stripeCustomerId: stripeCustomer.id,
cleanupError: cleanupError.message,
});
}
throw error;
}
}
```
2. **`upgradeSubscription()`** (lines 203-280):
- Replace `resolveStripeCustomerId()` call (line 220) with:
```typescript
const stripeCustomerId = await this.ensureStripeCustomer(currentSubscription, email);
```
3. **`updatePaymentMethod()`** (lines 965-973):
- Same pattern: replace `resolveStripeCustomerId()` call with `ensureStripeCustomer()`
4. **`getInvoices()`** (line 981):
- Change from prefix check to null check:
```typescript
if (!subscription?.stripeCustomerId) {
return [];
}
```
5. **Edge case hardening** for `cancelSubscription()` and `reactivateSubscription()` (service layer):
- Add null guard in service methods: if `stripeCustomerId` is null, throw BadRequestError with message: "Cannot cancel/reactivate subscription without active Stripe billing"
6. **Controller comment cleanup**: Remove any stale comments in `subscriptions.controller.ts` referencing `admin_override_` pattern
**Tests**: 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 `subscriptions` table 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) and `user_profiles.subscription_tier` (auth context cache). All non-admin flows use `syncTierToUserProfile()` 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
```
Milestone 1 (Migration + Types)
|
Milestone 2 (Repository)
|
Milestone 3 (Service - Delete resolve + Harden sync)
|
Milestone 4 (Service - ensureStripeCustomer + null handling)
```
### Risk Assessment
| Risk | Mitigation |
|------|-----------|
| Migration on production data | Migration is additive (DROP NOT NULL), reversible. UPDATE is targeted. |
| Multiple NULLs in UNIQUE column | PostgreSQL allows multiple NULLs in UNIQUE - verified. |
| Blocking sync increases failure surface | Single-row indexed UPDATE <1ms. Only failure mode (DB down) also fails preceding operations. |
| Orphaned Stripe customer on DB failure | ensureStripeCustomer() has cleanup logic - attempts deleteCustomer() on repository failure. |
| Cancel/reactivate for admin-set users | Throw explicit BadRequestError with clear message (service layer). |
| Grace period job | Does NOT use stripe_customer_id - admin records naturally excluded. |
| Webhook handlers | Use findByStripeCustomerId() - NULL records never match, correct isolation. |
| API contract change | stripeCustomerId goes from always-string to nullable. Single-tenant, no external consumers. |
| Pre-existing webhook race condition | Webhook handlers handle "not found" gracefully. Not introduced by this refactor. |
### Files NOT Changed (Confirmed Safe)
- `backend/src/features/subscriptions/jobs/grace-period.job.ts` - No stripe_customer_id references
- `backend/src/features/admin/api/users.controller.ts` - Clean delegation to adminOverrideTier()
- `backend/src/features/subscriptions/api/subscriptions.routes.ts` - Route definitions unchanged
- `backend/src/features/subscriptions/external/stripe/` - Stripe client unchanged
- All frontend pages/components except type definition - No admin_override_ in UI
---
### Review History
| Review | Verdict | Key Changes |
|--------|---------|-------------|
| QR plan-completeness | PASS_WITH_CONCERNS | Added: Decision Log, Invisible Knowledge, Tests per milestone, Mobile validation |
| TW plan-scrub | MINOR_EDITS | Fixed: `??` operator, cancel/reactivate location, UpdateSubscriptionData type, temporal contamination, API contract change |
| QR plan-code | NEEDS_CHANGES | Added: ensureStripeCustomer() with cleanup. Acknowledged: pre-existing race condition. Disagreed: non-blocking sync (Decision Critic verified). |
| QR plan-docs | PASS_WITH_CONCERNS | Added: NULL vs Sentinel Knowledge, Feature Context, grace period implications |
---
*Verdict*: APPROVED | *Next*: Create branch and begin execution
RULE 1 HIGH: Missing Mobile + Desktop Validation in Milestones
Location: All milestone sections (1-4)
Issue: No milestone explicitly includes responsive validation
Suggested Fix: Add to Milestone 4: "Mobile validation: SubscriptionPage renders correctly on 320px, 768px, 1920px viewports when stripe_customer_id is NULL"
RULE 1 HIGH: Missing Test Coverage in Plan
Location: All milestones
Issue: Zero test specifications across all 4 milestones
Suggested Fix: Add Tests section to each milestone
RULE 1 HIGH: Missing Decision Log Section
Location: Plan structure
Issue: No "Decision Log", "Rejected Alternatives", or "Constraints & Assumptions" sections
Suggested Fix: Add Planning Context with decision chains
RULE 1 HIGH: Missing Invisible Knowledge Section
Location: Plan structure
Issue: No invisible knowledge content for TW agent to generate documentation
Suggested Fix: Add section or explicitly state empty
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
Issue: The plan says stripeCustomerId: row.stripe_customer_id || null but the current code at line 626 is stripeCustomerId: row.stripe_customer_id (no fallback). The || operator will coerce empty string "" to null, which may be the intent, but this is an implicit behavior change beyond the stated goal. Additionally, the existing mapper uses || undefined for other nullable fields (e.g., stripeSubscriptionId at line 627), creating an inconsistency: some nullable Stripe fields will return undefined, while stripeCustomerId will return null.
Suggested Edit: Clarify the mapping strategy explicitly:
stripeCustomerId: row.stripe_customer_id ?? null
Use nullish coalescing (??) instead of logical OR (||) to only coerce null/undefined to null, not empty strings. Also note: consider whether stripeSubscriptionId should follow the same ?? null pattern for consistency (currently || undefined). If intentionally different, state why.
Issue: The plan says "Add null guard: if stripeCustomerId is 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 throws new Error(...). The plan's guard is for a different field (stripeCustomerId), and the error type changes from Error to BadRequestError. The sub-issue #210 body says "handle null gracefully" which contradicts the plan comment's "throw BadRequestError."
Suggested Edit:
Specify the guard goes in the service layer (consistent with existing pattern at line 295).
Specify the exact position: add the stripeCustomerId null check before the existing stripeSubscriptionId check.
Update sub-issue #210 acceptance criterion from "Cancel/reactivate handle null gracefully" to "Cancel/reactivate throw BadRequestError with message 'Cannot cancel/reactivate subscription without active Stripe billing' when stripeCustomerId is null."
Location: Sub-issue #210 body vs. Plan comment Milestone 4
Issue: The plan comment (revised after QR review) includes detailed code snippets for upgradeSubscription(), explicit BadRequestError specification 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.
Suggested Edit: Update sub-issue #210 body to match the plan comment's Milestone 4 section exactly, including:
Code snippet for upgradeSubscription() inline logic
"throw BadRequestError" instead of "handle null gracefully"
Tests section
Mobile + Desktop Validation section with viewport widths
Location: Plan comment, Milestone 4, Change #1 (upgradeSubscription code snippet)
Issue: The inline code snippet calls await this.repository.update(currentSubscription.id, { stripeCustomerId }). Looking at the repository update() method (line 144), UpdateSubscriptionData already has stripeCustomerId?: string. However, after Milestone 1, the type changes to string | null. The plan does not state whether UpdateSubscriptionData.stripeCustomerId also needs updating. If the developer follows only the M1 type changes (Subscription interface), they may miss updating UpdateSubscriptionData.
Suggested Edit: Add to Milestone 1 type changes: "Update UpdateSubscriptionData.stripeCustomerId from string to string | null to match the Subscription interface change."
5. Temporal Contamination (Minor)
Location: Plan comment, multiple locations
Issue: Several phrases use temporal language that will age:
"is now blocking" (Invisible Knowledge, Dual-Table Sync Pattern) -- after implementation, "now" is meaningless
"now creates with NULL" (Milestone 3, Change #2) -- refers to the M2 change that hasn't happened yet at time of reading
"now fail atomically" (Milestone 3, Change #3) -- same issue
Suggested Edit: Replace temporal references with absolute statements:
"is now blocking" -> "is blocking"
"now creates with NULL" -> "creates with NULL (per Milestone 2)"
"now fail atomically" -> "fail atomically after this change"
6. Imprecise Scope: Controller Comment at Line 223
Location: Plan comment, Codebase Analysis Summary, "Controller" section
Issue: The plan states "Controller delegates to service - admin_override_ checks are in service layer" and flags "cancel/reactivate endpoints have NO admin_override_ guards." However, the controller at line 223 has an inline comment // 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.
Suggested Edit: Either:
(a) Add controller to Milestone 4 file list with a single change: "Remove stale comment referencing admin_override_ at line 223", or
(b) Add a note under "Files NOT Changed" acknowledging the stale comment will persist until a separate cleanup
7. Missing Specification: SubscriptionResponse Type Alignment
Issue: The plan says to update SubscriptionResponse type, but the current SubscriptionResponse (types file line 77) has stripeCustomerId: string (non-nullable). The plan mentions this change but does not address the mapToResponse() method in the service (line 997-1013) which assigns stripeCustomerId: subscription.stripeCustomerId directly. After the type change, SubscriptionResponse.stripeCustomerId becomes string | null, and the mapper will pass through null to the API response. This is technically correct but the plan should explicitly state that the API contract changes: stripeCustomerId field in JSON responses will be null instead of an admin_override_* string.
Suggested Edit: Add to Milestone 1: "API contract change: GET /api/subscriptions response will return stripeCustomerId: null for admin-set subscriptions instead of stripeCustomerId: '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 in upgradeSubscription() and updatePaymentMethod() should carry forward the "why" context. Suggest adding a comment template to Milestone 4:
// Create Stripe customer for admin-overridden subscription.
// Admin-set subscriptions have NULL stripeCustomerId.
// On first Stripe payment, we create the customer in-place
// rather than using lazy resolution (see Decision Log #3).
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
---
## 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
- **Location**: Plan comment, Milestone 2, Change #2
- **Issue**: The plan says `stripeCustomerId: row.stripe_customer_id || null` but the current code at line 626 is `stripeCustomerId: row.stripe_customer_id` (no fallback). The `||` operator will coerce empty string `""` to `null`, which may be the intent, but this is an implicit behavior change beyond the stated goal. Additionally, the existing mapper uses `|| undefined` for other nullable fields (e.g., `stripeSubscriptionId` at line 627), creating an inconsistency: some nullable Stripe fields will return `undefined`, while `stripeCustomerId` will return `null`.
- **Suggested Edit**: Clarify the mapping strategy explicitly:
```
stripeCustomerId: row.stripe_customer_id ?? null
```
Use nullish coalescing (`??`) instead of logical OR (`||`) to only coerce `null`/`undefined` to `null`, not empty strings. Also note: consider whether `stripeSubscriptionId` should follow the same `?? null` pattern for consistency (currently `|| undefined`). If intentionally different, state why.
### 2. Ambiguity: cancel/reactivate Error Handling Location
- **Location**: Plan comment, Milestone 4, Change #4
- **Issue**: The plan says "Add null guard: if `stripeCustomerId` is 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 throws `new Error(...)`. The plan's guard is for a *different* field (`stripeCustomerId`), and the error type changes from `Error` to `BadRequestError`. The sub-issue #210 body says "handle null gracefully" which contradicts the plan comment's "throw BadRequestError."
- **Suggested Edit**:
- Specify the guard goes in the **service** layer (consistent with existing pattern at line 295).
- Specify the exact position: add the `stripeCustomerId` null check *before* the existing `stripeSubscriptionId` check.
- Update sub-issue #210 acceptance criterion from "Cancel/reactivate handle null gracefully" to "Cancel/reactivate throw BadRequestError with message 'Cannot cancel/reactivate subscription without active Stripe billing' when stripeCustomerId is null."
### 3. Sub-Issue #210 Body Diverges from Plan Comment
- **Location**: Sub-issue #210 body vs. Plan comment Milestone 4
- **Issue**: The plan comment (revised after QR review) includes detailed code snippets for `upgradeSubscription()`, explicit `BadRequestError` specification 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.
- **Suggested Edit**: Update sub-issue #210 body to match the plan comment's Milestone 4 section exactly, including:
- Code snippet for `upgradeSubscription()` inline logic
- "throw BadRequestError" instead of "handle null gracefully"
- Tests section
- Mobile + Desktop Validation section with viewport widths
### 4. Missing Specification: repository.update() Call Signature
- **Location**: Plan comment, Milestone 4, Change #1 (upgradeSubscription code snippet)
- **Issue**: The inline code snippet calls `await this.repository.update(currentSubscription.id, { stripeCustomerId })`. Looking at the repository `update()` method (line 144), `UpdateSubscriptionData` already has `stripeCustomerId?: string`. However, after Milestone 1, the type changes to `string | null`. The plan does not state whether `UpdateSubscriptionData.stripeCustomerId` also needs updating. If the developer follows only the M1 type changes (Subscription interface), they may miss updating UpdateSubscriptionData.
- **Suggested Edit**: Add to Milestone 1 type changes: "Update `UpdateSubscriptionData.stripeCustomerId` from `string` to `string | null` to match the Subscription interface change."
### 5. Temporal Contamination (Minor)
- **Location**: Plan comment, multiple locations
- **Issue**: Several phrases use temporal language that will age:
- "is now blocking" (Invisible Knowledge, Dual-Table Sync Pattern) -- after implementation, "now" is meaningless
- "now creates with NULL" (Milestone 3, Change #2) -- refers to the M2 change that hasn't happened yet at time of reading
- "now fail atomically" (Milestone 3, Change #3) -- same issue
- **Suggested Edit**: Replace temporal references with absolute statements:
- "is now blocking" -> "is blocking"
- "now creates with NULL" -> "creates with NULL (per Milestone 2)"
- "now fail atomically" -> "fail atomically after this change"
### 6. Imprecise Scope: Controller Comment at Line 223
- **Location**: Plan comment, Codebase Analysis Summary, "Controller" section
- **Issue**: The plan states "Controller delegates to service - admin_override_ checks are in service layer" and flags "cancel/reactivate endpoints have NO admin_override_ guards." However, the controller at line 223 has an inline comment `// 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.
- **Suggested Edit**: Either:
- (a) Add controller to Milestone 4 file list with a single change: "Remove stale comment referencing admin_override_ at line 223", or
- (b) Add a note under "Files NOT Changed" acknowledging the stale comment will persist until a separate cleanup
### 7. Missing Specification: SubscriptionResponse Type Alignment
- **Location**: Plan comment, Milestone 1, Change #2
- **Issue**: The plan says to update `SubscriptionResponse` type, but the current `SubscriptionResponse` (types file line 77) has `stripeCustomerId: string` (non-nullable). The plan mentions this change but does not address the `mapToResponse()` method in the service (line 997-1013) which assigns `stripeCustomerId: subscription.stripeCustomerId` directly. After the type change, `SubscriptionResponse.stripeCustomerId` becomes `string | null`, and the mapper will pass through `null` to the API response. This is technically correct but the plan should explicitly state that the API contract changes: `stripeCustomerId` field in JSON responses will be `null` instead of an `admin_override_*` string.
- **Suggested Edit**: Add to Milestone 1: "API contract change: GET /api/subscriptions response will return `stripeCustomerId: null` for admin-set subscriptions instead of `stripeCustomerId: '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 in `upgradeSubscription()` and `updatePaymentMethod()` should carry forward the "why" context. Suggest adding a comment template to Milestone 4:
```typescript
// Create Stripe customer for admin-overridden subscription.
// Admin-set subscriptions have NULL stripeCustomerId.
// On first Stripe payment, we create the customer in-place
// rather than using lazy resolution (see Decision Log #3).
```
- **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
Issue: Plan specifies || operator for null coercion but should use ?? (nullish coalescing) to avoid coercing empty strings. Inconsistency: stripeSubscriptionId uses || undefined while proposed stripeCustomerId uses || null.
Fix: Use row.stripe_customer_id ?? null consistently.
2. cancel/reactivate Error Location Unspecified
Location: Milestone 4
Issue: Does not specify whether BadRequestError guard goes in service or controller layer.
Fix: Specify service layer (aligns with existing business logic pattern).
Issue: Plan updates Subscription.stripeCustomerId but does not mention UpdateSubscriptionData.stripeCustomerId in same types file.
Fix: Add to Milestone 1 changes.
5. Temporal Contamination (3 instances)
Location: Milestones 3, 4
Issue: "is now blocking", "now creates with NULL", "now fail atomically" will age poorly.
Fix: Use absolute statements: "is blocking", "creates with NULL", "fail atomically".
6. Stale Controller Comment
Location: Files NOT Changed section
Issue: Controller line 223 may have comment referencing admin_override_ that becomes stale, but controller listed as "no changes".
Fix: Add controller comment cleanup to Milestone 4 or document as known tech debt.
7. API Contract Change Not Documented
Location: Planning Context
Issue: SubscriptionResponse type change means API returns null instead of admin_override_* strings. This is an API contract change.
Fix: Add to Constraints & Assumptions as explicit API contract note.
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
## TW Review: plan-scrub
**Phase**: Plan-Review | **Agent**: Technical Writer | **Status**: MINOR_EDITS
---
### VERDICT: MINOR_EDITS
### Findings
#### 1. mapSubscriptionRow NULL Handling Precision
- **Location**: Milestone 2
- **Issue**: Plan specifies `||` operator for null coercion but should use `??` (nullish coalescing) to avoid coercing empty strings. Inconsistency: `stripeSubscriptionId` uses `|| undefined` while proposed `stripeCustomerId` uses `|| null`.
- **Fix**: Use `row.stripe_customer_id ?? null` consistently.
#### 2. cancel/reactivate Error Location Unspecified
- **Location**: Milestone 4
- **Issue**: Does not specify whether BadRequestError guard goes in service or controller layer.
- **Fix**: Specify service layer (aligns with existing business logic pattern).
#### 3. Sub-Issue #210 Body Diverges from Plan
- **Location**: Sub-issue #210
- **Issue**: Revised plan comment includes code snippets, explicit BadRequestError, mobile validation, and tests absent from sub-issue body.
- **Fix**: Update sub-issue #210 body to match plan.
#### 4. Missing UpdateSubscriptionData Type Change
- **Location**: Milestone 1
- **Issue**: Plan updates `Subscription.stripeCustomerId` but does not mention `UpdateSubscriptionData.stripeCustomerId` in same types file.
- **Fix**: Add to Milestone 1 changes.
#### 5. Temporal Contamination (3 instances)
- **Location**: Milestones 3, 4
- **Issue**: "is now blocking", "now creates with NULL", "now fail atomically" will age poorly.
- **Fix**: Use absolute statements: "is blocking", "creates with NULL", "fail atomically".
#### 6. Stale Controller Comment
- **Location**: Files NOT Changed section
- **Issue**: Controller line 223 may have comment referencing admin_override_ that becomes stale, but controller listed as "no changes".
- **Fix**: Add controller comment cleanup to Milestone 4 or document as known tech debt.
#### 7. API Contract Change Not Documented
- **Location**: Planning Context
- **Issue**: SubscriptionResponse type change means API returns `null` instead of `admin_override_*` strings. This is an API contract change.
- **Fix**: Add to Constraints & Assumptions as explicit API contract note.
### 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
Issue: If createCustomer() succeeds but repository.update() fails, orphaned Stripe customer exists with no DB linkage
Failure Mode: Stripe customer billed but subscription record has NULL stripeCustomerId. Webhooks can't match.
Resolution: ACCEPTED. Extract 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
Location: Milestone 4 - upgradeSubscription()
Issue: Webhook can arrive before repository.update() commits new stripeCustomerId
Resolution: ACKNOWLEDGED BUT NOT BLOCKING. This is a pre-existing race condition in 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.
Issue: QR suggests keeping non-blocking with escalated logging instead of blocking
Resolution: DISAGREE - blocking is correct per Decision Critic verification. The sync is a single-row UPDATE on indexed column (<1ms). The only failure mode (DB connection loss) would also fail the preceding subscription update. If DB is down, the whole operation fails before reaching sync. Blocking prevents tier drift in ALL code paths.
RULE 1 HIGH: Missing TypeScript type updates in API layer
Location: Milestone 1 types
Issue: SubscriptionResponse and request validator types not mentioned
Resolution: ACCEPTED. Already added UpdateSubscriptionData to Milestone 1 in TW revision. SubscriptionResponse was already listed. Request schemas don't include stripeCustomerId (it's server-set). No gap.
RULE 2 SHOULD_FIX: Code duplication for Stripe customer creation
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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