feat: Implement Stripe integration for subscription management, including new services, controllers, Prisma models, and webhook handling.
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
25
src/stripe/dto/create-checkout.dto.ts
Normal file
25
src/stripe/dto/create-checkout.dto.ts
Normal 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
6
src/stripe/index.ts
Normal 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';
|
||||||
203
src/stripe/stripe-webhook.controller.ts
Normal file
203
src/stripe/stripe-webhook.controller.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/stripe/stripe.controller.ts
Normal file
158
src/stripe/stripe.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/stripe/stripe.module.ts
Normal file
12
src/stripe/stripe.module.ts
Normal 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 {}
|
||||||
154
src/stripe/stripe.service.ts
Normal file
154
src/stripe/stripe.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/stripe/subscription.service.ts
Normal file
166
src/stripe/subscription.service.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user