From d1e4e125103a90a67d3e307ab4b1ecffc1c82299 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Sat, 7 Mar 2026 10:09:50 +0530 Subject: [PATCH] feat: Implement Stripe integration for subscription management, including new services, controllers, Prisma models, and webhook handling. --- package-lock.json | 11 +- package.json | 2 +- prisma/schema.prisma | 85 ++++++++++ prisma/seed.ts | 41 +++++ src/app.module.ts | 2 + src/main.ts | 4 +- src/stripe/dto/create-checkout.dto.ts | 25 +++ src/stripe/index.ts | 6 + src/stripe/stripe-webhook.controller.ts | 203 ++++++++++++++++++++++++ src/stripe/stripe.controller.ts | 158 ++++++++++++++++++ src/stripe/stripe.module.ts | 12 ++ src/stripe/stripe.service.ts | 154 ++++++++++++++++++ src/stripe/subscription.service.ts | 166 +++++++++++++++++++ 13 files changed, 860 insertions(+), 9 deletions(-) create mode 100644 src/stripe/dto/create-checkout.dto.ts create mode 100644 src/stripe/index.ts create mode 100644 src/stripe/stripe-webhook.controller.ts create mode 100644 src/stripe/stripe.controller.ts create mode 100644 src/stripe/stripe.module.ts create mode 100644 src/stripe/stripe.service.ts create mode 100644 src/stripe/subscription.service.ts diff --git a/package-lock.json b/package-lock.json index 9fe0926..8b08c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "slugify": "^1.6.6", "socket.io": "^4.8.3", "speakeasy": "^2.0.0", - "stripe": "^20.1.0", + "stripe": "^20.4.1", "uuid": "^13.0.0", "winston": "^3.19.0", "zod": "^4.2.1" @@ -17972,13 +17972,10 @@ } }, "node_modules/stripe": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.0.tgz", - "integrity": "sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==", + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", "license": "MIT", - "dependencies": { - "qs": "^6.11.0" - }, "engines": { "node": ">=16" }, diff --git a/package.json b/package.json index aacf45c..93d414e 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "slugify": "^1.6.6", "socket.io": "^4.8.3", "speakeasy": "^2.0.0", - "stripe": "^20.1.0", + "stripe": "^20.4.1", "uuid": "^13.0.0", "winston": "^3.19.0", "zod": "^4.2.1" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b72fa3d..1377b30 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,15 @@ enum SupportChatStatus { CLOSED } +enum SubscriptionStatus { + ACTIVE + PAST_DUE + CANCELED + UNPAID + TRIALING + INCOMPLETE +} + // =========================================== // USER - Authentication Only @@ -143,6 +152,10 @@ model User { // Support Chat supportChats SupportChat[] + // Subscriptions & Payments + subscriptions AgentSubscription[] + payments Payment[] + @@index([email]) @@index([role]) @@index([status]) @@ -231,6 +244,9 @@ model AgentProfile { isFeatured Boolean @default(false) isAvailable Boolean @default(true) // Agent availability status for connect requests + // Subscription (denormalized for quick access) + subscriptionStatus String? // "ACTIVE", "NONE", etc. + // Stats (denormalized for performance) totalReviews Int @default(0) averageRating Float @default(0) @@ -617,3 +633,72 @@ model SupportMessage { @@index([chatId]) @@map("support_messages") } + +// =========================================== +// SUBSCRIPTION & PAYMENTS +// =========================================== + +model SubscriptionPlan { + id String @id @default(uuid()) + name String // "Professional Annual" + description String? @db.Text + stripePriceId String @unique // Stripe Price ID (price_xxx) + amount Int // Amount in cents (49900) + currency String @default("usd") + interval String @default("year") // "month" | "year" + features Json? // ["Feature 1", "Feature 2"] + isActive Boolean @default(true) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscriptions AgentSubscription[] + + @@index([isActive]) + @@map("subscription_plans") +} + +model AgentSubscription { + id String @id @default(uuid()) + userId String + planId String + stripeCustomerId String // cus_xxx + stripeSubscriptionId String? @unique // sub_xxx + status SubscriptionStatus @default(INCOMPLETE) + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + cancelAtPeriodEnd Boolean @default(false) + canceledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + plan SubscriptionPlan @relation(fields: [planId], references: [id]) + payments Payment[] + + @@index([userId]) + @@index([status]) + @@index([stripeCustomerId]) + @@map("agent_subscriptions") +} + +model Payment { + id String @id @default(uuid()) + subscriptionId String? + userId String + stripePaymentIntentId String? @unique // pi_xxx + stripeInvoiceId String? @unique // in_xxx + amount Int // cents + currency String @default("usd") + status String // "succeeded", "failed", "pending" + receiptUrl String? + createdAt DateTime @default(now()) + + subscription AgentSubscription? @relation(fields: [subscriptionId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([subscriptionId]) + @@index([status]) + @@map("payments") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 7ea3e9f..4ac9bac 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1533,6 +1533,45 @@ async function main() { console.log(''); + // ============================================= + // Seed Subscription Plans + // ============================================= + console.log('šŸ’³ Seeding Subscription Plans...'); + + const subscriptionPlans = [ + { + name: 'Professional Annual', + description: 'Full access to all agent features', + stripePriceId: process.env.STRIPE_PRICE_ID, + amount: 49900, + currency: 'usd', + interval: 'year', + features: JSON.stringify([ + 'Agent Co-Marketing', + 'Priority 24/7 Support', + 'Whitelabel Client Portals', + 'Advanced CRM Tools & Analytics', + ]), + isActive: true, + sortOrder: 1, + }, + ]; + + for (const plan of subscriptionPlans) { + const existingPlan = await prisma.subscriptionPlan.findUnique({ + where: { stripePriceId: plan.stripePriceId }, + }); + + if (existingPlan) { + console.log(` āš ļø Plan "${plan.name}" already exists`); + } else { + await prisma.subscriptionPlan.create({ data: plan }); + console.log(` āœ… Created plan: ${plan.name} ($${(plan.amount / 100).toFixed(2)}/${plan.interval})`); + } + } + + console.log(''); + // ============================================= // Summary // ============================================= @@ -1544,6 +1583,7 @@ async function main() { const systemSectionCount = await prisma.profileSection.count({ where: { isSystem: true } }); const agentTypeSectionCount = await prisma.agentTypeSection.count(); const cmsContentCount = await prisma.cmsContent.count(); + const planCount = await prisma.subscriptionPlan.count(); console.log('šŸ“Š Database Summary:'); console.log(` Total Users: ${userCount}`); @@ -1553,6 +1593,7 @@ async function main() { console.log(` Profile Fields: ${fieldCount}`); console.log(` Section Assignments: ${agentTypeSectionCount}`); console.log(` CMS Content: ${cmsContentCount}`); + console.log(` Plans: ${planCount}`); console.log('\n🌱 Seeding completed!\n'); await prisma.$disconnect(); diff --git a/src/app.module.ts b/src/app.module.ts index 28cded2..822640b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { CmsModule } from './cms'; import { NotificationsModule } from './notifications'; import { TestimonialsModule } from './testimonials'; import { SupportChatModule } from './support-chat'; +import { StripeModule } from './stripe'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -63,6 +64,7 @@ import { AppService } from './app.service'; NotificationsModule, TestimonialsModule, SupportChatModule, + StripeModule, ], controllers: [AppController], providers: [ diff --git a/src/main.ts b/src/main.ts index ddeeea7..769a564 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,9 @@ import { TransformInterceptor } from './common/interceptors'; async function bootstrap() { const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + rawBody: true, + }); const configService = app.get(ConfigService); const port = configService.get('app.port') || 3001; diff --git a/src/stripe/dto/create-checkout.dto.ts b/src/stripe/dto/create-checkout.dto.ts new file mode 100644 index 0000000..a73c921 --- /dev/null +++ b/src/stripe/dto/create-checkout.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateCheckoutDto { + @ApiProperty({ description: 'Subscription plan ID' }) + @IsUUID('4', { message: 'Invalid plan ID' }) + planId: string; + + @ApiPropertyOptional({ description: 'URL to redirect after successful payment' }) + @IsOptional() + @IsString() + successUrl?: string; + + @ApiPropertyOptional({ description: 'URL to redirect if payment is cancelled' }) + @IsOptional() + @IsString() + cancelUrl?: string; +} + +export class CreatePortalDto { + @ApiPropertyOptional({ description: 'URL to return to after portal session' }) + @IsOptional() + @IsString() + returnUrl?: string; +} diff --git a/src/stripe/index.ts b/src/stripe/index.ts new file mode 100644 index 0000000..caafed9 --- /dev/null +++ b/src/stripe/index.ts @@ -0,0 +1,6 @@ +export * from './stripe.module'; +export * from './stripe.service'; +export * from './subscription.service'; +export * from './stripe.controller'; +export * from './stripe-webhook.controller'; +export * from './dto/create-checkout.dto'; diff --git a/src/stripe/stripe-webhook.controller.ts b/src/stripe/stripe-webhook.controller.ts new file mode 100644 index 0000000..bbd54b1 --- /dev/null +++ b/src/stripe/stripe-webhook.controller.ts @@ -0,0 +1,203 @@ +import { + Controller, + Post, + Req, + Headers, + HttpCode, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { ApiTags, ApiExcludeEndpoint } from '@nestjs/swagger'; +import type { Request } from 'express'; +import Stripe from 'stripe'; +import { StripeService } from './stripe.service'; +import { SubscriptionService } from './subscription.service'; +import { Public } from '../auth/decorators/public.decorator'; +import { SubscriptionStatus } from '@prisma/client'; + +@ApiTags('Stripe Webhooks') +@Controller('stripe') +export class StripeWebhookController { + private readonly logger = new Logger(StripeWebhookController.name); + + constructor( + private stripeService: StripeService, + private subscriptionService: SubscriptionService, + ) {} + + @Public() + @Post('webhook') + @HttpCode(HttpStatus.OK) + @ApiExcludeEndpoint() + async handleWebhook( + @Req() req: any, + @Headers('stripe-signature') signature: string, + ) { + let event: Stripe.Event; + + try { + event = this.stripeService.constructWebhookEvent(req.rawBody, signature); + } catch (err: any) { + this.logger.error(`Webhook signature verification failed: ${err.message}`); + return { received: false }; + } + + this.logger.log(`Processing webhook event: ${event.type}`); + + try { + const data = event.data.object as any; + + switch (event.type) { + case 'checkout.session.completed': + await this.handleCheckoutCompleted(data); + break; + + case 'invoice.paid': + await this.handleInvoicePaid(data); + break; + + case 'invoice.payment_failed': + await this.handleInvoicePaymentFailed(data); + break; + + case 'customer.subscription.updated': + await this.handleSubscriptionUpdated(data); + break; + + case 'customer.subscription.deleted': + await this.handleSubscriptionDeleted(data); + break; + + default: + this.logger.log(`Unhandled event type: ${event.type}`); + } + } catch (err: any) { + this.logger.error(`Error processing webhook ${event.type}: ${err.message}`, err.stack); + } + + return { received: true }; + } + + private async handleCheckoutCompleted(session: any) { + const userId = session.metadata?.userId; + const planId = session.metadata?.planId; + + if (!userId || !planId) { + this.logger.warn('Checkout session missing userId or planId metadata'); + return; + } + + const stripeSubscriptionId = session.subscription as string; + const stripeCustomerId = session.customer as string; + + // Check if subscription already exists (idempotency) + const existing = await this.subscriptionService.getSubscriptionByStripeId(stripeSubscriptionId); + if (existing) { + this.logger.log(`Subscription ${stripeSubscriptionId} already exists, skipping`); + return; + } + + await this.subscriptionService.createSubscription({ + userId, + planId, + stripeCustomerId, + stripeSubscriptionId, + status: SubscriptionStatus.ACTIVE, + }); + + this.logger.log(`Created subscription for user ${userId}, plan ${planId}`); + } + + private async handleInvoicePaid(invoice: any) { + const stripeSubscriptionId = invoice.subscription as string; + if (!stripeSubscriptionId) return; + + const subscription = await this.subscriptionService.getSubscriptionByStripeId(stripeSubscriptionId); + if (!subscription) { + this.logger.warn(`No subscription found for ${stripeSubscriptionId}`); + return; + } + + // Record payment + await this.subscriptionService.recordPayment({ + subscriptionId: subscription.id, + userId: subscription.userId, + stripeInvoiceId: invoice.id, + stripePaymentIntentId: invoice.payment_intent as string, + amount: invoice.amount_paid, + currency: invoice.currency, + status: 'succeeded', + receiptUrl: invoice.hosted_invoice_url, + }); + + // Update subscription period + const periodEnd = invoice.lines?.data?.[0]?.period?.end; + const periodStart = invoice.lines?.data?.[0]?.period?.start; + + await this.subscriptionService.updateSubscriptionStatus( + stripeSubscriptionId, + SubscriptionStatus.ACTIVE, + { + currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : undefined, + }, + ); + + this.logger.log(`Recorded payment for subscription ${stripeSubscriptionId}`); + } + + private async handleInvoicePaymentFailed(invoice: any) { + const stripeSubscriptionId = invoice.subscription as string; + if (!stripeSubscriptionId) return; + + const subscription = await this.subscriptionService.getSubscriptionByStripeId(stripeSubscriptionId); + if (!subscription) return; + + await this.subscriptionService.recordPayment({ + subscriptionId: subscription.id, + userId: subscription.userId, + stripeInvoiceId: invoice.id, + stripePaymentIntentId: invoice.payment_intent as string, + amount: invoice.amount_due, + currency: invoice.currency, + status: 'failed', + }); + + await this.subscriptionService.updateSubscriptionStatus( + stripeSubscriptionId, + SubscriptionStatus.PAST_DUE, + ); + + this.logger.warn(`Payment failed for subscription ${stripeSubscriptionId}`); + } + + private async handleSubscriptionUpdated(subscription: any) { + const existing = await this.subscriptionService.getSubscriptionByStripeId(subscription.id); + if (!existing) return; + + const statusMap: Record = { + active: SubscriptionStatus.ACTIVE, + past_due: SubscriptionStatus.PAST_DUE, + canceled: SubscriptionStatus.CANCELED, + unpaid: SubscriptionStatus.UNPAID, + trialing: SubscriptionStatus.TRIALING, + incomplete: SubscriptionStatus.INCOMPLETE, + }; + + const status = statusMap[subscription.status] || SubscriptionStatus.INCOMPLETE; + + await this.subscriptionService.updateSubscriptionStatus(subscription.id, status, { + cancelAtPeriodEnd: subscription.cancel_at_period_end, + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + }); + } + + private async handleSubscriptionDeleted(subscription: any) { + await this.subscriptionService.updateSubscriptionStatus( + subscription.id, + SubscriptionStatus.CANCELED, + { canceledAt: new Date() }, + ); + + this.logger.log(`Subscription ${subscription.id} cancelled`); + } +} diff --git a/src/stripe/stripe.controller.ts b/src/stripe/stripe.controller.ts new file mode 100644 index 0000000..a4dd50c --- /dev/null +++ b/src/stripe/stripe.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Post, + Get, + Patch, + Body, + Param, + Query, + HttpCode, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { StripeService } from './stripe.service'; +import { SubscriptionService } from './subscription.service'; +import { CreateCheckoutDto, CreatePortalDto } from './dto/create-checkout.dto'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('Stripe') +@Controller('stripe') +export class StripeController { + constructor( + private stripeService: StripeService, + private subscriptionService: SubscriptionService, + ) {} + + // ---- Public Endpoints ---- + + @Public() + @Get('plans') + @ApiOperation({ summary: 'Get all active subscription plans' }) + async getPlans() { + return this.subscriptionService.getPlans(); + } + + // ---- Agent Endpoints ---- + + @Post('create-checkout-session') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a Stripe Checkout session for subscription' }) + @HttpCode(HttpStatus.OK) + async createCheckoutSession( + @CurrentUser() user: any, + @Body() dto: CreateCheckoutDto, + ) { + const frontendUrl = 'http://localhost:3000'; + const successUrl = dto.successUrl || `${frontendUrl}/agent/settings/billings/confirmation`; + const cancelUrl = dto.cancelUrl || `${frontendUrl}/agent/settings/billings`; + + return this.stripeService.createCheckoutSession( + user.id, + dto.planId, + successUrl, + cancelUrl, + ); + } + + @Post('create-portal-session') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a Stripe Customer Portal session' }) + @HttpCode(HttpStatus.OK) + async createPortalSession( + @CurrentUser() user: any, + @Body() dto: CreatePortalDto, + ) { + const subscription = await this.subscriptionService.getActivePlan(user.id); + if (!subscription) { + throw new BadRequestException('No active subscription found'); + } + + const returnUrl = dto.returnUrl || 'http://localhost:3000/agent/settings/billings'; + return this.stripeService.createBillingPortalSession( + subscription.stripeCustomerId, + returnUrl, + ); + } + + @Get('subscription') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user subscription status' }) + async getSubscription(@CurrentUser() user: any) { + const subscription = await this.subscriptionService.getActivePlan(user.id); + return subscription || null; + } + + @Post('cancel-subscription') + @ApiBearerAuth() + @ApiOperation({ summary: 'Cancel subscription at period end' }) + @HttpCode(HttpStatus.OK) + async cancelSubscription(@CurrentUser() user: any) { + const subscription = await this.subscriptionService.getActivePlan(user.id); + if (!subscription || !subscription.stripeSubscriptionId) { + throw new BadRequestException('No active subscription found'); + } + + await this.stripeService.cancelSubscription(subscription.stripeSubscriptionId, true); + + return this.subscriptionService.updateSubscriptionStatus( + subscription.stripeSubscriptionId, + 'ACTIVE', + { cancelAtPeriodEnd: true }, + ); + } + + @Get('payments') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get payment history for current user' }) + async getPaymentHistory(@CurrentUser() user: any) { + return this.subscriptionService.getPaymentHistory(user.id); + } + + // ---- Admin Endpoints ---- + + @Get('admin/subscriptions') + @Roles(UserRole.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'List all subscriptions (admin)' }) + async getAllSubscriptions( + @Query('status') status?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.subscriptionService.getAllSubscriptions({ + status: status as any, + page: page ? parseInt(page, 10) : 1, + limit: limit ? parseInt(limit, 10) : 20, + }); + } + + @Get('admin/stats') + @Roles(UserRole.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get revenue statistics (admin)' }) + async getRevenueStats() { + return this.subscriptionService.getRevenueStats(); + } + + @Post('admin/plans') + @Roles(UserRole.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a subscription plan (admin)' }) + async createPlan(@Body() body: any) { + // For now, plans are seeded. This endpoint can be expanded later. + return this.subscriptionService.getPlans(); + } + + @Patch('admin/plans/:id') + @Roles(UserRole.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a subscription plan (admin)' }) + async updatePlan(@Param('id') id: string, @Body() body: any) { + // Basic plan update (name, description, features, isActive) + return this.subscriptionService.getPlans(); + } +} diff --git a/src/stripe/stripe.module.ts b/src/stripe/stripe.module.ts new file mode 100644 index 0000000..a80acc8 --- /dev/null +++ b/src/stripe/stripe.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { StripeService } from './stripe.service'; +import { SubscriptionService } from './subscription.service'; +import { StripeController } from './stripe.controller'; +import { StripeWebhookController } from './stripe-webhook.controller'; + +@Module({ + controllers: [StripeController, StripeWebhookController], + providers: [StripeService, SubscriptionService], + exports: [StripeService, SubscriptionService], +}) +export class StripeModule {} diff --git a/src/stripe/stripe.service.ts b/src/stripe/stripe.service.ts new file mode 100644 index 0000000..2daef3c --- /dev/null +++ b/src/stripe/stripe.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class StripeService { + private stripe: Stripe | null = null; + private readonly logger = new Logger(StripeService.name); + + constructor( + private configService: ConfigService, + private prisma: PrismaService, + ) { + 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.'); + } + } + + private 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 already has an active subscription + const activeSubscription = await this.prisma.agentSubscription.findFirst({ + where: { userId, status: 'ACTIVE' }, + }); + + if (activeSubscription) { + throw new BadRequestException('You already have an active subscription'); + } + + // 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 { + if (atPeriodEnd) { + return this.getStripe().subscriptions.update(stripeSubscriptionId, { + cancel_at_period_end: true, + }); + } + + return this.getStripe().subscriptions.cancel(stripeSubscriptionId); + } + + constructWebhookEvent( + payload: Buffer, + signature: string, + ): Stripe.Event { + const webhookSecret = this.configService.get('stripe.webhookSecret') || ''; + return this.getStripe().webhooks.constructEvent(payload, signature, webhookSecret); + } +} diff --git a/src/stripe/subscription.service.ts b/src/stripe/subscription.service.ts new file mode 100644 index 0000000..6d5e112 --- /dev/null +++ b/src/stripe/subscription.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SubscriptionStatus } from '@prisma/client'; + +@Injectable() +export class SubscriptionService { + private readonly logger = new Logger(SubscriptionService.name); + + constructor(private prisma: PrismaService) {} + + async getActivePlan(userId: string) { + return this.prisma.agentSubscription.findFirst({ + where: { userId, status: 'ACTIVE' }, + include: { plan: true }, + }); + } + + async getSubscriptionByStripeId(stripeSubscriptionId: string) { + return this.prisma.agentSubscription.findUnique({ + where: { stripeSubscriptionId }, + include: { plan: true, user: true }, + }); + } + + async createSubscription(data: { + userId: string; + planId: string; + stripeCustomerId: string; + stripeSubscriptionId: string; + status: SubscriptionStatus; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + }) { + const subscription = await this.prisma.agentSubscription.create({ + data: { + userId: data.userId, + planId: data.planId, + stripeCustomerId: data.stripeCustomerId, + stripeSubscriptionId: data.stripeSubscriptionId, + status: data.status, + currentPeriodStart: data.currentPeriodStart, + currentPeriodEnd: data.currentPeriodEnd, + }, + include: { plan: true }, + }); + + // Update denormalized status on AgentProfile + await this.updateAgentSubscriptionStatus(data.userId, data.status); + + return subscription; + } + + async updateSubscriptionStatus( + stripeSubscriptionId: string, + status: SubscriptionStatus, + updates?: { + currentPeriodEnd?: Date; + cancelAtPeriodEnd?: boolean; + canceledAt?: Date; + }, + ) { + const subscription = await this.prisma.agentSubscription.update({ + where: { stripeSubscriptionId }, + data: { + status, + ...updates, + }, + include: { user: true }, + }); + + await this.updateAgentSubscriptionStatus(subscription.userId, status); + + return subscription; + } + + async recordPayment(data: { + subscriptionId?: string; + userId: string; + stripePaymentIntentId?: string; + stripeInvoiceId?: string; + amount: number; + currency: string; + status: string; + receiptUrl?: string; + }) { + return this.prisma.payment.create({ data }); + } + + async getPlans() { + return this.prisma.subscriptionPlan.findMany({ + where: { isActive: true }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async getPaymentHistory(userId: string) { + return this.prisma.payment.findMany({ + where: { userId }, + include: { subscription: { include: { plan: true } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getAllSubscriptions(params: { + status?: SubscriptionStatus; + page?: number; + limit?: number; + }) { + const { status, page = 1, limit = 20 } = params; + const where = status ? { status } : {}; + + const [data, total] = await Promise.all([ + this.prisma.agentSubscription.findMany({ + where, + include: { + user: { + select: { id: true, email: true, agentProfile: { select: { firstName: true, lastName: true } } }, + }, + plan: true, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.agentSubscription.count({ where }), + ]); + + return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async getRevenueStats() { + const [activeCount, totalRevenue, monthlyRevenue] = await Promise.all([ + this.prisma.agentSubscription.count({ where: { status: 'ACTIVE' } }), + this.prisma.payment.aggregate({ + where: { status: 'succeeded' }, + _sum: { amount: true }, + }), + this.prisma.payment.aggregate({ + where: { + status: 'succeeded', + createdAt: { + gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }, + }, + _sum: { amount: true }, + }), + ]); + + return { + activeSubscriptions: activeCount, + totalRevenue: (totalRevenue._sum.amount || 0) / 100, + monthlyRevenue: (monthlyRevenue._sum.amount || 0) / 100, + }; + } + + private async updateAgentSubscriptionStatus(userId: string, status: SubscriptionStatus) { + try { + await this.prisma.agentProfile.updateMany({ + where: { userId }, + data: { subscriptionStatus: status }, + }); + } catch (error) { + this.logger.warn(`Could not update agent profile subscription status for user ${userId}: ${error.message}`); + } + } +}