import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Stripe from 'stripe'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AuditAction } from '../audit/audit.constants'; @Injectable() export class StripeService { private stripe: Stripe | null = null; private readonly logger = new Logger(StripeService.name); constructor( private configService: ConfigService, private prisma: PrismaService, private audit: AuditService, ) { const secretKey = this.configService.get('stripe.secretKey'); if (secretKey) { this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any, }); } else { this.logger.warn( 'Stripe secret key not configured. Stripe features will be unavailable.', ); } } getStripe(): Stripe { if (!this.stripe) { throw new BadRequestException( 'Stripe is not configured. Please set STRIPE_SECRET_KEY in environment.', ); } return this.stripe; } async createCustomer( userId: string, email: string, name?: string, ): Promise { // Check if user already has a subscription with a Stripe customer ID const existing = await this.prisma.agentSubscription.findFirst({ where: { userId }, select: { stripeCustomerId: true }, }); if (existing) { return existing.stripeCustomerId; } const customer = await this.getStripe().customers.create({ email, name, metadata: { userId }, }); return customer.id; } async createCheckoutSession( userId: string, planId: string, successUrl: string, cancelUrl: string, ): Promise<{ url: string }> { const plan = await this.prisma.subscriptionPlan.findUnique({ where: { id: planId }, }); if (!plan || !plan.isActive) { throw new BadRequestException('Invalid or inactive plan'); } // Check if user has a VALID active subscription (ACTIVE with valid period end in the future) const now = new Date(); const validActive = await this.prisma.agentSubscription.findFirst({ where: { userId, status: 'ACTIVE', currentPeriodEnd: { gt: now }, }, }); if (validActive) { throw new BadRequestException('You already have an active subscription'); } // Clean up stale subscriptions (failed states OR ACTIVE with no period / expired period) // This handles the case where checkout completed but invoice.paid never fired, // leaving an orphaned ACTIVE record without proper period dates const staleSubscriptions = await this.prisma.agentSubscription.findMany({ where: { userId, OR: [ { status: { in: ['INCOMPLETE', 'UNPAID', 'PAST_DUE', 'CANCELED'] } }, { status: 'ACTIVE', currentPeriodEnd: null }, { status: 'ACTIVE', currentPeriodEnd: { lte: now } }, ], }, }); if (staleSubscriptions.length > 0) { // Cancel stale Stripe subscriptions before deleting DB records for (const sub of staleSubscriptions) { if (!sub.stripeSubscriptionId) continue; try { await this.getStripe().subscriptions.cancel(sub.stripeSubscriptionId); } catch (err: any) { this.logger.warn(`Could not cancel stale Stripe subscription ${sub.stripeSubscriptionId}: ${err.message}`); } } await this.prisma.agentSubscription.deleteMany({ where: { id: { in: staleSubscriptions.map((s) => s.id) }, }, }); this.logger.log(`Cleaned up ${staleSubscriptions.length} stale subscription(s) for user ${userId}`); } // Get or create Stripe customer const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { agentProfile: true }, }); if (!user) { throw new BadRequestException('User not found'); } const customerName = user.agentProfile ? `${user.agentProfile.firstName || ''} ${user.agentProfile.lastName || ''}`.trim() : undefined; const customerId = await this.createCustomer( userId, user.email, customerName, ); const session = await this.getStripe().checkout.sessions.create({ customer: customerId, mode: 'subscription', payment_method_types: ['card'], line_items: [ { price: plan.stripePriceId, quantity: 1, }, ], success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: cancelUrl, metadata: { userId, planId, }, subscription_data: { metadata: { userId, planId, }, }, }); return { url: session.url! }; } async createBillingPortalSession( customerId: string, returnUrl: string, ): Promise<{ url: string }> { const session = await this.getStripe().billingPortal.sessions.create({ customer: customerId, return_url: returnUrl, }); return { url: session.url }; } async cancelSubscription( stripeSubscriptionId: string, atPeriodEnd = true, ): Promise { const result = atPeriodEnd ? await this.getStripe().subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: true, }) : await this.getStripe().subscriptions.cancel(stripeSubscriptionId); this.audit.log({ action: AuditAction.SUBSCRIPTION_CANCEL, resourceType: 'AgentSubscription', resourceId: stripeSubscriptionId, metadata: { atPeriodEnd, source: 'agent_action' }, }); return result; } async getSubscription( stripeSubscriptionId: string, ): Promise { return this.getStripe().subscriptions.retrieve(stripeSubscriptionId); } constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event { const webhookSecret = this.configService.get('stripe.webhookSecret') || ''; return this.getStripe().webhooks.constructEvent( payload, signature, webhookSecret, ); } }