Files
Emailsorter/server/routes/stripe.mjs
ANDJ 89bc86b615 Try
dfssdfsfdsf
2026-04-09 21:00:04 +02:00

497 lines
14 KiB
JavaScript

/**
* 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, isAdmin } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
import { requireAuth } from '../middleware/auth.mjs'
import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs'
import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.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
* (Also registered on app in index.mjs before router mount.)
*/
export async function handleGetSubscriptionStatus(req, res) {
const userId = req.appwriteUser.id
if (isAdmin(req.appwriteUser.email)) {
return respond.success(res, {
status: 'active',
plan: 'business',
planDisplayName: 'Business (Admin)',
isFreeTier: false,
emailsUsedThisMonth: 0,
emailsLimit: 999999,
features: {
emailAccounts: 999,
emailsPerDay: 999999,
historicalSync: true,
customRules: true,
prioritySupport: true,
},
})
}
try {
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
// No subscription document yet (synthetic free tier from DB layer) — safe defaults, not 404
if (!sub?.$id && sub?.plan === 'free') {
return respond.success(res, {
status: 'free',
plan: 'free',
planDisplayName: PLAN_DISPLAY_NAMES.free,
isFreeTier: true,
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
emailsLimit: sub.emailsLimit ?? 500,
features: {
emailAccounts: 1,
emailsPerDay: 50,
historicalSync: false,
customRules: false,
prioritySupport: false,
},
})
}
const topKey = config.topSubscriptionPlan
const plan = sub.plan || topKey
const features =
PLANS[plan]?.features ||
PLANS[topKey]?.features ||
PLANS.business.features
return 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),
})
} catch (err) {
if (isAppwriteCollectionMissing(err)) {
return respond.success(res, {
status: 'free',
plan: 'free',
isFreeTier: true,
emailsUsedThisMonth: 0,
emailsLimit: 50,
features: {
emailAccounts: 1,
emailsPerDay: 50,
historicalSync: false,
customRules: false,
prioritySupport: false,
},
})
}
throw err
}
}
router.get('/status', asyncHandler(handleGetSubscriptionStatus))
/**
* 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