208 lines
5.7 KiB
TypeScript
208 lines
5.7 KiB
TypeScript
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.',
|
|
);
|
|
}
|
|
}
|
|
|
|
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 has a VALID active subscription (ACTIVE with valid period end in the future)
|
|
const now = new Date();
|
|
const validActive = await this.prisma.agentSubscription.findFirst({
|
|
where: {
|
|
userId,
|
|
status: 'ACTIVE',
|
|
currentPeriodEnd: { gt: now },
|
|
},
|
|
});
|
|
|
|
if (validActive) {
|
|
throw new BadRequestException('You already have an active subscription');
|
|
}
|
|
|
|
// Clean up stale subscriptions (failed states OR ACTIVE with no period / expired period)
|
|
// This handles the case where checkout completed but invoice.paid never fired,
|
|
// leaving an orphaned ACTIVE record without proper period dates
|
|
const staleSubscriptions = await this.prisma.agentSubscription.findMany({
|
|
where: {
|
|
userId,
|
|
OR: [
|
|
{ status: { in: ['INCOMPLETE', 'UNPAID', 'PAST_DUE', 'CANCELED'] } },
|
|
{ status: 'ACTIVE', currentPeriodEnd: null },
|
|
{ status: 'ACTIVE', currentPeriodEnd: { lte: now } },
|
|
],
|
|
},
|
|
});
|
|
|
|
if (staleSubscriptions.length > 0) {
|
|
// Cancel stale Stripe subscriptions before deleting DB records
|
|
for (const sub of staleSubscriptions) {
|
|
if (!sub.stripeSubscriptionId) continue;
|
|
try {
|
|
await this.getStripe().subscriptions.cancel(sub.stripeSubscriptionId);
|
|
} catch (err: any) {
|
|
this.logger.warn(`Could not cancel stale Stripe subscription ${sub.stripeSubscriptionId}: ${err.message}`);
|
|
}
|
|
}
|
|
await this.prisma.agentSubscription.deleteMany({
|
|
where: {
|
|
id: { in: staleSubscriptions.map((s) => s.id) },
|
|
},
|
|
});
|
|
this.logger.log(`Cleaned up ${staleSubscriptions.length} stale subscription(s) for user ${userId}`);
|
|
}
|
|
|
|
// Get or create Stripe customer
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
include: { agentProfile: true },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
const customerName = user.agentProfile
|
|
? `${user.agentProfile.firstName || ''} ${user.agentProfile.lastName || ''}`.trim()
|
|
: undefined;
|
|
|
|
const customerId = await this.createCustomer(
|
|
userId,
|
|
user.email,
|
|
customerName,
|
|
);
|
|
|
|
const session = await this.getStripe().checkout.sessions.create({
|
|
customer: customerId,
|
|
mode: 'subscription',
|
|
payment_method_types: ['card'],
|
|
line_items: [
|
|
{
|
|
price: plan.stripePriceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: cancelUrl,
|
|
metadata: {
|
|
userId,
|
|
planId,
|
|
},
|
|
subscription_data: {
|
|
metadata: {
|
|
userId,
|
|
planId,
|
|
},
|
|
},
|
|
});
|
|
|
|
return { url: session.url! };
|
|
}
|
|
|
|
async createBillingPortalSession(
|
|
customerId: string,
|
|
returnUrl: string,
|
|
): Promise<{ url: string }> {
|
|
const session = await this.getStripe().billingPortal.sessions.create({
|
|
customer: customerId,
|
|
return_url: returnUrl,
|
|
});
|
|
|
|
return { url: session.url };
|
|
}
|
|
|
|
async cancelSubscription(
|
|
stripeSubscriptionId: string,
|
|
atPeriodEnd = true,
|
|
): Promise<Stripe.Subscription> {
|
|
if (atPeriodEnd) {
|
|
return this.getStripe().subscriptions.update(stripeSubscriptionId, {
|
|
cancel_at_period_end: true,
|
|
});
|
|
}
|
|
|
|
return this.getStripe().subscriptions.cancel(stripeSubscriptionId);
|
|
}
|
|
|
|
async getSubscription(
|
|
stripeSubscriptionId: string,
|
|
): Promise<Stripe.Subscription> {
|
|
return this.getStripe().subscriptions.retrieve(stripeSubscriptionId);
|
|
}
|
|
|
|
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
|
|
const webhookSecret =
|
|
this.configService.get<string>('stripe.webhookSecret') || '';
|
|
return this.getStripe().webhooks.constructEvent(
|
|
payload,
|
|
signature,
|
|
webhookSecret,
|
|
);
|
|
}
|
|
}
|