feat: Implement Stripe integration for subscription management, including new services, controllers, Prisma models, and webhook handling.
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -11,7 +11,9 @@ import { TransformInterceptor } from './common/interceptors';
|
||||
|
||||
async function 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 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