/** * Stripe Routes * Payment and subscription management */ import express from 'express' import Stripe from 'stripe' import { Client, Users } from 'node-appwrite' 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' import { requireAuth } from '../middleware/auth.mjs' import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs' const router = express.Router() async function resolveUserEmail(userId, stripeCustomerId) { if (userId) { try { const c = new Client() .setEndpoint(config.appwrite.endpoint) .setProject(config.appwrite.projectId) .setKey(config.appwrite.apiKey) const u = await new Users(c).get(userId) if (u.email) return u.email } catch (e) { log.warn('Appwrite Users.get failed', { userId, error: e.message }) } } if (stripeCustomerId) { try { const cust = await stripe.customers.retrieve(String(stripeCustomerId)) if (cust && !cust.deleted && cust.email) return cust.email } catch (e) { log.warn('Stripe customer retrieve failed', { error: e.message }) } } return null } const stripe = new Stripe(config.stripe.secretKey) function requireAuthUnlessStripeWebhook(req, res, next) { if (req.path === '/webhook' && req.method === 'POST') { return next() } return requireAuth(req, res, next) } router.use(requireAuthUnlessStripeWebhook) /** * Plan configuration */ const PLAN_DISPLAY_NAMES = { basic: 'Basic', pro: 'Pro', business: 'Business', free: 'Free', } 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: { plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])], }, }), asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { 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, req.appwriteUser?.email) 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.appwriteUser.id const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) const topKey = config.topSubscriptionPlan const plan = sub.plan || topKey const features = PLANS[plan]?.features || PLANS[topKey]?.features || PLANS.business.features respond.success(res, { status: sub.status || 'active', plan, planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business', isFreeTier: Boolean(sub.isFreeTier), emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0, emailsLimit: sub.emailsLimit ?? -1, features, currentPeriodEnd: sub.currentPeriodEnd, cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd), }) })) /** * POST /api/subscription/portal * Create Stripe Customer Portal session */ router.post('/portal', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) 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', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) 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', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) 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}`) try { const to = await resolveUserEmail(sub.userId, subscription.customer) if (to) { const plan = subscription.metadata?.plan || sub.plan || 'current' const periodEnd = subscription.current_period_end ? new Date(subscription.current_period_end * 1000).toISOString() : '' const tpl = loadEmailTemplate('subscription-updated') const text = renderTemplate(tpl, { plan: String(plan), status: String(subscription.status || ''), periodEndLine: periodEnd ? `Current period ends: ${periodEnd}` : '', }) await sendPlainEmail({ to, subject: 'MailFlow — Subscription updated', text, }) } } catch (e) { log.warn('subscription.updated email skipped', { error: e.message }) } } 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}`) try { const to = await resolveUserEmail(sub.userId, subscription.customer) if (to) { const tpl = loadEmailTemplate('subscription-ended') const text = renderTemplate(tpl, { endedDate: new Date().toISOString(), }) await sendPlainEmail({ to, subject: 'MailFlow — Your subscription has ended', text, }) } } catch (e) { log.warn('subscription.deleted email skipped', { error: e.message }) } } break } case 'invoice.payment_failed': { const invoice = event.data.object log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, { customer: invoice.customer, }) try { let metaUserId if (invoice.subscription) { const subStripe = await stripe.subscriptions.retrieve(invoice.subscription) metaUserId = subStripe.metadata?.userId } const to = await resolveUserEmail(metaUserId, invoice.customer) if (to) { const tpl = loadEmailTemplate('payment-failed') const text = renderTemplate(tpl, { invoiceId: String(invoice.id || ''), }) await sendPlainEmail({ to, subject: 'MailFlow — Payment failed, please update billing', text, }) } } catch (e) { log.warn('invoice.payment_failed email skipped', { error: e.message }) } 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