feat: Implement Stripe integration for subscription management, including new services, controllers, Prisma models, and webhook handling.

This commit is contained in:
pradeepkumar
2026-03-07 10:09:50 +05:30
parent b3f43bf2e5
commit d1e4e12510
13 changed files with 860 additions and 9 deletions

11
package-lock.json generated
View File

@@ -72,7 +72,7 @@
"slugify": "^1.6.6", "slugify": "^1.6.6",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"stripe": "^20.1.0", "stripe": "^20.4.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",
"zod": "^4.2.1" "zod": "^4.2.1"
@@ -17972,13 +17972,10 @@
} }
}, },
"node_modules/stripe": { "node_modules/stripe": {
"version": "20.1.0", "version": "20.4.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.0.tgz", "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz",
"integrity": "sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==", "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==",
"license": "MIT", "license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
"engines": { "engines": {
"node": ">=16" "node": ">=16"
}, },

View File

@@ -90,7 +90,7 @@
"slugify": "^1.6.6", "slugify": "^1.6.6",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"stripe": "^20.1.0", "stripe": "^20.4.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",
"zod": "^4.2.1" "zod": "^4.2.1"

View File

@@ -80,6 +80,15 @@ enum SupportChatStatus {
CLOSED CLOSED
} }
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELED
UNPAID
TRIALING
INCOMPLETE
}
// =========================================== // ===========================================
// USER - Authentication Only // USER - Authentication Only
@@ -143,6 +152,10 @@ model User {
// Support Chat // Support Chat
supportChats SupportChat[] supportChats SupportChat[]
// Subscriptions & Payments
subscriptions AgentSubscription[]
payments Payment[]
@@index([email]) @@index([email])
@@index([role]) @@index([role])
@@index([status]) @@index([status])
@@ -231,6 +244,9 @@ model AgentProfile {
isFeatured Boolean @default(false) isFeatured Boolean @default(false)
isAvailable Boolean @default(true) // Agent availability status for connect requests isAvailable Boolean @default(true) // Agent availability status for connect requests
// Subscription (denormalized for quick access)
subscriptionStatus String? // "ACTIVE", "NONE", etc.
// Stats (denormalized for performance) // Stats (denormalized for performance)
totalReviews Int @default(0) totalReviews Int @default(0)
averageRating Float @default(0) averageRating Float @default(0)
@@ -617,3 +633,72 @@ model SupportMessage {
@@index([chatId]) @@index([chatId])
@@map("support_messages") @@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")
}

View File

@@ -1533,6 +1533,45 @@ async function main() {
console.log(''); 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 // Summary
// ============================================= // =============================================
@@ -1544,6 +1583,7 @@ async function main() {
const systemSectionCount = await prisma.profileSection.count({ where: { isSystem: true } }); const systemSectionCount = await prisma.profileSection.count({ where: { isSystem: true } });
const agentTypeSectionCount = await prisma.agentTypeSection.count(); const agentTypeSectionCount = await prisma.agentTypeSection.count();
const cmsContentCount = await prisma.cmsContent.count(); const cmsContentCount = await prisma.cmsContent.count();
const planCount = await prisma.subscriptionPlan.count();
console.log('📊 Database Summary:'); console.log('📊 Database Summary:');
console.log(` Total Users: ${userCount}`); console.log(` Total Users: ${userCount}`);
@@ -1553,6 +1593,7 @@ async function main() {
console.log(` Profile Fields: ${fieldCount}`); console.log(` Profile Fields: ${fieldCount}`);
console.log(` Section Assignments: ${agentTypeSectionCount}`); console.log(` Section Assignments: ${agentTypeSectionCount}`);
console.log(` CMS Content: ${cmsContentCount}`); console.log(` CMS Content: ${cmsContentCount}`);
console.log(` Plans: ${planCount}`);
console.log('\n🌱 Seeding completed!\n'); console.log('\n🌱 Seeding completed!\n');
await prisma.$disconnect(); await prisma.$disconnect();

View File

@@ -20,6 +20,7 @@ import { CmsModule } from './cms';
import { NotificationsModule } from './notifications'; import { NotificationsModule } from './notifications';
import { TestimonialsModule } from './testimonials'; import { TestimonialsModule } from './testimonials';
import { SupportChatModule } from './support-chat'; import { SupportChatModule } from './support-chat';
import { StripeModule } from './stripe';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
@@ -63,6 +64,7 @@ import { AppService } from './app.service';
NotificationsModule, NotificationsModule,
TestimonialsModule, TestimonialsModule,
SupportChatModule, SupportChatModule,
StripeModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@@ -11,7 +11,9 @@ import { TransformInterceptor } from './common/interceptors';
async function bootstrap() { async function bootstrap() {
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true,
});
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
const port = configService.get<number>('app.port') || 3001; const port = configService.get<number>('app.port') || 3001;

View File

@@ -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;
}

6
src/stripe/index.ts Normal file
View File

@@ -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';

View File

@@ -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<string, SubscriptionStatus> = {
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`);
}
}

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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<string>('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<string> {
// 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<Stripe.Subscription> {
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<string>('stripe.webhookSecret') || '';
return this.getStripe().webhooks.constructEvent(payload, signature, webhookSecret);
}
}

View File

@@ -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}`);
}
}
}