/** * @ai-summary Subscription business logic and webhook handling * @ai-context Manages subscription lifecycle, Stripe integration, and tier syncing */ import { Pool } from 'pg'; import { logger } from '../../../core/logging/logger'; import { SubscriptionsRepository } from '../data/subscriptions.repository'; import { StripeClient } from '../external/stripe/stripe.client'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { NotificationsService } from '../../notifications/domain/notifications.service'; import { Subscription, SubscriptionResponse, SubscriptionTier, BillingCycle, SubscriptionStatus, UpdateSubscriptionData, NeedsVehicleSelectionResponse, } from './subscriptions.types'; import { VehiclesRepository } from '../../vehicles/data/vehicles.repository'; interface StripeWebhookEvent { id: string; type: string; data: { object: any; }; } export class SubscriptionsService { private userProfileRepository: UserProfileRepository; private vehiclesRepository: VehiclesRepository; private notificationsService: NotificationsService; constructor( private repository: SubscriptionsRepository, private stripeClient: StripeClient, pool: Pool ) { this.userProfileRepository = new UserProfileRepository(pool); this.vehiclesRepository = new VehiclesRepository(pool); this.notificationsService = new NotificationsService(); } /** * Get current subscription for user */ async getSubscription(userId: string): Promise { try { const subscription = await this.repository.findByUserId(userId); if (!subscription) { return null; } return this.mapToResponse(subscription); } catch (error: any) { logger.error('Failed to get subscription', { userId, error: error.message, }); throw error; } } /** * Check if user needs to make a vehicle selection after auto-downgrade * Returns true if: user is on free tier, has >2 vehicles, and hasn't made selections */ async checkNeedsVehicleSelection(userId: string): Promise { const FREE_TIER_VEHICLE_LIMIT = 2; try { // Get current subscription const subscription = await this.repository.findByUserId(userId); // No subscription or not on free tier = no selection needed if (!subscription || subscription.tier !== 'free') { return { needsSelection: false, vehicleCount: 0, maxAllowed: FREE_TIER_VEHICLE_LIMIT, }; } // Count user's active vehicles const vehicleCount = await this.vehiclesRepository.countByUserId(userId); // If within limit, no selection needed if (vehicleCount <= FREE_TIER_VEHICLE_LIMIT) { return { needsSelection: false, vehicleCount, maxAllowed: FREE_TIER_VEHICLE_LIMIT, }; } // Check if user already has vehicle selections const existingSelections = await this.repository.findVehicleSelectionsByUserId(userId); // If user already made selections, no selection needed if (existingSelections.length > 0) { return { needsSelection: false, vehicleCount, maxAllowed: FREE_TIER_VEHICLE_LIMIT, }; } // User is on free tier, has >2 vehicles, and hasn't made selections return { needsSelection: true, vehicleCount, maxAllowed: FREE_TIER_VEHICLE_LIMIT, }; } catch (error: any) { logger.error('Failed to check needs vehicle selection', { userId, error: error.message, }); throw error; } } /** * Create new subscription (Stripe customer + initial free tier record) */ async createSubscription(userId: string, email: string): Promise { try { logger.info('Creating subscription', { userId, email }); // Check if user already has a subscription const existing = await this.repository.findByUserId(userId); if (existing) { logger.warn('User already has a subscription', { userId, subscriptionId: existing.id }); return existing; } // Create Stripe customer const stripeCustomer = await this.stripeClient.createCustomer(email); // Create subscription record with free tier const subscription = await this.repository.create({ userId, stripeCustomerId: stripeCustomer.id, tier: 'free', billingCycle: 'monthly', }); logger.info('Subscription created', { subscriptionId: subscription.id, userId, stripeCustomerId: stripeCustomer.id, }); return subscription; } catch (error: any) { logger.error('Failed to create subscription', { userId, email, error: error.message, }); throw error; } } /** * Create or return existing Stripe customer for a subscription. * Admin-set subscriptions have NULL stripeCustomerId. On first Stripe payment, * the customer is created in-place. Includes cleanup of orphaned Stripe customer * if the DB update fails after customer creation. */ private async ensureStripeCustomer( subscription: Subscription, email: string ): Promise { 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; } } /** * Upgrade from current tier to new tier */ async upgradeSubscription( userId: string, newTier: 'pro' | 'enterprise', billingCycle: 'monthly' | 'yearly', paymentMethodId: string, email: string ): Promise { try { logger.info('Upgrading subscription', { userId, newTier, billingCycle }); // Get current subscription const currentSubscription = await this.repository.findByUserId(userId); if (!currentSubscription) { throw new Error('No subscription found for user'); } // Ensure Stripe customer exists (creates one for admin-set subscriptions) const stripeCustomerId = await this.ensureStripeCustomer(currentSubscription, email); // Determine price ID from environment variables const priceId = this.getPriceId(newTier, billingCycle); // Create or update Stripe subscription const stripeSubscription = await this.stripeClient.createSubscription( stripeCustomerId, priceId, paymentMethodId ); // Update subscription record const updateData: UpdateSubscriptionData = { stripeSubscriptionId: stripeSubscription.id, tier: newTier, billingCycle, status: this.mapStripeStatus(stripeSubscription.status), currentPeriodStart: new Date(stripeSubscription.currentPeriodStart * 1000), currentPeriodEnd: new Date(stripeSubscription.currentPeriodEnd * 1000), cancelAtPeriodEnd: false, }; const updatedSubscription = await this.repository.update( currentSubscription.id, updateData ); if (!updatedSubscription) { throw new Error('Failed to update subscription'); } // Sync tier to user profile await this.syncTierToUserProfile(userId, newTier); // Send tier change notification await this.sendTierChangeNotificationSafe( userId, currentSubscription.tier, newTier, 'user_upgrade' ); logger.info('Subscription upgraded', { subscriptionId: updatedSubscription.id, userId, newTier, billingCycle, }); return updatedSubscription; } catch (error: any) { logger.error('Failed to upgrade subscription', { userId, newTier, billingCycle, error: error.message, }); throw error; } } /** * Cancel subscription (schedules for end of period) */ async cancelSubscription(userId: string): Promise { try { logger.info('Canceling subscription', { userId }); // Get current subscription const currentSubscription = await this.repository.findByUserId(userId); if (!currentSubscription) { throw new Error('No subscription found for user'); } if (!currentSubscription.stripeCustomerId) { throw new Error('Cannot cancel subscription without active Stripe billing'); } if (!currentSubscription.stripeSubscriptionId) { throw new Error('No active Stripe subscription to cancel'); } // Cancel at period end in Stripe await this.stripeClient.cancelSubscription( currentSubscription.stripeSubscriptionId, true ); // Update subscription record const updatedSubscription = await this.repository.update(currentSubscription.id, { cancelAtPeriodEnd: true, }); if (!updatedSubscription) { throw new Error('Failed to update subscription'); } logger.info('Subscription canceled', { subscriptionId: updatedSubscription.id, userId, }); return updatedSubscription; } catch (error: any) { logger.error('Failed to cancel subscription', { userId, error: error.message, }); throw error; } } /** * Reactivate a pending cancellation */ async reactivateSubscription(userId: string): Promise { try { logger.info('Reactivating subscription', { userId }); // Get current subscription const currentSubscription = await this.repository.findByUserId(userId); if (!currentSubscription) { throw new Error('No subscription found for user'); } if (!currentSubscription.stripeCustomerId) { throw new Error('Cannot reactivate subscription without active Stripe billing'); } if (!currentSubscription.stripeSubscriptionId) { throw new Error('No active Stripe subscription to reactivate'); } if (!currentSubscription.cancelAtPeriodEnd) { logger.warn('Subscription is not pending cancellation', { subscriptionId: currentSubscription.id, userId, }); return currentSubscription; } // Reactivate in Stripe (remove cancel_at_period_end flag) await this.stripeClient.cancelSubscription( currentSubscription.stripeSubscriptionId, false ); // Update subscription record const updatedSubscription = await this.repository.update(currentSubscription.id, { cancelAtPeriodEnd: false, }); if (!updatedSubscription) { throw new Error('Failed to update subscription'); } logger.info('Subscription reactivated', { subscriptionId: updatedSubscription.id, userId, }); return updatedSubscription; } catch (error: any) { logger.error('Failed to reactivate subscription', { userId, error: error.message, }); throw error; } } /** * Downgrade subscription to a lower tier with vehicle selection */ async downgradeSubscription( userId: string, targetTier: SubscriptionTier, vehicleIdsToKeep: string[] ): Promise { try { logger.info('Downgrading subscription', { userId, targetTier, vehicleCount: vehicleIdsToKeep.length }); // Get current subscription const currentSubscription = await this.repository.findByUserId(userId); if (!currentSubscription) { throw new Error('No subscription found for user'); } // Define tier limits const tierLimits: Record = { free: 2, pro: 5, enterprise: null, // unlimited }; const targetLimit = tierLimits[targetTier]; // Validate vehicle selection count if (targetLimit !== null && vehicleIdsToKeep.length > targetLimit) { throw new Error(`Vehicle selection exceeds tier limit. ${targetTier} tier allows ${targetLimit} vehicles, but ${vehicleIdsToKeep.length} were selected.`); } // Cancel current Stripe subscription if exists (downgrading from paid tier) if (currentSubscription.stripeSubscriptionId) { await this.stripeClient.cancelSubscription( currentSubscription.stripeSubscriptionId, false // Cancel immediately, not at period end ); } // Clear previous vehicle selections await this.repository.deleteVehicleSelectionsByUserId(userId); // Save new vehicle selections for (const vehicleId of vehicleIdsToKeep) { await this.repository.createVehicleSelection({ userId, vehicleId, }); } // Update subscription tier const updateData: UpdateSubscriptionData = { tier: targetTier, status: 'active', stripeSubscriptionId: undefined, billingCycle: undefined, cancelAtPeriodEnd: false, }; const updatedSubscription = await this.repository.update( currentSubscription.id, updateData ); if (!updatedSubscription) { throw new Error('Failed to update subscription'); } // Sync tier to user profile await this.syncTierToUserProfile(userId, targetTier); // Send tier change notification await this.sendTierChangeNotificationSafe( userId, currentSubscription.tier, targetTier, 'user_downgrade' ); logger.info('Subscription downgraded', { subscriptionId: updatedSubscription.id, userId, targetTier, vehicleCount: vehicleIdsToKeep.length, }); return updatedSubscription; } catch (error: any) { logger.error('Failed to downgrade subscription', { userId, targetTier, error: error.message, }); throw error; } } /** * Handle incoming Stripe webhook event */ async handleWebhookEvent(payload: Buffer, signature: string): Promise { try { // Construct and verify webhook event const event = this.stripeClient.constructWebhookEvent( payload, signature ) as StripeWebhookEvent; logger.info('Processing webhook event', { eventId: event.id, eventType: event.type, }); // Check idempotency - skip if we've already processed this event const existingEvent = await this.repository.findEventByStripeId(event.id); if (existingEvent) { logger.info('Event already processed, skipping', { eventId: event.id }); return; } // Process based on event type switch (event.type) { case 'customer.subscription.created': await this.handleSubscriptionCreated(event); break; case 'customer.subscription.updated': await this.handleSubscriptionUpdated(event); break; case 'customer.subscription.deleted': await this.handleSubscriptionDeleted(event); break; case 'invoice.payment_succeeded': await this.handlePaymentSucceeded(event); break; case 'invoice.payment_failed': await this.handlePaymentFailed(event); break; case 'payment_intent.succeeded': await this.handleDonationPaymentSucceeded(event); break; default: logger.info('Unhandled webhook event type', { eventType: event.type }); } logger.info('Webhook event processed', { eventId: event.id, eventType: event.type }); } catch (error: any) { logger.error('Failed to handle webhook event', { error: error.message, }); throw error; } } // ========== Private Helper Methods ========== /** * Handle customer.subscription.created webhook */ private async handleSubscriptionCreated(event: StripeWebhookEvent): Promise { const stripeSubscription = event.data.object; // Find subscription by Stripe customer ID const subscription = await this.repository.findByStripeCustomerId( stripeSubscription.customer ); if (!subscription) { logger.warn('Subscription not found for Stripe customer', { stripeCustomerId: stripeSubscription.customer, }); return; } // Update subscription with Stripe subscription ID // Period dates moved from subscription to items in API 2025-03-31.basil const item = stripeSubscription.items?.data?.[0]; await this.repository.update(subscription.id, { stripeSubscriptionId: stripeSubscription.id, status: this.mapStripeStatus(stripeSubscription.status), currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), }); // Log event await this.repository.createEvent({ subscriptionId: subscription.id, stripeEventId: event.id, eventType: event.type, payload: event.data.object, }); } /** * Handle customer.subscription.updated webhook */ private async handleSubscriptionUpdated(event: StripeWebhookEvent): Promise { const stripeSubscription = event.data.object; // Find subscription by Stripe subscription ID const subscription = await this.repository.findByStripeSubscriptionId( stripeSubscription.id ); if (!subscription) { logger.warn('Subscription not found for Stripe subscription', { stripeSubscriptionId: stripeSubscription.id, }); return; } // Determine tier from price metadata or plan const tier = this.determineTierFromStripeSubscription(stripeSubscription); // Update subscription // Period dates moved from subscription to items in API 2025-03-31.basil const item = stripeSubscription.items?.data?.[0]; const updateData: UpdateSubscriptionData = { status: this.mapStripeStatus(stripeSubscription.status), tier, currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false, }; await this.repository.update(subscription.id, updateData); // Sync tier to user profile await this.syncTierToUserProfile(subscription.userId, tier); // Log event await this.repository.createEvent({ subscriptionId: subscription.id, stripeEventId: event.id, eventType: event.type, payload: event.data.object, }); } /** * Handle customer.subscription.deleted webhook */ private async handleSubscriptionDeleted(event: StripeWebhookEvent): Promise { const stripeSubscription = event.data.object; // Find subscription by Stripe subscription ID const subscription = await this.repository.findByStripeSubscriptionId( stripeSubscription.id ); if (!subscription) { logger.warn('Subscription not found for Stripe subscription', { stripeSubscriptionId: stripeSubscription.id, }); return; } // Update subscription to canceled await this.repository.update(subscription.id, { status: 'canceled', }); // Downgrade tier to free await this.syncTierToUserProfile(subscription.userId, 'free'); // Log event await this.repository.createEvent({ subscriptionId: subscription.id, stripeEventId: event.id, eventType: event.type, payload: event.data.object, }); } /** * Handle invoice.payment_succeeded webhook */ private async handlePaymentSucceeded(event: StripeWebhookEvent): Promise { const invoice = event.data.object; // Find subscription by Stripe subscription ID const subscription = await this.repository.findByStripeSubscriptionId( invoice.subscription ); if (!subscription) { logger.warn('Subscription not found for Stripe subscription', { stripeSubscriptionId: invoice.subscription, }); return; } // Clear grace period and mark as active await this.repository.update(subscription.id, { status: 'active', gracePeriodEnd: undefined, }); // Log event await this.repository.createEvent({ subscriptionId: subscription.id, stripeEventId: event.id, eventType: event.type, payload: event.data.object, }); } /** * Handle invoice.payment_failed webhook */ private async handlePaymentFailed(event: StripeWebhookEvent): Promise { const invoice = event.data.object; // Find subscription by Stripe subscription ID const subscription = await this.repository.findByStripeSubscriptionId( invoice.subscription ); if (!subscription) { logger.warn('Subscription not found for Stripe subscription', { stripeSubscriptionId: invoice.subscription, }); return; } // Set grace period (30 days from now) const gracePeriodEnd = new Date(); gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 30); await this.repository.update(subscription.id, { status: 'past_due', gracePeriodEnd, }); // Log event await this.repository.createEvent({ subscriptionId: subscription.id, stripeEventId: event.id, eventType: event.type, payload: event.data.object, }); } /** * Handle payment_intent.succeeded webhook for donations */ private async handleDonationPaymentSucceeded(event: StripeWebhookEvent): Promise { const paymentIntent = event.data.object; // Check if this is a donation (based on metadata) if (paymentIntent.metadata?.type !== 'donation') { logger.info('PaymentIntent is not a donation, skipping', { paymentIntentId: paymentIntent.id, }); return; } // Find donation by payment intent ID const donation = await this.repository.findDonationByPaymentIntentId( paymentIntent.id ); if (!donation) { logger.warn('Donation not found for payment intent', { paymentIntentId: paymentIntent.id, }); return; } // Update donation status to succeeded await this.repository.updateDonation(donation.id, { status: 'succeeded', }); logger.info('Donation marked as succeeded via webhook', { donationId: donation.id, paymentIntentId: paymentIntent.id, }); } /** * Send tier change notification safely (non-blocking, logs errors) */ private async sendTierChangeNotificationSafe( userId: string, previousTier: SubscriptionTier, newTier: SubscriptionTier, reason: 'admin_override' | 'user_upgrade' | 'user_downgrade' | 'grace_period_expiration' ): Promise { try { // Get user profile for email and name const userProfile = await this.userProfileRepository.getById(userId); if (!userProfile) { logger.warn('User profile not found for tier change notification', { userId }); return; } const userEmail = userProfile.notificationEmail || userProfile.email; const userName = userProfile.displayName || userProfile.email.split('@')[0]; await this.notificationsService.sendTierChangeNotification( userId, userEmail, userName, previousTier, newTier, reason ); logger.info('Tier change notification sent', { userId, previousTier, newTier, reason }); } catch (error: any) { // Log but don't throw - notifications are non-critical logger.error('Failed to send tier change notification', { userId, previousTier, newTier, reason, error: error.message, }); } } /** * Sync subscription tier to user_profiles table */ private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise { await this.userProfileRepository.updateSubscriptionTier(userId, tier); logger.info('Subscription tier synced to user profile', { userId, tier }); } /** * Get Stripe price ID from environment variables */ private getPriceId(tier: 'pro' | 'enterprise', billingCycle: BillingCycle): string { const envVarMap: Record = { 'pro-monthly': 'STRIPE_PRO_MONTHLY_PRICE_ID', 'pro-yearly': 'STRIPE_PRO_YEARLY_PRICE_ID', 'enterprise-monthly': 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID', 'enterprise-yearly': 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID', }; const envVar = envVarMap[`${tier}-${billingCycle}`]; const priceId = process.env[envVar]; if (!priceId) { throw new Error(`Missing environment variable: ${envVar}`); } return priceId; } /** * Map Stripe subscription status to our status type */ private mapStripeStatus(stripeStatus: string): SubscriptionStatus { switch (stripeStatus) { case 'active': case 'trialing': case 'incomplete': return 'active'; case 'past_due': return 'past_due'; case 'canceled': case 'incomplete_expired': return 'canceled'; case 'unpaid': return 'unpaid'; default: logger.warn('Unknown Stripe status, defaulting to canceled', { stripeStatus }); return 'canceled'; } } /** * Determine tier from Stripe subscription object */ private determineTierFromStripeSubscription(stripeSubscription: any): SubscriptionTier { // Try to extract tier from price metadata or plan const priceId = stripeSubscription.items?.data?.[0]?.price?.id; if (!priceId) { logger.warn('Could not determine tier from Stripe subscription, defaulting to free'); return 'free'; } // Check environment variables to match price ID to tier if ( priceId === process.env.STRIPE_PRO_MONTHLY_PRICE_ID || priceId === process.env.STRIPE_PRO_YEARLY_PRICE_ID ) { return 'pro'; } if ( priceId === process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || priceId === process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID ) { return 'enterprise'; } logger.warn('Unknown price ID, defaulting to free', { priceId }); return 'free'; } /** * Admin override of subscription tier (bypasses Stripe) * Updates both subscriptions.tier and user_profiles.subscription_tier atomically * Creates subscription record if user doesn't have one * * Note: Admin feature depends on Subscriptions for tier management * This is intentional - admin has oversight capabilities */ async adminOverrideTier(userId: string, newTier: SubscriptionTier): Promise { const pool = this.repository.getPool(); const client = await pool.connect(); try { await client.query('BEGIN'); logger.info('Admin overriding subscription tier', { userId, newTier }); // Check if user has a subscription record const existingSubscription = await this.repository.findByUserId(userId); const previousTier: SubscriptionTier = existingSubscription?.tier || 'free'; let subscription: Subscription; if (!existingSubscription) { // Create subscription record for user (admin override bypasses Stripe) logger.info('Creating subscription record for admin override', { userId, newTier }); subscription = await this.repository.createForAdminOverride(userId, newTier, client); } else { // Update existing subscription tier const updated = await this.repository.updateTierByUserId(userId, newTier, client); if (!updated) { throw new Error('Failed to update subscription tier'); } subscription = updated; } // Sync tier to user_profiles table (within same transaction) await client.query( 'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2', [newTier, userId] ); await client.query('COMMIT'); // Send tier change notification (after transaction committed) await this.sendTierChangeNotificationSafe( userId, previousTier, newTier, 'admin_override' ); logger.info('Admin subscription tier override complete', { userId, newTier, subscriptionId: subscription.id, }); return subscription; } catch (error: any) { await client.query('ROLLBACK'); logger.error('Failed to admin override subscription tier', { userId, newTier, error: error.message, }); throw error; } finally { client.release(); } } /** * Update payment method for a user's subscription */ async updatePaymentMethod(userId: string, paymentMethodId: string, email: string): Promise { const subscription = await this.repository.findByUserId(userId); if (!subscription) { throw new Error('No subscription found for user'); } const stripeCustomerId = await this.ensureStripeCustomer(subscription, email); await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId); } /** * Get invoices for a user's subscription */ async getInvoices(userId: string): Promise { try { const subscription = await this.repository.findByUserId(userId); if (!subscription?.stripeCustomerId) { return []; } return this.stripeClient.listInvoices(subscription.stripeCustomerId); } catch (error: any) { logger.error('Failed to get invoices', { userId, error: error.message, }); throw error; } } /** * Map subscription entity to response DTO */ private mapToResponse(subscription: Subscription): SubscriptionResponse { return { id: subscription.id, userId: subscription.userId, stripeCustomerId: subscription.stripeCustomerId, stripeSubscriptionId: subscription.stripeSubscriptionId, tier: subscription.tier, billingCycle: subscription.billingCycle, status: subscription.status, currentPeriodStart: subscription.currentPeriodStart?.toISOString(), currentPeriodEnd: subscription.currentPeriodEnd?.toISOString(), gracePeriodEnd: subscription.gracePeriodEnd?.toISOString(), cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, createdAt: subscription.createdAt.toISOString(), updatedAt: subscription.updatedAt.toISOString(), }; } }