Files
Kordant/packages/shared-billing/src/services/billing.service.ts
Michael Freno 9f65ebce5d FRE-5398: Fix invoice endpoint customer IDOR (M-3)
- 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>
2026-05-16 09:57:57 -04:00

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,
});
}
}