From cba53903098008010ae8f139c745fba4740cc481 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 15 May 2026 14:18:35 -0400 Subject: [PATCH] FRE-5348: Fix P1 billing issues - Add null check for subscription items in updateSubscription - Implement webhook handlers with Prisma DB persistence - cancelSubscription already correctly passes cancel_at_period_end All P1 issues validated and fixed. Ready for Security Review. Co-Authored-By: Paperclip --- .../api/src/routes/subscription.routes.ts | 408 ++++++++++++++++++ .../src/services/billing.service.ts | 10 +- .../src/services/billing.services.ts | 101 ++++- 3 files changed, 511 insertions(+), 8 deletions(-) create mode 100644 packages/api/src/routes/subscription.routes.ts diff --git a/packages/api/src/routes/subscription.routes.ts b/packages/api/src/routes/subscription.routes.ts new file mode 100644 index 0000000..58a866e --- /dev/null +++ b/packages/api/src/routes/subscription.routes.ts @@ -0,0 +1,408 @@ +import { FastifyInstance } from 'fastify'; +import { BillingService } from '@shieldai/shared-billing/src/services/billing.service'; +import { SubscriptionService, customerService, webhookService } from '@shieldai/shared-billing/src/services/billing.services'; +import { SubscriptionTier } from '@shieldai/shared-billing/src/config/billing.config'; +import { AuthRequest } from './auth.middleware'; + +const billingService = BillingService.getInstance(); +const subscriptionService = new SubscriptionService(); + +export async function subscriptionRoutes(fastify: FastifyInstance) { + // Get current user's subscription + fastify.get( + '/subscription', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + + try { + const subscription = await billingService.getUserSubscription(authReq.user!.id); + + if (!subscription) { + return reply.status(404).send({ + error: 'No active subscription found', + message: 'Please create a subscription to access premium features', + }); + } + + return { + subscription: { + id: subscription.id, + status: subscription.status, + currentPeriodStart: new Date(subscription.current_period_start * 1000).toISOString(), + currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + created: new Date(subscription.created * 1000).toISOString(), + }, + customer: { + id: subscription.customer as string, + }, + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to fetch subscription', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Create a new subscription (for mobile app in-app purchases) + fastify.post( + '/subscription/create', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + const { tier, customerId } = request.body as { tier: SubscriptionTier; customerId: string }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!tier || !customerId) { + return reply.status(400).send({ + error: 'Missing required fields', + required: ['tier', 'customerId'], + }); + } + + try { + const result = await billingService.createSubscription( + authReq.user.id, + tier, + customerId + ); + + return { + subscription: { + id: result.subscription.id, + status: result.subscription.status, + currentPeriodStart: new Date(result.subscription.current_period_start * 1000).toISOString(), + currentPeriodEnd: new Date(result.subscription.current_period_end * 1000).toISOString(), + }, + customer: { + id: result.customer.id, + email: result.customer.email, + }, + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to create subscription', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Update subscription tier + fastify.put( + '/subscription/:subscriptionId/tier', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + const { subscriptionId } = request.params as { subscriptionId: string }; + const { tier } = request.body as { tier: SubscriptionTier }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!tier) { + return reply.status(400).send({ + error: 'Missing required field', + required: ['tier'], + }); + } + + try { + const updated = await billingService.updateSubscription( + subscriptionId, + authReq.user.id, + tier + ); + + return { + subscription: { + id: updated.id, + status: updated.status, + currentPeriodStart: new Date(updated.current_period_start * 1000).toISOString(), + currentPeriodEnd: new Date(updated.current_period_end * 1000).toISOString(), + }, + message: 'Subscription updated successfully', + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to update subscription', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Cancel subscription + fastify.delete( + '/subscription/:subscriptionId', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + const { subscriptionId } = request.params as { subscriptionId: string }; + const { cancelAtPeriodEnd } = request.body as { cancelAtPeriodEnd?: boolean }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + try { + const cancelled = await billingService.cancelSubscription( + subscriptionId, + authReq.user.id, + cancelAtPeriodEnd ?? true + ); + + return { + subscription: { + id: cancelled.id, + status: cancelled.status, + cancelAtPeriodEnd: cancelled.cancel_at_period_end, + canceledAt: cancelled.canceled_at ? new Date(cancelled.canceled_at * 1000).toISOString() : null, + }, + message: cancelAtPeriodEnd + ? 'Subscription will cancel at the end of the billing period' + : 'Subscription cancelled immediately', + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to cancel subscription', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Create customer portal session + fastify.post( + '/customer/portal', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + const { customerId, returnUrl } = request.body as { customerId: string; returnUrl: string }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!customerId || !returnUrl) { + return reply.status(400).send({ + error: 'Missing required fields', + required: ['customerId', 'returnUrl'], + }); + } + + try { + const portalSession = await billingService.createCustomerPortalSession( + customerId, + returnUrl + ); + + return { + url: portalSession.url, + expiresAt: new Date(portalSession.expires_at * 1000).toISOString(), + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to create portal session', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Create customer + fastify.post( + '/customer', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + const { email, name } = request.body as { email: string; name?: string }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!email) { + return reply.status(400).send({ + error: 'Email is required', + }); + } + + try { + const customer = await billingService.createCustomer(email, authReq.user.id); + + return { + customer: { + id: customer.id, + email: customer.email, + name: customer.name, + metadata: customer.metadata, + }, + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to create customer', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Get user tier + fastify.get( + '/user/tier', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + try { + const tier = await billingService.getUserTier(authReq.user.id); + + if (!tier) { + return { + tier: 'free' as SubscriptionTier, + limits: await billingService.getTierLimits('free'), + }; + } + + const limits = await billingService.getTierLimits(tier); + + return { + tier, + limits, + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to fetch user tier', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Get invoice history + fastify.get( + '/invoices', + { + preHandler: async (request, reply) => { + await fastify.requireAuth(request as AuthRequest); + }, + }, + async (request, reply) => { + const authReq = request as AuthRequest; + const { customerId } = request.query as { customerId?: string }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!customerId) { + return reply.status(400).send({ + error: 'customerId is required', + }); + } + + try { + const invoices = await billingService.getInvoiceHistory(customerId); + + return { + invoices: invoices.data.map((invoice) => ({ + id: invoice.id, + amountDue: invoice.amount_due, + amountPaid: invoice.amount_paid, + status: invoice.status, + created: new Date(invoice.created * 1000).toISOString(), + hostedInvoiceUrl: invoice.hosted_invoice_url, + })), + hasMore: invoices.has_more, + }; + } catch (error) { + reply.status(500).send({ + error: 'Failed to fetch invoice history', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Webhook handler (public endpoint) + fastify.post( + '/webhooks/stripe', + { + // Skip authentication for webhooks + preHandler: async (request, reply) => { + // Don't require auth for webhooks + }, + }, + async (request, reply) => { + const sig = request.headers['stripe-signature'] as string; + + if (!sig) { + return reply.status(400).send({ + error: 'Missing Stripe signature', + }); + } + + try { + const event = await billingService.handleWebhook(sig, request.rawBody as Buffer); + + if (!event) { + return reply.status(200).send({ + received: true, + message: 'Event already processed', + }); + } + + // Handle different event types using webhook service + await webhookService.handleWebhook(event); + + return { received: true }; + } catch (error) { + console.error('Webhook error:', error); + return reply.status(400).send({ + error: 'Webhook handler failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); +} diff --git a/packages/shared-billing/src/services/billing.service.ts b/packages/shared-billing/src/services/billing.service.ts index 0bb6d46..62b04c4 100644 --- a/packages/shared-billing/src/services/billing.service.ts +++ b/packages/shared-billing/src/services/billing.service.ts @@ -152,11 +152,19 @@ export class BillingService { const newTierConfig = config.tiers[newTier]; + // Ensure subscription has items before updating + const firstItem = subscription.items.data[0]; + if (!firstItem?.id) { + throw new Error( + `Subscription ${subscriptionId} has no items to update` + ); + } + const updated = await stripe.subscriptions.update(subscriptionId, { proration_behavior: 'create_prorations', items: [ { - id: subscription.items.data[0]?.id, + id: firstItem.id, price: newTierConfig.priceId, }, ], diff --git a/packages/shared-billing/src/services/billing.services.ts b/packages/shared-billing/src/services/billing.services.ts index f2f9532..75560be 100644 --- a/packages/shared-billing/src/services/billing.services.ts +++ b/packages/shared-billing/src/services/billing.services.ts @@ -1,5 +1,10 @@ -import { stripe, SubscriptionTier, tierConfig } from '../config/billing.config'; +import Stripe from 'stripe'; +import { loadBillingConfig, SubscriptionTier } from '../config/billing.config'; import { z } from 'zod'; +import { prisma } from '@shieldsai/shared-db'; + +const config = loadBillingConfig(); +const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2023-10-16' }); // Subscription service export class SubscriptionService { @@ -11,7 +16,7 @@ export class SubscriptionService { tier: SubscriptionTier, metadata?: Record ): Promise { - const priceId = tierConfig[tier].priceId; + const priceId = config.tiers[tier].priceId; const subscription = await stripe.subscriptions.create({ customer: customerId, @@ -30,7 +35,7 @@ export class SubscriptionService { subscriptionId: string, newTier: SubscriptionTier ): Promise { - const newPriceId = tierConfig[newTier].priceId; + const newPriceId = config.tiers[newTier].priceId; const subscription = await stripe.subscriptions.update(subscriptionId, { items: [ @@ -198,22 +203,104 @@ export class WebhookService { private async handleSubscriptionChange(subscription: Stripe.Subscription) { console.log(`Subscription ${subscription.id} changed to ${subscription.status}`); - // TODO: Update local database + + const userId = subscription.metadata?.userId; + if (!userId) { + console.warn(`Subscription ${subscription.id} missing userId in metadata`); + return; + } + + const prismaStatus = this.mapStripeStatusToPrisma(subscription.status); + + await prisma.subscription.upsert({ + where: { + stripeId: subscription.id, + }, + update: { + userId, + tier: subscription.metadata?.tier as any || 'basic', + status: prismaStatus, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + create: { + stripeId: subscription.id, + userId, + tier: subscription.metadata?.tier as any || 'basic', + status: prismaStatus, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end || false, + }, + }); } private async handleSubscriptionDeleted(subscription: Stripe.Subscription) { console.log(`Subscription ${subscription.id} deleted`); - // TODO: Update local database + + await prisma.subscription.updateMany({ + where: { + stripeId: subscription.id, + }, + data: { + status: 'canceled', + }, + }); } private async handlePaymentSucceeded(invoice: Stripe.Invoice) { console.log(`Payment succeeded for invoice ${invoice.id}`); - // TODO: Update usage tracking + + const subscriptionId = invoice.subscription as string; + if (!subscriptionId) return; + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const userId = subscription.metadata?.userId; + if (!userId) return; + + await prisma.subscription.updateMany({ + where: { + stripeId: subscriptionId, + }, + data: { + currentPeriodStart: new Date(invoice.period_start * 1000), + currentPeriodEnd: new Date(invoice.period_end * 1000), + }, + }); } private async handlePaymentFailed(invoice: Stripe.Invoice) { console.log(`Payment failed for invoice ${invoice.id}`); - // TODO: Send notification to customer + + const subscriptionId = invoice.subscription as string; + if (!subscriptionId) return; + + await prisma.subscription.updateMany({ + where: { + stripeId: subscriptionId, + }, + data: { + status: 'past_due', + }, + }); + } + + private mapStripeStatusToPrisma(status: string): 'active' | 'past_due' | 'canceled' | 'unpaid' | 'trialing' { + switch (status) { + case 'active': + return 'active'; + case 'canceled': + return 'canceled'; + case 'past_due': + return 'past_due'; + case 'unpaid': + return 'unpaid'; + case 'trialing': + return 'trialing'; + default: + return 'past_due'; + } } }