diff --git a/backend/src/features/subscriptions/README.md b/backend/src/features/subscriptions/README.md index 5a79612..2a09f61 100644 --- a/backend/src/features/subscriptions/README.md +++ b/backend/src/features/subscriptions/README.md @@ -54,13 +54,39 @@ Defined in `user_profiles.subscription_tier` enum: - `canceled` - Subscription canceled - `unpaid` - Payment failed, grace period expired +## Milestone 2: Service Layer + Webhook Endpoint (COMPLETE) + +### Service Layer +- **SubscriptionsService** (`domain/subscriptions.service.ts`): Business logic for subscription management + - Get current subscription for user + - Create new subscription (Stripe customer + free tier record) + - Upgrade subscription (create Stripe subscription with payment method) + - Cancel subscription (schedule for end of period) + - Reactivate subscription (remove pending cancellation) + - Handle Stripe webhook events with idempotency + +### Webhook Processing +- **Webhook Events Handled**: + - `customer.subscription.created` - Update subscription record with Stripe subscription ID + - `customer.subscription.updated` - Update status, tier, period dates + - `customer.subscription.deleted` - Mark as canceled, downgrade to free tier + - `invoice.payment_succeeded` - Clear grace period, mark active + - `invoice.payment_failed` - Set 30-day grace period + +### API Endpoints +- **WebhooksController** (`api/webhooks.controller.ts`): Webhook event handler +- **Routes** (`api/webhooks.routes.ts`): PUBLIC endpoint with rawBody support + - POST /api/webhooks/stripe - Stripe webhook receiver (no JWT auth, signature verified) + +### Integration +- Syncs subscription tier changes to `user_profiles.subscription_tier` via UserProfileRepository +- Uses environment variables for Stripe price IDs (PRO/ENTERPRISE, MONTHLY/YEARLY) + ## Next Steps (Future Milestones) -- M2: Subscription service layer with business logic -- M3: API endpoints for subscription management -- M4: Webhook handlers for Stripe events -- M5: Frontend integration and subscription UI -- M6: Testing and documentation +- M3: API endpoints for subscription management (user-facing CRUD) +- M4: Frontend integration and subscription UI +- M5: Testing and documentation ## Database Migration diff --git a/backend/src/features/subscriptions/api/webhooks.controller.ts b/backend/src/features/subscriptions/api/webhooks.controller.ts new file mode 100644 index 0000000..edd15ba --- /dev/null +++ b/backend/src/features/subscriptions/api/webhooks.controller.ts @@ -0,0 +1,62 @@ +/** + * @ai-summary Webhook controller for Stripe events + * @ai-context Handles incoming Stripe webhook events with signature verification + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { logger } from '../../../core/logging/logger'; +import { SubscriptionsService } from '../domain/subscriptions.service'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { pool } from '../../../core/config/database'; + +export class WebhooksController { + private service: SubscriptionsService; + + constructor() { + const repository = new SubscriptionsRepository(pool); + const stripeClient = new StripeClient(); + this.service = new SubscriptionsService(repository, stripeClient, pool); + } + + /** + * Handle Stripe webhook events + * POST /api/webhooks/stripe + */ + async handleStripeWebhook(request: FastifyRequest, reply: FastifyReply): Promise { + try { + // Get raw body from request (must be enabled via config: { rawBody: true }) + const rawBody = (request as any).rawBody; + if (!rawBody) { + logger.error('Missing raw body in webhook request'); + return reply.status(400).send({ error: 'Missing raw body' }); + } + + // Get Stripe signature from headers + const signature = request.headers['stripe-signature']; + if (!signature || typeof signature !== 'string') { + logger.error('Missing or invalid Stripe signature'); + return reply.status(400).send({ error: 'Missing Stripe signature' }); + } + + // Process the webhook event + await this.service.handleWebhookEvent(rawBody, signature); + + // Return 200 to acknowledge receipt + return reply.status(200).send({ received: true }); + } catch (error: any) { + logger.error('Webhook handler error', { + error: error.message, + stack: error.stack, + }); + + // Return 400 for signature verification failures + if (error.message.includes('signature') || error.message.includes('verify')) { + return reply.status(400).send({ error: 'Invalid signature' }); + } + + // Return 500 for other errors + return reply.status(500).send({ error: 'Webhook processing failed' }); + } + } +} diff --git a/backend/src/features/subscriptions/api/webhooks.routes.ts b/backend/src/features/subscriptions/api/webhooks.routes.ts new file mode 100644 index 0000000..4a24c1b --- /dev/null +++ b/backend/src/features/subscriptions/api/webhooks.routes.ts @@ -0,0 +1,24 @@ +/** + * @ai-summary Webhook routes for Stripe events + * @ai-context PUBLIC endpoint - no JWT auth, authenticated via Stripe signature + */ + +import { FastifyPluginAsync } from 'fastify'; +import { WebhooksController } from './webhooks.controller'; + +export const webhooksRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new WebhooksController(); + + // POST /api/webhooks/stripe - PUBLIC endpoint (no JWT auth) + // Stripe authenticates via webhook signature verification + // IMPORTANT: rawBody MUST be enabled for signature verification to work + fastify.post( + '/webhooks/stripe', + { + config: { + rawBody: true, // Enable raw body for Stripe signature verification + }, + }, + controller.handleStripeWebhook.bind(controller) + ); +}; diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts new file mode 100644 index 0000000..2729e86 --- /dev/null +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -0,0 +1,622 @@ +/** + * @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 { + Subscription, + SubscriptionResponse, + SubscriptionTier, + BillingCycle, + SubscriptionStatus, + UpdateSubscriptionData, +} from './subscriptions.types'; + +interface StripeWebhookEvent { + id: string; + type: string; + data: { + object: any; + }; +} + +export class SubscriptionsService { + private userProfileRepository: UserProfileRepository; + + constructor( + private repository: SubscriptionsRepository, + private stripeClient: StripeClient, + pool: Pool + ) { + this.userProfileRepository = new UserProfileRepository(pool); + } + + /** + * 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; + } + } + + /** + * 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; + } + } + + /** + * Upgrade from current tier to new tier + */ + async upgradeSubscription( + userId: string, + newTier: 'pro' | 'enterprise', + billingCycle: 'monthly' | 'yearly', + paymentMethodId: 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'); + } + + // Determine price ID from environment variables + const priceId = this.getPriceId(newTier, billingCycle); + + // Create or update Stripe subscription + const stripeSubscription = await this.stripeClient.createSubscription( + currentSubscription.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); + + 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.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.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; + } + } + + /** + * 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; + 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 + await this.repository.update(subscription.id, { + stripeSubscriptionId: stripeSubscription.id, + status: this.mapStripeStatus(stripeSubscription.status), + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 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 + const updateData: UpdateSubscriptionData = { + status: this.mapStripeStatus(stripeSubscription.status), + tier, + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 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, + }); + } + + /** + * Sync subscription tier to user_profiles table + */ + private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise { + try { + await this.userProfileRepository.updateSubscriptionTier(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 + } + } + + /** + * 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': + 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'; + } + + /** + * 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(), + }; + } +} diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts index fd40cf6..92a24b8 100644 --- a/backend/src/features/subscriptions/index.ts +++ b/backend/src/features/subscriptions/index.ts @@ -39,3 +39,9 @@ export { StripeClient } from './external/stripe/stripe.client'; // Repository export { SubscriptionsRepository } from './data/subscriptions.repository'; + +// Service +export { SubscriptionsService } from './domain/subscriptions.service'; + +// Routes +export { webhooksRoutes } from './api/webhooks.routes';