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

@@ -0,0 +1,408 @@
import { FastifyInstance } from 'fastify';
import { BillingService } from '@shieldai/shared-billing/src/services/billing.service';
import { SubscriptionService, customerService, webhookService } from '@shieldai/shared-billing/src/services/billing.services';
import { SubscriptionTier } from '@shieldai/shared-billing/src/config/billing.config';
import { AuthRequest } from './auth.middleware';
const billingService = BillingService.getInstance();
const subscriptionService = new SubscriptionService();
export async function subscriptionRoutes(fastify: FastifyInstance) {
// Get current user's subscription
fastify.get(
'/subscription',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
try {
const subscription = await billingService.getUserSubscription(authReq.user!.id);
if (!subscription) {
return reply.status(404).send({
error: 'No active subscription found',
message: 'Please create a subscription to access premium features',
});
}
return {
subscription: {
id: subscription.id,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000).toISOString(),
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
created: new Date(subscription.created * 1000).toISOString(),
},
customer: {
id: subscription.customer as string,
},
};
} catch (error) {
reply.status(500).send({
error: 'Failed to fetch subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Create a new subscription (for mobile app in-app purchases)
fastify.post(
'/subscription/create',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { tier, customerId } = request.body as { tier: SubscriptionTier; customerId: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!tier || !customerId) {
return reply.status(400).send({
error: 'Missing required fields',
required: ['tier', 'customerId'],
});
}
try {
const result = await billingService.createSubscription(
authReq.user.id,
tier,
customerId
);
return {
subscription: {
id: result.subscription.id,
status: result.subscription.status,
currentPeriodStart: new Date(result.subscription.current_period_start * 1000).toISOString(),
currentPeriodEnd: new Date(result.subscription.current_period_end * 1000).toISOString(),
},
customer: {
id: result.customer.id,
email: result.customer.email,
},
};
} catch (error) {
reply.status(500).send({
error: 'Failed to create subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Update subscription tier
fastify.put(
'/subscription/:subscriptionId/tier',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { subscriptionId } = request.params as { subscriptionId: string };
const { tier } = request.body as { tier: SubscriptionTier };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!tier) {
return reply.status(400).send({
error: 'Missing required field',
required: ['tier'],
});
}
try {
const updated = await billingService.updateSubscription(
subscriptionId,
authReq.user.id,
tier
);
return {
subscription: {
id: updated.id,
status: updated.status,
currentPeriodStart: new Date(updated.current_period_start * 1000).toISOString(),
currentPeriodEnd: new Date(updated.current_period_end * 1000).toISOString(),
},
message: 'Subscription updated successfully',
};
} catch (error) {
reply.status(500).send({
error: 'Failed to update subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Cancel subscription
fastify.delete(
'/subscription/:subscriptionId',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { subscriptionId } = request.params as { subscriptionId: string };
const { cancelAtPeriodEnd } = request.body as { cancelAtPeriodEnd?: boolean };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
try {
const cancelled = await billingService.cancelSubscription(
subscriptionId,
authReq.user.id,
cancelAtPeriodEnd ?? true
);
return {
subscription: {
id: cancelled.id,
status: cancelled.status,
cancelAtPeriodEnd: cancelled.cancel_at_period_end,
canceledAt: cancelled.canceled_at ? new Date(cancelled.canceled_at * 1000).toISOString() : null,
},
message: cancelAtPeriodEnd
? 'Subscription will cancel at the end of the billing period'
: 'Subscription cancelled immediately',
};
} catch (error) {
reply.status(500).send({
error: 'Failed to cancel subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Create customer portal session
fastify.post(
'/customer/portal',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { customerId, returnUrl } = request.body as { customerId: string; returnUrl: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!customerId || !returnUrl) {
return reply.status(400).send({
error: 'Missing required fields',
required: ['customerId', 'returnUrl'],
});
}
try {
const portalSession = await billingService.createCustomerPortalSession(
customerId,
returnUrl
);
return {
url: portalSession.url,
expiresAt: new Date(portalSession.expires_at * 1000).toISOString(),
};
} catch (error) {
reply.status(500).send({
error: 'Failed to create portal session',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Create customer
fastify.post(
'/customer',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { email, name } = request.body as { email: string; name?: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!email) {
return reply.status(400).send({
error: 'Email is required',
});
}
try {
const customer = await billingService.createCustomer(email, authReq.user.id);
return {
customer: {
id: customer.id,
email: customer.email,
name: customer.name,
metadata: customer.metadata,
},
};
} catch (error) {
reply.status(500).send({
error: 'Failed to create customer',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Get user tier
fastify.get(
'/user/tier',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
try {
const tier = await billingService.getUserTier(authReq.user.id);
if (!tier) {
return {
tier: 'free' as SubscriptionTier,
limits: await billingService.getTierLimits('free'),
};
}
const limits = await billingService.getTierLimits(tier);
return {
tier,
limits,
};
} catch (error) {
reply.status(500).send({
error: 'Failed to fetch user tier',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Get invoice history
fastify.get(
'/invoices',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { customerId } = request.query as { customerId?: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!customerId) {
return reply.status(400).send({
error: 'customerId is required',
});
}
try {
const invoices = await billingService.getInvoiceHistory(customerId);
return {
invoices: invoices.data.map((invoice) => ({
id: invoice.id,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
status: invoice.status,
created: new Date(invoice.created * 1000).toISOString(),
hostedInvoiceUrl: invoice.hosted_invoice_url,
})),
hasMore: invoices.has_more,
};
} catch (error) {
reply.status(500).send({
error: 'Failed to fetch invoice history',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Webhook handler (public endpoint)
fastify.post(
'/webhooks/stripe',
{
// Skip authentication for webhooks
preHandler: async (request, reply) => {
// Don't require auth for webhooks
},
},
async (request, reply) => {
const sig = request.headers['stripe-signature'] as string;
if (!sig) {
return reply.status(400).send({
error: 'Missing Stripe signature',
});
}
try {
const event = await billingService.handleWebhook(sig, request.rawBody as Buffer);
if (!event) {
return reply.status(200).send({
received: true,
message: 'Event already processed',
});
}
// Handle different event types using webhook service
await webhookService.handleWebhook(event);
return { received: true };
} catch (error) {
console.error('Webhook error:', error);
return reply.status(400).send({
error: 'Webhook handler failed',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
}