diff --git a/backend/app.ts b/backend/app.ts index c9f64d8..ba5b4a5 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -17,7 +17,8 @@ import contactRoutes from './routes/contactRoutes.js'; import fileUploadRoutes from './routes/fileUploadRoutes.js'; import pageContentRoutes from './routes/pageContentRoutes.js'; import mapRoutes from './routes/mapRoutes.js'; -import eventRoutes from './routes/eventRoutes.js'; +import stripeRoutes from './routes/stripeRoutes.js'; +import stripeWebhookRoutes from './routes/stripeWebhookRoutes.js'; import { clerkMiddleware } from '@clerk/express'; import { connectRedis } from './config/redis.js'; import { warmCache } from './utils/cacheWarmer.js'; @@ -49,6 +50,7 @@ app.use( credentials: true, }) ); +app.use('/api/stripe/webhook', stripeWebhookRoutes); app.use(express.json()); @@ -74,12 +76,11 @@ app.use('/api/contact', contactRoutes); app.use('/api/files', fileUploadRoutes); app.use('/api/page-content', pageContentRoutes); app.use('/api/map', mapRoutes); -app.use('/api/events', eventRoutes); +app.use('/api/stripe', stripeRoutes); // Add new route imports and register new routes // For Stripe webhook (MUST be before express.json() middleware): // Add this line BEFORE app.use(express.json()): -// app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }), stripeRoutes); export default app; diff --git a/backend/controllers/stripeController.ts b/backend/controllers/stripeController.ts index b78177f..ac82a6c 100644 --- a/backend/controllers/stripeController.ts +++ b/backend/controllers/stripeController.ts @@ -1,12 +1,12 @@ -import e, { Response } from 'express'; +import { Request, Response } from 'express'; +import Stripe from 'stripe'; import { AuthenticatedRequest } from '../types/index.js'; import { StripeService } from '../services/stripeService.js'; -// import { stripe } from '../config/stripe.js'; -// import { STRIPE_WEBHOOK_SECRET } from '../config/stripe.js'; +import { stripe, STRIPE_WEBHOOK_SECRET } from '../config/stripe.js'; export const stripeController = { /** - * Create subscription + * Create subscription: done */ createSubscription: async (req: AuthenticatedRequest, res: Response) => { try { @@ -31,7 +31,7 @@ export const stripeController = { }, /** - * Get subscription status + * Get subscription status: done */ getSubscription: async (req: AuthenticatedRequest, res: Response) => { try { @@ -70,14 +70,24 @@ export const stripeController = { /** * Stripe webhook handler */ - handleWebhook: async (req: AuthenticatedRequest, res: Response) => { + handleWebhook: async (req: Request, res: Response) => { + let event: Stripe.Event; try { - // Verify webhook signature - // Call StripeService.handleWebhook - res.status(501).json({ error: 'Not implemented' }); - } catch (error: any) { - console.error('Webhook error:', error); - res.status(400).json({ error: error.message }); + const sig = req.headers['stripe-signature'] as string; + event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET); + } catch (error) { + console.error('Error handling webhook:', error); + return res + .sendStatus(400) + .send(`Webhook Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + try { + await StripeService.handleWebhook(event); + res.status(200).json({ received: true }); + } catch (error) { + console.error('Error handling webhook event:', error); + return res.json({ received: true }); } }, diff --git a/backend/routes/stripeRoutes.ts b/backend/routes/stripeRoutes.ts new file mode 100644 index 0000000..6f4db55 --- /dev/null +++ b/backend/routes/stripeRoutes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import express from 'express'; +import { authenticateToken } from '../middleware/auth.js'; +import { stripeController } from '../controllers/stripeController.js'; +import { StripeService } from '../services/stripeService.js'; + +const router = Router(); + +// Protected routes +router.get('/subscription', authenticateToken, stripeController.getSubscription); +router.post('/subscription', authenticateToken, stripeController.createSubscription); +router.post('/subscription/cancel', authenticateToken, stripeController.cancelSubscription); + +// Public routes +router.post('/customer', StripeService.createCustomer); + +export default router; diff --git a/backend/routes/stripeWebhookRoutes.ts b/backend/routes/stripeWebhookRoutes.ts new file mode 100644 index 0000000..6357c1d --- /dev/null +++ b/backend/routes/stripeWebhookRoutes.ts @@ -0,0 +1,7 @@ +import { Router } from 'express'; +import express from 'express'; +import { stripeController } from '../controllers/stripeController.js'; + +const router = Router(); +router.post('/', express.raw({ type: 'application/json' }), stripeController.handleWebhook); +export default router; diff --git a/backend/services/stripeService.ts b/backend/services/stripeService.ts index c7eef95..567184c 100644 --- a/backend/services/stripeService.ts +++ b/backend/services/stripeService.ts @@ -1,4 +1,3 @@ -import { stripe } from '../config/stripe.js'; import { prisma } from '../config/prisma.js'; import Stripe from 'stripe'; import { SubscriptionStatus, PaymentStatus } from '@prisma/client'; @@ -40,7 +39,7 @@ export class StripeService { } } /** - * Create a Stripe customer for an organization + * Create a Stripe customer for an organization: done */ static async createCustomer(organizationId: string) { try { @@ -352,5 +351,120 @@ export class StripeService { /** * Handle webhook events */ - static async handleWebhook(event: Stripe.Event) {} + static async handleWebhook(event: Stripe.Event) { + switch (event.type) { + case 'customer.subscription.updated': + const subscription = event.data.object as Stripe.Subscription; + const validStatuses = ['ACTIVE', 'PAST_DUE', 'CANCELED', 'INCOMPLETE', 'TRIALING']; + const status = subscription.status.toUpperCase(); + await prisma.subscription.update({ + where: { stripeSubscriptionId: subscription.id }, + data: { + status: (validStatuses.includes(status) ? status : 'INCOMPLETE') as SubscriptionStatus, + currentPeriodStart: subscription.current_period_start + ? new Date(subscription.current_period_start * 1000) + : new Date(), + currentPeriodEnd: subscription.current_period_end + ? new Date(subscription.current_period_end * 1000) + : new Date(), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + }); + console.log(`Subscription info: ${JSON.stringify(subscription)}`); + console.log(`Subscription ${subscription.id} updated.`); + break; + case 'customer.subscription.deleted': + const deletedSubscription = event.data.object as Stripe.Subscription; + await prisma.subscription.update({ + where: { stripeSubscriptionId: deletedSubscription.id }, + data: { + status: 'CANCELED' as SubscriptionStatus, + cancelAtPeriodEnd: false, + }, + }); + console.log(`Subscription ${deletedSubscription.id} deleted.`); + break; + + case 'customer.subscription.created': + const createdSubscription = event.data.object as Stripe.Subscription; + await prisma.subscription.create({ + data: { + stripeSubscriptionId: createdSubscription.id, + stripeCustomerId: createdSubscription.customer as string, + stripePriceId: createdSubscription.items.data[0].price.id, + status: createdSubscription.status.toUpperCase() as SubscriptionStatus, + currentPeriodStart: createdSubscription.current_period_start + ? new Date(createdSubscription.current_period_start * 1000) + : new Date(), + currentPeriodEnd: createdSubscription.current_period_end + ? new Date(createdSubscription.current_period_end * 1000) + : new Date(), + cancelAtPeriodEnd: createdSubscription.cancel_at_period_end, + organization: { + connect: { id: createdSubscription.metadata.organizationId }, + }, + }, + }); + console.log(`Subscription ${createdSubscription.id} created.`); + break; + + case 'invoice.payment_succeeded': + const invoice = event.data.object as Stripe.Invoice; + if (!invoice.subscription) { + console.log(`Invoice ${invoice.id} has no subscription, skipping.`); + break; + } + await prisma.invoice.create({ + data: { + stripeInvoiceId: invoice.id, + amount: invoice.amount_paid, + currency: invoice.currency, + status: (invoice.status ?? 'unknown').toUpperCase(), + invoicePdf: invoice.invoice_pdf ?? null, + hostedInvoiceUrl: invoice.hosted_invoice_url ?? null, + periodStart: invoice.period_start ? new Date(invoice.period_start * 1000) : new Date(), + periodEnd: invoice.period_end ? new Date(invoice.period_end * 1000) : new Date(), + subscription: { + connect: { stripeSubscriptionId: invoice.subscription as string }, + }, + }, + }); + console.log(`Invoice ${invoice.id} payment succeeded.`); + break; + case 'invoice.payment_failed': + const failedInvoice = event.data.object as Stripe.Invoice; + if (!failedInvoice.subscription) { + console.log(`Invoice ${failedInvoice.id} has no subscription, skipping.`); + break; + } + await prisma.invoice.create({ + data: { + stripeInvoiceId: failedInvoice.id, + amount: failedInvoice.amount_due, + currency: failedInvoice.currency, + status: (failedInvoice.status ?? 'unknown').toUpperCase(), + invoicePdf: failedInvoice.invoice_pdf ?? null, + hostedInvoiceUrl: failedInvoice.hosted_invoice_url ?? null, + periodStart: failedInvoice.period_start + ? new Date(failedInvoice.period_start * 1000) + : new Date(), + periodEnd: failedInvoice.period_end + ? new Date(failedInvoice.period_end * 1000) + : new Date(), + subscription: { + connect: { stripeSubscriptionId: failedInvoice.subscription as string }, + }, + }, + }); + console.log(`Invoice ${failedInvoice.id} payment failed.`); + break; + case 'payment_intent.succeeded': + const paymentIntent = event.data.object as Stripe.PaymentIntent; + await StripeService.updateSubscriptionAfterPaymentConfirmed(paymentIntent.id); + console.log(`PaymentIntent ${paymentIntent.id} succeeded.`); + break; + default: + console.log(`Unhandled event type ${event.type}`); + } + } } diff --git a/frontend/src/pages/AboutPage/About.tsx b/frontend/src/pages/AboutPage/About.tsx index 22abc23..fc4de27 100644 --- a/frontend/src/pages/AboutPage/About.tsx +++ b/frontend/src/pages/AboutPage/About.tsx @@ -389,7 +389,7 @@ const AboutPage = ({ previewContent }: AboutPageProps = {}) => {
@@ -580,7 +580,7 @@ const RegisterForm = ({ previewContent }: RegisterFormProps = {}) => {