Compare commits

...

4 Commits

Author SHA1 Message Date
5f0da87110 Merge pull request 'refactor: Clean up subscription admin override and Stripe integration (#205)' (#218) from issue-205-clean-subscription-admin-override into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 38s
Deploy to Staging / Deploy to Staging (push) Successful in 24s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #218
2026-02-16 15:44:10 +00:00
Eric Gullickson
93e79d1170 refactor: replace resolveStripeCustomerId with ensureStripeCustomer, harden sync (refs #209, refs #210)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m33s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Delete resolveStripeCustomerId() and replace with ensureStripeCustomer()
that includes orphaned Stripe customer cleanup on DB failure. Make
syncTierToUserProfile() blocking (errors propagate). Add null guards to
cancel/reactivate for admin-set subscriptions. Fix getInvoices() null
check. Clean controller comment. Add deleteCustomer() to StripeClient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:29:02 -06:00
Eric Gullickson
a6eea6c9e2 refactor: update repository for nullable stripe_customer_id (refs #208)
Remove admin_override_ placeholder from createForAdminOverride(), use NULL.
Update mapSubscriptionRow() with ?? null. Make stripeCustomerId optional
in create() method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:28:52 -06:00
Eric Gullickson
af11b49e26 refactor: add migration and nullable types for stripe_customer_id (refs #207)
Make stripe_customer_id NULLABLE via migration, clean up admin_override_*
values to NULL, and update Subscription/SubscriptionResponse/UpdateSubscriptionData
types in both backend and frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:28:46 -06:00
7 changed files with 81 additions and 48 deletions

View File

@@ -220,7 +220,7 @@ export class SubscriptionsController {
return; return;
} }
// Update payment method via service (handles admin_override_ customer IDs) // Update payment method via service (creates Stripe customer if needed)
await this.service.updatePaymentMethod(userId, paymentMethodId, email); await this.service.updatePaymentMethod(userId, paymentMethodId, email);
reply.status(200).send({ reply.status(200).send({

View File

@@ -27,7 +27,7 @@ export class SubscriptionsRepository {
/** /**
* Create a new subscription * Create a new subscription
*/ */
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> { async create(data: CreateSubscriptionRequest & { stripeCustomerId?: string | null }): Promise<Subscription> {
const query = ` const query = `
INSERT INTO subscriptions ( INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle user_id, stripe_customer_id, tier, billing_cycle
@@ -38,7 +38,7 @@ export class SubscriptionsRepository {
const values = [ const values = [
data.userId, data.userId,
data.stripeCustomerId, data.stripeCustomerId ?? null,
data.tier, data.tier,
data.billingCycle, data.billingCycle,
]; ];
@@ -579,18 +579,16 @@ export class SubscriptionsRepository {
client?: any client?: any
): Promise<Subscription> { ): Promise<Subscription> {
const queryClient = client || this.pool; const queryClient = client || this.pool;
// Generate a placeholder Stripe customer ID since admin override bypasses Stripe
const placeholderCustomerId = `admin_override_${userId}_${Date.now()}`;
const query = ` const query = `
INSERT INTO subscriptions ( INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle, status user_id, stripe_customer_id, tier, billing_cycle, status
) )
VALUES ($1, $2, $3, 'monthly', 'active') VALUES ($1, NULL, $2, 'monthly', 'active')
RETURNING * RETURNING *
`; `;
const values = [userId, placeholderCustomerId, tier]; const values = [userId, tier];
try { try {
const result = await queryClient.query(query, values); const result = await queryClient.query(query, values);
@@ -623,7 +621,7 @@ export class SubscriptionsRepository {
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
stripeCustomerId: row.stripe_customer_id, stripeCustomerId: row.stripe_customer_id ?? null,
stripeSubscriptionId: row.stripe_subscription_id || undefined, stripeSubscriptionId: row.stripe_subscription_id || undefined,
tier: row.tier, tier: row.tier,
billingCycle: row.billing_cycle || undefined, billingCycle: row.billing_cycle || undefined,

View File

@@ -166,35 +166,42 @@ export class SubscriptionsService {
} }
/** /**
* Resolve admin_override_ placeholder customer IDs to real Stripe customers. * Create or return existing Stripe customer for a subscription.
* When an admin overrides a user's tier without Stripe, a placeholder ID is stored. * Admin-set subscriptions have NULL stripeCustomerId. On first Stripe payment,
* This method creates a real Stripe customer and updates the subscription record. * the customer is created in-place. Includes cleanup of orphaned Stripe customer
* if the DB update fails after customer creation.
*/ */
private async resolveStripeCustomerId( private async ensureStripeCustomer(
subscription: Subscription, subscription: Subscription,
email: string email: string
): Promise<string> { ): Promise<string> {
if (!subscription.stripeCustomerId.startsWith('admin_override_')) { if (subscription.stripeCustomerId) {
return subscription.stripeCustomerId; return subscription.stripeCustomerId;
} }
logger.info('Replacing admin_override_ placeholder with real Stripe customer', {
subscriptionId: subscription.id,
userId: subscription.userId,
});
const stripeCustomer = await this.stripeClient.createCustomer(email); const stripeCustomer = await this.stripeClient.createCustomer(email);
try {
await this.repository.update(subscription.id, { await this.repository.update(subscription.id, { stripeCustomerId: stripeCustomer.id });
stripeCustomerId: stripeCustomer.id, logger.info('Created Stripe customer for subscription', {
}); subscriptionId: subscription.id,
stripeCustomerId: stripeCustomer.id,
logger.info('Stripe customer created for admin-overridden subscription', { });
subscriptionId: subscription.id, return stripeCustomer.id;
stripeCustomerId: stripeCustomer.id, } catch (error) {
}); // Attempt cleanup of orphaned Stripe customer
try {
return stripeCustomer.id; 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;
}
} }
/** /**
@@ -216,8 +223,8 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); throw new Error('No subscription found for user');
} }
// Resolve admin_override_ placeholder to real Stripe customer if needed // Ensure Stripe customer exists (creates one for admin-set subscriptions)
const stripeCustomerId = await this.resolveStripeCustomerId(currentSubscription, email); const stripeCustomerId = await this.ensureStripeCustomer(currentSubscription, email);
// Determine price ID from environment variables // Determine price ID from environment variables
const priceId = this.getPriceId(newTier, billingCycle); const priceId = this.getPriceId(newTier, billingCycle);
@@ -292,6 +299,10 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); throw new Error('No subscription found for user');
} }
if (!currentSubscription.stripeCustomerId) {
throw new Error('Cannot cancel subscription without active Stripe billing');
}
if (!currentSubscription.stripeSubscriptionId) { if (!currentSubscription.stripeSubscriptionId) {
throw new Error('No active Stripe subscription to cancel'); throw new Error('No active Stripe subscription to cancel');
} }
@@ -339,6 +350,10 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); throw new Error('No subscription found for user');
} }
if (!currentSubscription.stripeCustomerId) {
throw new Error('Cannot reactivate subscription without active Stripe billing');
}
if (!currentSubscription.stripeSubscriptionId) { if (!currentSubscription.stripeSubscriptionId) {
throw new Error('No active Stripe subscription to reactivate'); throw new Error('No active Stripe subscription to reactivate');
} }
@@ -802,17 +817,8 @@ export class SubscriptionsService {
* Sync subscription tier to user_profiles table * Sync subscription tier to user_profiles table
*/ */
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> { private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
try { await this.userProfileRepository.updateSubscriptionTier(userId, tier);
await this.userProfileRepository.updateSubscriptionTier(userId, tier); logger.info('Subscription tier synced to user profile', { userId, tier });
logger.info('Subscription tier synced to user profile', { userId, tier });
} catch (error: any) {
logger.error('Failed to sync tier to user profile', {
userId,
tier,
error: error.message,
});
// Don't throw - we don't want to fail the subscription operation if sync fails
}
} }
/** /**
@@ -968,7 +974,7 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); throw new Error('No subscription found for user');
} }
const stripeCustomerId = await this.resolveStripeCustomerId(subscription, email); const stripeCustomerId = await this.ensureStripeCustomer(subscription, email);
await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId); await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId);
} }
@@ -978,7 +984,7 @@ export class SubscriptionsService {
async getInvoices(userId: string): Promise<any[]> { async getInvoices(userId: string): Promise<any[]> {
try { try {
const subscription = await this.repository.findByUserId(userId); const subscription = await this.repository.findByUserId(userId);
if (!subscription?.stripeCustomerId || subscription.stripeCustomerId.startsWith('admin_override_')) { if (!subscription?.stripeCustomerId) {
return []; return [];
} }
return this.stripeClient.listInvoices(subscription.stripeCustomerId); return this.stripeClient.listInvoices(subscription.stripeCustomerId);

View File

@@ -19,7 +19,7 @@ export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
export interface Subscription { export interface Subscription {
id: string; id: string;
userId: string; userId: string;
stripeCustomerId: string; stripeCustomerId: string | null;
stripeSubscriptionId?: string; stripeSubscriptionId?: string;
tier: SubscriptionTier; tier: SubscriptionTier;
billingCycle?: BillingCycle; billingCycle?: BillingCycle;
@@ -74,7 +74,7 @@ export interface CreateSubscriptionRequest {
export interface SubscriptionResponse { export interface SubscriptionResponse {
id: string; id: string;
userId: string; userId: string;
stripeCustomerId: string; stripeCustomerId: string | null;
stripeSubscriptionId?: string; stripeSubscriptionId?: string;
tier: SubscriptionTier; tier: SubscriptionTier;
billingCycle?: BillingCycle; billingCycle?: BillingCycle;
@@ -118,7 +118,7 @@ export interface CreateTierVehicleSelectionRequest {
// Service layer types // Service layer types
export interface UpdateSubscriptionData { export interface UpdateSubscriptionData {
stripeCustomerId?: string; stripeCustomerId?: string | null;
stripeSubscriptionId?: string; stripeSubscriptionId?: string;
tier?: SubscriptionTier; tier?: SubscriptionTier;
billingCycle?: BillingCycle; billingCycle?: BillingCycle;

View File

@@ -260,6 +260,24 @@ export class StripeClient {
} }
} }
/**
* Delete a Stripe customer (used for cleanup of orphaned customers)
*/
async deleteCustomer(customerId: string): Promise<void> {
try {
logger.info('Deleting Stripe customer', { customerId });
await this.stripe.customers.del(customerId);
logger.info('Stripe customer deleted', { customerId });
} catch (error: any) {
logger.error('Failed to delete Stripe customer', {
customerId,
error: error.message,
code: error.code,
});
throw error;
}
}
/** /**
* Retrieve a subscription by ID * Retrieve a subscription by ID
*/ */

View File

@@ -0,0 +1,11 @@
-- Migration: Make stripe_customer_id NULLABLE
-- Removes the NOT NULL constraint that forced admin_override_ placeholder values.
-- Admin-set subscriptions (no Stripe billing) use NULL instead of sentinel strings.
-- PostgreSQL UNIQUE constraint allows multiple NULLs (SQL standard).
-- 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 to NULL
UPDATE subscriptions SET stripe_customer_id = NULL
WHERE stripe_customer_id LIKE 'admin_override_%';

View File

@@ -5,7 +5,7 @@ export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid';
export interface Subscription { export interface Subscription {
id: string; id: string;
userId: string; userId: string;
stripeCustomerId: string; stripeCustomerId: string | null;
stripeSubscriptionId?: string; stripeSubscriptionId?: string;
tier: SubscriptionTier; tier: SubscriptionTier;
billingCycle?: BillingCycle; billingCycle?: BillingCycle;