/** * Stripe Routes * Payment and subscription management */ import express from 'express' import Stripe from 'stripe' import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs' import { validate, rules } from '../middleware/validate.mjs' import { limiters } from '../middleware/rateLimit.mjs' import { respond } from '../utils/response.mjs' import { subscriptions, submissions } from '../services/database.mjs' import { config } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' const router = express.Router() const stripe = new Stripe(config.stripe.secretKey) /** * Plan configuration */ const PLANS = { basic: { name: 'Basic', priceId: config.stripe.prices.basic, features: { emailAccounts: 1, emailsPerDay: 500, historicalSync: false, customRules: false, prioritySupport: false, }, }, pro: { name: 'Pro', priceId: config.stripe.prices.pro, features: { emailAccounts: 3, emailsPerDay: -1, historicalSync: true, customRules: true, prioritySupport: false, }, }, business: { name: 'Business', priceId: config.stripe.prices.business, features: { emailAccounts: 10, emailsPerDay: -1, historicalSync: true, customRules: true, prioritySupport: true, }, }, } /** * POST /api/subscription/checkout * Create subscription checkout session */ router.post('/checkout', limiters.auth, validate({ body: { userId: [rules.required('userId')], plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])], }, }), asyncHandler(async (req, res) => { const { userId, plan, email } = req.body const planConfig = PLANS[plan] if (!planConfig) { throw new ValidationError('Ungültiger Plan', { plan: ['Ungültig'] }) } // Check for existing subscription const existing = await subscriptions.getByUser(userId) let customerId = existing?.stripeCustomerId // Create checkout session const sessionConfig = { mode: 'subscription', payment_method_types: ['card'], line_items: [ { price: planConfig.priceId, quantity: 1, }, ], success_url: `${config.frontendUrl}/setup?subscription=success&setup=auto`, cancel_url: `${config.frontendUrl}/pricing?subscription=cancelled`, metadata: { userId, plan, }, subscription_data: { trial_period_days: 14, metadata: { userId, plan }, }, allow_promotion_codes: true, } if (customerId) { sessionConfig.customer = customerId } else if (email) { sessionConfig.customer_email = email } const session = await stripe.checkout.sessions.create(sessionConfig) log.info(`Checkout Session erstellt für User ${userId}`, { plan }) respond.success(res, { url: session.url, sessionId: session.id, }) }) ) /** * GET /api/subscription/status * Get user's subscription status */ router.get('/status', asyncHandler(async (req, res) => { const { userId } = req.query if (!userId) { throw new ValidationError('userId ist erforderlich') } const sub = await subscriptions.getByUser(userId) if (!sub) { // No subscription - return trial info return respond.success(res, { status: 'trial', plan: 'pro', features: PLANS.pro.features, trialEndsAt: null, // Would calculate from user creation date cancelAtPeriodEnd: false, }) } respond.success(res, { status: sub.status, plan: sub.plan, features: PLANS[sub.plan]?.features || PLANS.basic.features, currentPeriodEnd: sub.currentPeriodEnd, cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false, }) })) /** * POST /api/subscription/portal * Create Stripe Customer Portal session */ router.post('/portal', validate({ body: { userId: [rules.required('userId')], }, }), asyncHandler(async (req, res) => { const { userId } = req.body const sub = await subscriptions.getByUser(userId) if (!sub?.stripeCustomerId) { throw new NotFoundError('Subscription') } const session = await stripe.billingPortal.sessions.create({ customer: sub.stripeCustomerId, return_url: `${config.frontendUrl}/settings`, }) respond.success(res, { url: session.url }) }) ) /** * POST /api/subscription/cancel * Cancel subscription at period end */ router.post('/cancel', validate({ body: { userId: [rules.required('userId')], }, }), asyncHandler(async (req, res) => { const { userId } = req.body const sub = await subscriptions.getByUser(userId) if (!sub?.stripeSubscriptionId) { throw new NotFoundError('Subscription') } await stripe.subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: true, }) await subscriptions.update(sub.$id, { cancelAtPeriodEnd: true }) log.info(`Subscription gekündigt für User ${userId}`) respond.success(res, null, 'Subscription wird zum Ende der Periode gekündigt') }) ) /** * POST /api/subscription/reactivate * Reactivate cancelled subscription */ router.post('/reactivate', validate({ body: { userId: [rules.required('userId')], }, }), asyncHandler(async (req, res) => { const { userId } = req.body const sub = await subscriptions.getByUser(userId) if (!sub?.stripeSubscriptionId) { throw new NotFoundError('Subscription') } await stripe.subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: false, }) await subscriptions.update(sub.$id, { cancelAtPeriodEnd: false }) log.info(`Subscription reaktiviert für User ${userId}`) respond.success(res, null, 'Subscription wurde reaktiviert') }) ) /** * POST /stripe/webhook & POST /api/subscription/webhook * Stripe webhook handler */ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(async (req, res) => { const sig = req.headers['stripe-signature'] let event try { event = stripe.webhooks.constructEvent( req.body, sig, config.stripe.webhookSecret ) } catch (err) { log.error('Webhook Signatur Fehler', { error: err.message }) return res.status(400).send(`Webhook Error: ${err.message}`) } log.info(`Stripe Webhook: ${event.type}`) try { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object const { userId, plan } = session.metadata || {} if (userId && session.subscription) { const subscription = await stripe.subscriptions.retrieve(session.subscription) await subscriptions.upsertByUser(userId, { stripeCustomerId: session.customer, stripeSubscriptionId: session.subscription, plan: plan || 'basic', status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(), cancelAtPeriodEnd: subscription.cancel_at_period_end, }) log.success(`Subscription erstellt für User ${userId}`, { plan }) } // Handle one-time payment (legacy) if (session.metadata?.submissionId) { await submissions.updateStatus(session.metadata.submissionId, 'paid') log.success(`Zahlung abgeschlossen: ${session.metadata.submissionId}`) } break } case 'customer.subscription.updated': { const subscription = event.data.object const sub = await subscriptions.getByStripeId(subscription.id) if (sub) { await subscriptions.update(sub.$id, { status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(), cancelAtPeriodEnd: subscription.cancel_at_period_end, }) log.info(`Subscription aktualisiert: ${subscription.id}`) } break } case 'customer.subscription.deleted': { const subscription = event.data.object const sub = await subscriptions.getByStripeId(subscription.id) if (sub) { await subscriptions.update(sub.$id, { status: 'cancelled', }) log.info(`Subscription gelöscht: ${subscription.id}`) } break } case 'invoice.payment_failed': { const invoice = event.data.object log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, { customer: invoice.customer, }) // TODO: Send notification email break } case 'invoice.payment_succeeded': { const invoice = event.data.object log.success(`Zahlung erfolgreich: ${invoice.id}`) break } default: log.debug(`Unbehandelter Webhook: ${event.type}`) } res.json({ received: true }) } catch (err) { log.error('Webhook Handler Fehler', { error: err.message, event: event.type }) res.status(500).json({ error: 'Webhook handler failed' }) } })) export default router