feat: add subscriptions service layer and webhook endpoint - M2 (refs #55)
- Implement SubscriptionsService with getSubscription, createSubscription, upgradeSubscription, cancelSubscription, reactivateSubscription - Add handleWebhookEvent for Stripe webhook processing with idempotency - Handle 5 webhook events: subscription.created/updated/deleted, invoice.payment_succeeded/failed - Auto-sync tier changes to user_profiles.subscription_tier - Add public webhook endpoint POST /api/webhooks/stripe (signature verified) - Implement 30-day grace period on payment failure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @ai-summary Webhook controller for Stripe events
|
||||
* @ai-context Handles incoming Stripe webhook events with signature verification
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { SubscriptionsService } from '../domain/subscriptions.service';
|
||||
import { SubscriptionsRepository } from '../data/subscriptions.repository';
|
||||
import { StripeClient } from '../external/stripe/stripe.client';
|
||||
import { pool } from '../../../core/config/database';
|
||||
|
||||
export class WebhooksController {
|
||||
private service: SubscriptionsService;
|
||||
|
||||
constructor() {
|
||||
const repository = new SubscriptionsRepository(pool);
|
||||
const stripeClient = new StripeClient();
|
||||
this.service = new SubscriptionsService(repository, stripeClient, pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Stripe webhook events
|
||||
* POST /api/webhooks/stripe
|
||||
*/
|
||||
async handleStripeWebhook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
// Get raw body from request (must be enabled via config: { rawBody: true })
|
||||
const rawBody = (request as any).rawBody;
|
||||
if (!rawBody) {
|
||||
logger.error('Missing raw body in webhook request');
|
||||
return reply.status(400).send({ error: 'Missing raw body' });
|
||||
}
|
||||
|
||||
// Get Stripe signature from headers
|
||||
const signature = request.headers['stripe-signature'];
|
||||
if (!signature || typeof signature !== 'string') {
|
||||
logger.error('Missing or invalid Stripe signature');
|
||||
return reply.status(400).send({ error: 'Missing Stripe signature' });
|
||||
}
|
||||
|
||||
// Process the webhook event
|
||||
await this.service.handleWebhookEvent(rawBody, signature);
|
||||
|
||||
// Return 200 to acknowledge receipt
|
||||
return reply.status(200).send({ received: true });
|
||||
} catch (error: any) {
|
||||
logger.error('Webhook handler error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
// Return 400 for signature verification failures
|
||||
if (error.message.includes('signature') || error.message.includes('verify')) {
|
||||
return reply.status(400).send({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
// Return 500 for other errors
|
||||
return reply.status(500).send({ error: 'Webhook processing failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/src/features/subscriptions/api/webhooks.routes.ts
Normal file
24
backend/src/features/subscriptions/api/webhooks.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary Webhook routes for Stripe events
|
||||
* @ai-context PUBLIC endpoint - no JWT auth, authenticated via Stripe signature
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
|
||||
export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new WebhooksController();
|
||||
|
||||
// POST /api/webhooks/stripe - PUBLIC endpoint (no JWT auth)
|
||||
// Stripe authenticates via webhook signature verification
|
||||
// IMPORTANT: rawBody MUST be enabled for signature verification to work
|
||||
fastify.post(
|
||||
'/webhooks/stripe',
|
||||
{
|
||||
config: {
|
||||
rawBody: true, // Enable raw body for Stripe signature verification
|
||||
},
|
||||
},
|
||||
controller.handleStripeWebhook.bind(controller)
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user