- Make verifyCustomerOwnership public in BillingService - Add ownership verification before fetching invoice history - Returns 403 if customerId does not belong to authenticated user Co-Authored-By: Paperclip <noreply@paperclip.ing>
281 lines
7.4 KiB
TypeScript
281 lines
7.4 KiB
TypeScript
import Stripe from 'stripe';
|
|
import { loadBillingConfig, SubscriptionTier } from '../config/billing.config';
|
|
import { RedisService } from '@shieldsai/shared-notifications';
|
|
import type { Subscription, SubscriptionCreateSchema, SubscriptionUpdateSchema } from '../models/subscription.model';
|
|
|
|
const config = loadBillingConfig();
|
|
const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2023-10-16' });
|
|
const redis = RedisService.getInstance();
|
|
const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60;
|
|
|
|
export class BillingService {
|
|
private static instance: BillingService;
|
|
|
|
private constructor() {}
|
|
|
|
static getInstance(): BillingService {
|
|
if (!BillingService.instance) {
|
|
BillingService.instance = new BillingService();
|
|
}
|
|
return BillingService.instance;
|
|
}
|
|
|
|
async createCustomer(email: string, userId: string): Promise<Stripe.Customer> {
|
|
const customer = await stripe.customers.create({
|
|
email,
|
|
metadata: { userId },
|
|
});
|
|
return customer;
|
|
}
|
|
|
|
async getCustomer(customerId: string): Promise<Stripe.Customer | null> {
|
|
try {
|
|
const customer = await stripe.customers.retrieve(customerId);
|
|
return customer as Stripe.Customer;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async verifyCustomerOwnership(
|
|
customerId: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
const customer = await stripe.customers.retrieve(customerId);
|
|
const customerUserId = (customer as Stripe.Customer).metadata?.userId;
|
|
|
|
if (customerUserId !== userId) {
|
|
throw new Error(
|
|
`Customer ${customerId} does not belong to user ${userId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async getUserTier(userId: string): Promise<SubscriptionTier | null> {
|
|
try {
|
|
const customers = await stripe.customers.list({
|
|
limit: 100,
|
|
expand: ['data.subscriptions'],
|
|
});
|
|
|
|
const customer = customers.data.find(
|
|
(c: Stripe.Customer) =>
|
|
c.metadata?.userId === userId && c.subscriptions?.data.length && c.subscriptions.data.length > 0
|
|
);
|
|
|
|
if (!customer || !customer.subscriptions) {
|
|
return null;
|
|
}
|
|
|
|
const activeSubscription = customer.subscriptions.data.find(
|
|
(sub: Stripe.Subscription) => sub.status === 'active'
|
|
);
|
|
|
|
if (!activeSubscription) {
|
|
return null;
|
|
}
|
|
|
|
const tier = activeSubscription.metadata?.tier as SubscriptionTier;
|
|
return tier || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async createSubscription(
|
|
userId: string,
|
|
tier: SubscriptionTier,
|
|
customerId: string
|
|
): Promise<{ subscription: Stripe.Subscription; customer: Stripe.Customer }> {
|
|
const tierConfig = config.tiers[tier];
|
|
|
|
const customer = await this.getCustomer(customerId);
|
|
if (!customer) {
|
|
throw new Error(`Customer ${customerId} not found`);
|
|
}
|
|
|
|
await this.verifyCustomerOwnership(customerId, userId);
|
|
|
|
const subscription = await stripe.subscriptions.create({
|
|
customer: customerId,
|
|
items: [{ price: tierConfig.priceId }],
|
|
metadata: { userId, tier },
|
|
});
|
|
|
|
return { subscription, customer };
|
|
}
|
|
|
|
async cancelSubscription(
|
|
subscriptionId: string,
|
|
userId: string,
|
|
cancelAtPeriodEnd: boolean = false
|
|
): Promise<Stripe.Subscription> {
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
const customerUserId = subscription.metadata?.userId;
|
|
|
|
if (customerUserId !== userId) {
|
|
throw new Error(
|
|
`Subscription ${subscriptionId} does not belong to user ${userId}`
|
|
);
|
|
}
|
|
|
|
if (cancelAtPeriodEnd) {
|
|
return await stripe.subscriptions.update(subscriptionId, {
|
|
cancel_at_period_end: true,
|
|
});
|
|
}
|
|
return await stripe.subscriptions.cancel(subscriptionId);
|
|
}
|
|
|
|
async updateSubscription(
|
|
subscriptionId: string,
|
|
userId: string,
|
|
newTier: SubscriptionTier
|
|
): Promise<Stripe.Subscription> {
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
const customerUserId = subscription.metadata?.userId;
|
|
|
|
if (customerUserId !== userId) {
|
|
throw new Error(
|
|
`Subscription ${subscriptionId} does not belong to user ${userId}`
|
|
);
|
|
}
|
|
|
|
const newTierConfig = config.tiers[newTier];
|
|
|
|
// Ensure subscription has items before updating
|
|
const firstItem = subscription.items.data[0];
|
|
if (!firstItem?.id) {
|
|
throw new Error(
|
|
`Subscription ${subscriptionId} has no items to update`
|
|
);
|
|
}
|
|
|
|
const updated = await stripe.subscriptions.update(subscriptionId, {
|
|
proration_behavior: 'create_prorations',
|
|
items: [
|
|
{
|
|
id: firstItem.id,
|
|
price: newTierConfig.priceId,
|
|
},
|
|
],
|
|
});
|
|
|
|
return updated;
|
|
}
|
|
|
|
async createCustomerPortalSession(
|
|
customerId: string,
|
|
returnUrl: string
|
|
): Promise<Stripe.BillingPortal.Session> {
|
|
return await stripe.billingPortal.sessions.create({
|
|
customer: customerId,
|
|
return_url: returnUrl,
|
|
});
|
|
}
|
|
|
|
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription | null> {
|
|
try {
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
return subscription;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getUserSubscription(userId: string): Promise<Stripe.Subscription | null> {
|
|
try {
|
|
const customers = await stripe.customers.list({
|
|
limit: 100,
|
|
expand: ['data.subscriptions'],
|
|
});
|
|
|
|
for (const customer of customers.data) {
|
|
if (customer.metadata?.userId === userId && customer.subscriptions) {
|
|
const activeSub = customer.subscriptions.data.find(
|
|
(sub: Stripe.Subscription) => sub.status === 'active'
|
|
);
|
|
if (activeSub) {
|
|
return activeSub;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getTierLimits(tier: SubscriptionTier) {
|
|
return config.tiers[tier];
|
|
}
|
|
|
|
async checkUsageAgainstLimit(
|
|
userId: string,
|
|
tier: SubscriptionTier,
|
|
currentUsage: number
|
|
): Promise<{ withinLimit: boolean; remaining: number; limit: number }> {
|
|
const tierConfig = config.tiers[tier];
|
|
const limit = tierConfig.callMinutesLimit;
|
|
const remaining = Math.max(0, limit - currentUsage);
|
|
|
|
return {
|
|
withinLimit: currentUsage <= limit,
|
|
remaining,
|
|
limit,
|
|
};
|
|
}
|
|
|
|
async createInvoice(
|
|
customerId: string,
|
|
amount: number,
|
|
description: string,
|
|
metadata?: Record<string, string>
|
|
): Promise<Stripe.Invoice> {
|
|
const invoice = await stripe.invoices.create({
|
|
customer: customerId,
|
|
metadata: metadata,
|
|
});
|
|
|
|
await stripe.invoiceItems.create({
|
|
invoice: invoice.id,
|
|
customer: customerId,
|
|
price_data: {
|
|
currency: 'usd',
|
|
unit_amount: amount,
|
|
product: 'default_product',
|
|
},
|
|
description: description,
|
|
quantity: 1,
|
|
});
|
|
|
|
return await stripe.invoices.retrieve(invoice.id);
|
|
}
|
|
|
|
async handleWebhook(
|
|
sig: string,
|
|
body: Buffer
|
|
): Promise<Stripe.Event | null> {
|
|
const event = stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret);
|
|
|
|
const wasNew = await redis.setIfNotExists(
|
|
`stripe:event:${event.id}`,
|
|
'1',
|
|
IDEMPOTENCY_TTL_SECONDS
|
|
);
|
|
|
|
if (!wasNew) {
|
|
return null;
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
async getInvoiceHistory(customerId: string): Promise<Stripe.ApiList<Stripe.Invoice>> {
|
|
return await stripe.invoices.list({
|
|
customer: customerId,
|
|
limit: 100,
|
|
});
|
|
}
|
|
}
|