FRE-5348: Fix P1 billing issues

- Add null check for subscription items in updateSubscription
- Implement webhook handlers with Prisma DB persistence
- cancelSubscription already correctly passes cancel_at_period_end

All P1 issues validated and fixed. Ready for Security Review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-15 14:18:35 -04:00
parent 7ed1a340b9
commit cba5390309
3 changed files with 511 additions and 8 deletions

View File

@@ -152,11 +152,19 @@ export class BillingService {
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: subscription.items.data[0]?.id,
id: firstItem.id,
price: newTierConfig.priceId,
},
],

View File

@@ -1,5 +1,10 @@
import { stripe, SubscriptionTier, tierConfig } from '../config/billing.config';
import Stripe from 'stripe';
import { loadBillingConfig, SubscriptionTier } from '../config/billing.config';
import { z } from 'zod';
import { prisma } from '@shieldsai/shared-db';
const config = loadBillingConfig();
const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2023-10-16' });
// Subscription service
export class SubscriptionService {
@@ -11,7 +16,7 @@ export class SubscriptionService {
tier: SubscriptionTier,
metadata?: Record<string, string>
): Promise<Stripe.Subscription> {
const priceId = tierConfig[tier].priceId;
const priceId = config.tiers[tier].priceId;
const subscription = await stripe.subscriptions.create({
customer: customerId,
@@ -30,7 +35,7 @@ export class SubscriptionService {
subscriptionId: string,
newTier: SubscriptionTier
): Promise<Stripe.Subscription> {
const newPriceId = tierConfig[newTier].priceId;
const newPriceId = config.tiers[newTier].priceId;
const subscription = await stripe.subscriptions.update(subscriptionId, {
items: [
@@ -198,22 +203,104 @@ export class WebhookService {
private async handleSubscriptionChange(subscription: Stripe.Subscription) {
console.log(`Subscription ${subscription.id} changed to ${subscription.status}`);
// TODO: Update local database
const userId = subscription.metadata?.userId;
if (!userId) {
console.warn(`Subscription ${subscription.id} missing userId in metadata`);
return;
}
const prismaStatus = this.mapStripeStatusToPrisma(subscription.status);
await prisma.subscription.upsert({
where: {
stripeId: subscription.id,
},
update: {
userId,
tier: subscription.metadata?.tier as any || 'basic',
status: prismaStatus,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
create: {
stripeId: subscription.id,
userId,
tier: subscription.metadata?.tier as any || 'basic',
status: prismaStatus,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end || false,
},
});
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
console.log(`Subscription ${subscription.id} deleted`);
// TODO: Update local database
await prisma.subscription.updateMany({
where: {
stripeId: subscription.id,
},
data: {
status: 'canceled',
},
});
}
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
console.log(`Payment succeeded for invoice ${invoice.id}`);
// TODO: Update usage tracking
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const userId = subscription.metadata?.userId;
if (!userId) return;
await prisma.subscription.updateMany({
where: {
stripeId: subscriptionId,
},
data: {
currentPeriodStart: new Date(invoice.period_start * 1000),
currentPeriodEnd: new Date(invoice.period_end * 1000),
},
});
}
private async handlePaymentFailed(invoice: Stripe.Invoice) {
console.log(`Payment failed for invoice ${invoice.id}`);
// TODO: Send notification to customer
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) return;
await prisma.subscription.updateMany({
where: {
stripeId: subscriptionId,
},
data: {
status: 'past_due',
},
});
}
private mapStripeStatusToPrisma(status: string): 'active' | 'past_due' | 'canceled' | 'unpaid' | 'trialing' {
switch (status) {
case 'active':
return 'active';
case 'canceled':
return 'canceled';
case 'past_due':
return 'past_due';
case 'unpaid':
return 'unpaid';
case 'trialing':
return 'trialing';
default:
return 'past_due';
}
}
}