import { Injectable, Logger, NotFoundException, BadRequestException, Inject, forwardRef, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { eq, and, desc } from 'drizzle-orm'; import Stripe from 'stripe'; import { getDb } from '../db/connection'; import { plans, subscriptions, invoices, users, stripeCustomers } from '../db/schema'; import { StripeService } from '../stripe/stripe.service'; @Injectable() export class SubscriptionsService { private readonly logger = new Logger(SubscriptionsService.name); private stripe: Stripe | null = null; constructor( private configService: ConfigService, @Inject(forwardRef(() => StripeService)) private stripeService: StripeService ) { const secretKey = this.configService.get('stripe.secretKey'); if (secretKey) { this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' }); } } private getDb() { const databaseUrl = this.configService.get('database.url'); return getDb(databaseUrl!); } // ============================================================================ // PLANS // ============================================================================ /** * Get all active plans */ async getPlans() { const db = this.getDb(); return db.select().from(plans).where(eq(plans.active, true)).orderBy(plans.sortOrder); } /** * Get a specific plan by ID */ async getPlan(planId: string) { const db = this.getDb(); const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1); if (!plan) { throw new NotFoundException('Plan not found'); } return plan; } // ============================================================================ // SUBSCRIPTIONS // ============================================================================ /** * Get user's current subscription */ async getCurrentSubscription(userId: string) { const db = this.getDb(); const [subscription] = await db .select({ subscription: subscriptions, plan: plans, }) .from(subscriptions) .innerJoin(plans, eq(subscriptions.planId, plans.id)) .where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active'))) .limit(1); if (!subscription) { // Return default free plan info const [freePlan] = await db.select().from(plans).where(eq(plans.isDefault, true)).limit(1); return { plan: freePlan || null, subscription: null, isFreePlan: true, }; } return { plan: subscription.plan, subscription: subscription.subscription, isFreePlan: false, }; } /** * Create a Stripe Checkout Session for subscription */ async createCheckoutSession( userId: string, planId: string, billingInterval: 'month' | 'year' = 'month', successUrl: string, cancelUrl: string ) { if (!this.stripe) { throw new BadRequestException('Stripe is not configured'); } const db = this.getDb(); // Get plan const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1); if (!plan) { throw new NotFoundException('Plan not found'); } // Get Stripe price ID const stripePriceId = billingInterval === 'year' ? plan.stripePriceIdYearly : plan.stripePriceIdMonthly; if (!stripePriceId) { throw new BadRequestException(`No Stripe price configured for ${billingInterval} billing`); } // Get user const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) { throw new NotFoundException('User not found'); } // Get or create Stripe customer const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email); // Create checkout session const session = await this.stripe.checkout.sessions.create({ customer: stripeCustomerId, mode: 'subscription', payment_method_types: ['card', 'sepa_debit'], line_items: [ { price: stripePriceId, quantity: 1, }, ], success_url: successUrl, cancel_url: cancelUrl, metadata: { userId, planId, billingInterval, }, subscription_data: { metadata: { userId, planId, }, }, }); this.logger.log('Checkout session created', { sessionId: session.id, userId, planId, }); return { sessionId: session.id, url: session.url, }; } /** * Create a Stripe Customer Portal session for self-service billing */ async createPortalSession(userId: string, returnUrl: string) { if (!this.stripe) { throw new BadRequestException('Stripe is not configured'); } const db = this.getDb(); // Get Stripe customer ID const [customer] = await db .select() .from(stripeCustomers) .where(eq(stripeCustomers.userId, userId)) .limit(1); if (!customer) { throw new BadRequestException('No billing account found. Please subscribe to a plan first.'); } const session = await this.stripe.billingPortal.sessions.create({ customer: customer.stripeCustomerId, return_url: returnUrl, }); this.logger.log('Portal session created', { userId }); return { url: session.url, }; } /** * Cancel subscription (at period end) */ async cancelSubscription(userId: string) { if (!this.stripe) { throw new BadRequestException('Stripe is not configured'); } const db = this.getDb(); const [subscription] = await db .select() .from(subscriptions) .where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active'))) .limit(1); if (!subscription || !subscription.stripeSubscriptionId) { throw new NotFoundException('No active subscription found'); } // Cancel at period end (user keeps access until end of billing period) await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true, }); // Update local record await db .update(subscriptions) .set({ cancelAtPeriodEnd: true, updatedAt: new Date(), }) .where(eq(subscriptions.id, subscription.id)); this.logger.log('Subscription scheduled for cancellation', { userId, subscriptionId: subscription.id, }); return { success: true, cancelAtPeriodEnd: true }; } /** * Reactivate a canceled subscription (if still within billing period) */ async reactivateSubscription(userId: string) { if (!this.stripe) { throw new BadRequestException('Stripe is not configured'); } const db = this.getDb(); const [subscription] = await db .select() .from(subscriptions) .where(and(eq(subscriptions.userId, userId), eq(subscriptions.cancelAtPeriodEnd, true))) .limit(1); if (!subscription || !subscription.stripeSubscriptionId) { throw new NotFoundException('No canceled subscription found'); } // Remove cancellation await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: false, }); await db .update(subscriptions) .set({ cancelAtPeriodEnd: false, canceledAt: null, updatedAt: new Date(), }) .where(eq(subscriptions.id, subscription.id)); this.logger.log('Subscription reactivated', { userId }); return { success: true }; } // ============================================================================ // INVOICES // ============================================================================ /** * Get user's invoices */ async getInvoices(userId: string, limit = 20) { const db = this.getDb(); return db .select() .from(invoices) .where(eq(invoices.userId, userId)) .orderBy(desc(invoices.createdAt)) .limit(limit); } // ============================================================================ // WEBHOOK HANDLERS // ============================================================================ /** * Handle Stripe subscription created/updated */ async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) { const db = this.getDb(); const userId = stripeSubscription.metadata?.userId; if (!userId) { this.logger.warn('Subscription webhook missing userId in metadata', { subscriptionId: stripeSubscription.id, }); return; } const planId = stripeSubscription.metadata?.planId; // Check if subscription exists const [existing] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeSubscriptionId, stripeSubscription.id)) .limit(1); const subscriptionData = { userId, planId: planId || existing?.planId, stripeSubscriptionId: stripeSubscription.id, stripeCustomerId: stripeSubscription.customer as string, stripePriceId: stripeSubscription.items.data[0]?.price.id, status: stripeSubscription.status as any, billingInterval: stripeSubscription.items.data[0]?.price.recurring?.interval as any, currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, canceledAt: stripeSubscription.canceled_at ? new Date(stripeSubscription.canceled_at * 1000) : null, endedAt: stripeSubscription.ended_at ? new Date(stripeSubscription.ended_at * 1000) : null, trialStart: stripeSubscription.trial_start ? new Date(stripeSubscription.trial_start * 1000) : null, trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null, updatedAt: new Date(), }; if (existing) { await db.update(subscriptions).set(subscriptionData).where(eq(subscriptions.id, existing.id)); this.logger.log('Subscription updated', { subscriptionId: existing.id, status: stripeSubscription.status, }); } else { const [created] = await db .insert(subscriptions) .values(subscriptionData as any) .returning(); this.logger.log('Subscription created', { subscriptionId: created.id, userId, }); } } /** * Handle Stripe invoice events */ async handleInvoiceUpdated(stripeInvoice: Stripe.Invoice) { const db = this.getDb(); // Get user from customer const [customer] = await db .select() .from(stripeCustomers) .where(eq(stripeCustomers.stripeCustomerId, stripeInvoice.customer as string)) .limit(1); if (!customer) { this.logger.warn('Invoice webhook: customer not found', { stripeCustomerId: stripeInvoice.customer, }); return; } // Get subscription if exists let subscriptionId: string | null = null; if (stripeInvoice.subscription) { const [sub] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeSubscriptionId, stripeInvoice.subscription as string)) .limit(1); subscriptionId = sub?.id || null; } const invoiceData = { userId: customer.userId, subscriptionId, stripeInvoiceId: stripeInvoice.id, stripeCustomerId: stripeInvoice.customer as string, number: stripeInvoice.number, status: stripeInvoice.status || 'unknown', amountDueEuroCents: stripeInvoice.amount_due, amountPaidEuroCents: stripeInvoice.amount_paid, currency: stripeInvoice.currency, hostedInvoiceUrl: stripeInvoice.hosted_invoice_url, invoicePdfUrl: stripeInvoice.invoice_pdf, periodStart: stripeInvoice.period_start ? new Date(stripeInvoice.period_start * 1000) : null, periodEnd: stripeInvoice.period_end ? new Date(stripeInvoice.period_end * 1000) : null, dueDate: stripeInvoice.due_date ? new Date(stripeInvoice.due_date * 1000) : null, paidAt: stripeInvoice.status === 'paid' && stripeInvoice.status_transitions?.paid_at ? new Date(stripeInvoice.status_transitions.paid_at * 1000) : null, }; // Upsert invoice const [existing] = await db .select() .from(invoices) .where(eq(invoices.stripeInvoiceId, stripeInvoice.id)) .limit(1); if (existing) { await db.update(invoices).set(invoiceData).where(eq(invoices.id, existing.id)); } else { await db.insert(invoices).values(invoiceData as any); } this.logger.log('Invoice synced', { invoiceId: stripeInvoice.id, status: stripeInvoice.status, }); } }