497 lines
14 KiB
JavaScript
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
|