import Stripe from 'stripe'; import { loadBillingConfig, SubscriptionTier } from '../config/billing.config'; import { RedisService } from '@shieldsai/shared-notifications'; import type { Subscription, SubscriptionCreateSchema, SubscriptionUpdateSchema } from '../models/subscription.model'; const config = loadBillingConfig(); const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2023-10-16' }); const redis = RedisService.getInstance(); const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60; export class BillingService { private static instance: BillingService; private constructor() {} static getInstance(): BillingService { if (!BillingService.instance) { BillingService.instance = new BillingService(); } return BillingService.instance; } async createCustomer(email: string, userId: string): Promise { const customer = await stripe.customers.create({ email, metadata: { userId }, }); return customer; } async getCustomer(customerId: string): Promise { try { const customer = await stripe.customers.retrieve(customerId); return customer as Stripe.Customer; } catch { return null; } } async verifyCustomerOwnership( customerId: string, userId: string ): Promise { const customer = await stripe.customers.retrieve(customerId); const customerUserId = (customer as Stripe.Customer).metadata?.userId; if (customerUserId !== userId) { throw new Error( `Customer ${customerId} does not belong to user ${userId}` ); } } async getUserTier(userId: string): Promise { try { const customers = await stripe.customers.list({ limit: 100, expand: ['data.subscriptions'], }); const customer = customers.data.find( (c: Stripe.Customer) => c.metadata?.userId === userId && c.subscriptions?.data.length && c.subscriptions.data.length > 0 ); if (!customer || !customer.subscriptions) { return null; } const activeSubscription = customer.subscriptions.data.find( (sub: Stripe.Subscription) => sub.status === 'active' ); if (!activeSubscription) { return null; } const tier = activeSubscription.metadata?.tier as SubscriptionTier; return tier || null; } catch { return null; } } async createSubscription( userId: string, tier: SubscriptionTier, customerId: string ): Promise<{ subscription: Stripe.Subscription; customer: Stripe.Customer }> { const tierConfig = config.tiers[tier]; const customer = await this.getCustomer(customerId); if (!customer) { throw new Error(`Customer ${customerId} not found`); } await this.verifyCustomerOwnership(customerId, userId); const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: tierConfig.priceId }], metadata: { userId, tier }, }); return { subscription, customer }; } async cancelSubscription( subscriptionId: string, userId: string, cancelAtPeriodEnd: boolean = false ): Promise { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const customerUserId = subscription.metadata?.userId; if (customerUserId !== userId) { throw new Error( `Subscription ${subscriptionId} does not belong to user ${userId}` ); } if (cancelAtPeriodEnd) { return await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, }); } return await stripe.subscriptions.cancel(subscriptionId); } async updateSubscription( subscriptionId: string, userId: string, newTier: SubscriptionTier ): Promise { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const customerUserId = subscription.metadata?.userId; if (customerUserId !== userId) { throw new Error( `Subscription ${subscriptionId} does not belong to user ${userId}` ); } 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: firstItem.id, price: newTierConfig.priceId, }, ], }); return updated; } async createCustomerPortalSession( customerId: string, returnUrl: string ): Promise { return await stripe.billingPortal.sessions.create({ customer: customerId, return_url: returnUrl, }); } async getSubscription(subscriptionId: string): Promise { try { const subscription = await stripe.subscriptions.retrieve(subscriptionId); return subscription; } catch { return null; } } async getUserSubscription(userId: string): Promise { try { const customers = await stripe.customers.list({ limit: 100, expand: ['data.subscriptions'], }); for (const customer of customers.data) { if (customer.metadata?.userId === userId && customer.subscriptions) { const activeSub = customer.subscriptions.data.find( (sub: Stripe.Subscription) => sub.status === 'active' ); if (activeSub) { return activeSub; } } } return null; } catch { return null; } } async getTierLimits(tier: SubscriptionTier) { return config.tiers[tier]; } async checkUsageAgainstLimit( userId: string, tier: SubscriptionTier, currentUsage: number ): Promise<{ withinLimit: boolean; remaining: number; limit: number }> { const tierConfig = config.tiers[tier]; const limit = tierConfig.callMinutesLimit; const remaining = Math.max(0, limit - currentUsage); return { withinLimit: currentUsage <= limit, remaining, limit, }; } async createInvoice( customerId: string, amount: number, description: string, metadata?: Record ): Promise { const invoice = await stripe.invoices.create({ customer: customerId, metadata: metadata, }); await stripe.invoiceItems.create({ invoice: invoice.id, customer: customerId, price_data: { currency: 'usd', unit_amount: amount, product: 'default_product', }, description: description, quantity: 1, }); return await stripe.invoices.retrieve(invoice.id); } async handleWebhook( sig: string, body: Buffer ): Promise { const event = stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret); const wasNew = await redis.setIfNotExists( `stripe:event:${event.id}`, '1', IDEMPOTENCY_TTL_SECONDS ); if (!wasNew) { return null; } return event; } async getInvoiceHistory(customerId: string): Promise> { return await stripe.invoices.list({ customer: customerId, limit: 100, }); } }