412 lines
18 KiB
JavaScript
412 lines
18 KiB
JavaScript
import { readFile } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { Router } from 'express'
|
||
import { config } from '../config.js'
|
||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||
import { getLedger } from '../services/hourLedger.js'
|
||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||
import {
|
||
ACTIVE_STATUSES,
|
||
cancelAtPeriodEnd,
|
||
cancelSubscriptionAt,
|
||
constructWebhookEvent,
|
||
createAboCheckout,
|
||
createBillingPortalUrl,
|
||
getCurrentSubscription,
|
||
grantCreditForInvoice,
|
||
isStripeConfigured,
|
||
syncSubscription,
|
||
} from '../services/stripeBilling.js'
|
||
|
||
const router = Router()
|
||
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
||
|
||
const fmtDate = (iso) =>
|
||
iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '–'
|
||
const fmtDateTime = (d) =>
|
||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||
|
||
function addDays(iso, days) {
|
||
const d = new Date(iso)
|
||
d.setUTCDate(d.getUTCDate() + days)
|
||
return d
|
||
}
|
||
|
||
function publicSubscription(sub) {
|
||
if (!sub) return null
|
||
const active = ACTIVE_STATUSES.has(sub.status)
|
||
const withdrawalUntil = sub.startedAt ? addDays(sub.startedAt, config.abo.withdrawalDays) : null
|
||
return {
|
||
status: sub.status,
|
||
active,
|
||
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
|
||
startedAt: sub.startedAt,
|
||
currentPeriodEnd: sub.currentPeriodEnd,
|
||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||
endedAt: sub.endedAt,
|
||
priceAmount: sub.priceAmount,
|
||
startChoice: sub.startChoice || null,
|
||
// Beginn nach Ablauf der Widerrufsfrist: Leistung startet mit Ende der Probezeit
|
||
serviceStartsAt: sub.status === 'trialing' ? sub.trialEnd : null,
|
||
// Widerrufsbutton im Portal nur waehrend der Frist hervorheben
|
||
withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()),
|
||
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
|
||
}
|
||
}
|
||
|
||
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb)
|
||
function isPilotAllowed(customer) {
|
||
const list = config.abo.pilotEmails
|
||
if (!list.length || list.includes('*')) return true
|
||
return list.includes(String(customer?.email || '').toLowerCase())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Kunde (eingeloggt)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Abo-Status + Stundenkonto fuer die Startseite. */
|
||
router.get('/', requireSession, async (req, res) => {
|
||
try {
|
||
const customerId = getSessionCustomerId(req)
|
||
const [sub, customer] = await Promise.all([
|
||
getCurrentSubscription(customerId),
|
||
getDocument(config.collections.customers, customerId).catch(() => null),
|
||
])
|
||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||
return res.json({
|
||
available: isStripeConfigured() && isPilotAllowed(customer),
|
||
termsVersion: config.abo.termsVersion,
|
||
subscription: publicSubscription(sub),
|
||
ledger,
|
||
})
|
||
} catch (err) {
|
||
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||
}
|
||
})
|
||
|
||
/** Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart). */
|
||
router.post('/checkout', requireSession, async (req, res) => {
|
||
try {
|
||
if (req.session.viewAs) {
|
||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
||
}
|
||
const { acceptTerms, startChoice, termsVersion } = req.body || {}
|
||
if (acceptTerms !== true) {
|
||
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
|
||
}
|
||
if (startChoice !== 'sofort' && startChoice !== 'nach_frist') {
|
||
return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' })
|
||
}
|
||
if (termsVersion !== config.abo.termsVersion) {
|
||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||
}
|
||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||
if (!isStripeConfigured() || !isPilotAllowed(customer)) {
|
||
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
|
||
}
|
||
// Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser
|
||
const now = new Date().toISOString()
|
||
const url = await createAboCheckout(customer, {
|
||
termsAcceptedAt: now,
|
||
startChoice,
|
||
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
|
||
consentIp: req.ip,
|
||
consentUserAgent: req.get('user-agent') || '',
|
||
})
|
||
return res.json({ url })
|
||
} catch (err) {
|
||
console.error('[abo] Checkout fehlgeschlagen:', err.message)
|
||
return res.status(err.status || 500).json({ error: err.message || 'Bestellung fehlgeschlagen' })
|
||
}
|
||
})
|
||
|
||
/** Stripe-Kundenportal: Zahlungsart, Rechnungen, Kuendigung. */
|
||
router.post('/portal', requireSession, async (req, res) => {
|
||
try {
|
||
if (req.session.viewAs) {
|
||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
||
}
|
||
const url = await createBillingPortalUrl(getSessionCustomerId(req))
|
||
return res.json({ url })
|
||
} catch (err) {
|
||
return res.status(err.status || 500).json({ error: err.message || 'Portal nicht verfügbar' })
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stripe-Webhook
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function sendContractConfirmation(subDoc) {
|
||
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
||
const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
||
const pdf = await readFile(pdfPath).catch(() => null)
|
||
const lateStart = subDoc.startChoice === 'nach_frist'
|
||
const consentLine = lateStart
|
||
? `Sie haben gewählt, dass wir erst nach Ablauf der Widerrufsfrist beginnen. Leistungsbeginn und erste Abbuchung: ${fmtDate(subDoc.trialEnd)}. Bis dahin erbringen wir keine Leistungen und buchen nichts ab.`
|
||
: 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahlen.'
|
||
await sendCustomerMail(
|
||
subDoc.email,
|
||
'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung',
|
||
[
|
||
'Guten Tag,',
|
||
'',
|
||
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
|
||
'',
|
||
' Vertrag: WEBklar Wartungs-Abo',
|
||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
||
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
|
||
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
|
||
' Laufzeit: unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar',
|
||
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
|
||
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
|
||
'',
|
||
`Ihre Zustimmung zu den Vertragsbedingungen (Version ${subDoc.termsVersion || config.abo.termsVersion}) haben wir am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} erhalten.`,
|
||
consentLine,
|
||
'',
|
||
'Im Anhang finden Sie die vollständigen Vertragsunterlagen als PDF: Vertragsbedingungen mit Leistungsbeschreibung,',
|
||
'Widerrufsbelehrung mit Muster-Widerrufsformular und Auftragsverarbeitungsvertrag.',
|
||
'',
|
||
`Ihr Stundenkontingent sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
|
||
`Vertrag widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
|
||
`Verträge hier kündigen: ${config.publicBaseUrl}/kuendigen.html`,
|
||
'',
|
||
'Viele Grüße',
|
||
'Ihr WEBklar-Team',
|
||
'',
|
||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com',
|
||
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
|
||
],
|
||
pdf
|
||
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
|
||
: []
|
||
)
|
||
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
||
confirmationSentAt: new Date().toISOString(),
|
||
})
|
||
}
|
||
|
||
async function handleStripeEvent(event) {
|
||
const obj = event.data.object
|
||
switch (event.type) {
|
||
case 'checkout.session.completed': {
|
||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||
const sub = await syncSubscription(obj.subscription)
|
||
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
|
||
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
|
||
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
|
||
`Stripe-Abo: ${obj.subscription}`,
|
||
])
|
||
await sendContractConfirmation(sub).catch((e) =>
|
||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||
)
|
||
return
|
||
}
|
||
case 'invoice.paid': {
|
||
const result = await grantCreditForInvoice(obj)
|
||
if (result.granted) {
|
||
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
|
||
}
|
||
return
|
||
}
|
||
case 'invoice.payment_failed': {
|
||
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
|
||
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
|
||
await sendAdminNotice(`Zahlung fehlgeschlagen: ${sub?.email || obj.customer_email || ''}`, [
|
||
`Rechnung ${obj.number || obj.id}, Betrag ${(obj.amount_due / 100).toFixed(2)} €.`,
|
||
'Stripe versucht die Zahlung automatisch erneut und mahnt per E-Mail.',
|
||
])
|
||
return
|
||
}
|
||
case 'customer.subscription.created':
|
||
case 'customer.subscription.updated':
|
||
await syncSubscription(obj.id)
|
||
return
|
||
case 'customer.subscription.deleted': {
|
||
const sub = await syncSubscription(obj.id)
|
||
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
||
`Stripe-Abo ${obj.id} ist beendet (Status ${obj.status}). Restguthaben verfällt.`,
|
||
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
|
||
])
|
||
return
|
||
}
|
||
default:
|
||
}
|
||
}
|
||
|
||
router.post('/webhook', async (req, res) => {
|
||
let event
|
||
try {
|
||
event = constructWebhookEvent(req.rawBody, req.get('stripe-signature'))
|
||
} catch (err) {
|
||
console.warn('[abo] Webhook-Signatur ungueltig:', err.message)
|
||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||
}
|
||
try {
|
||
await handleStripeEvent(event)
|
||
return res.json({ received: true })
|
||
} catch (err) {
|
||
// 500 -> Stripe wiederholt das Event (Handler sind idempotent)
|
||
console.error(`[abo] Webhook ${event.type} fehlgeschlagen:`, err.message)
|
||
return res.status(500).json({ error: 'processing failed' })
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Oeffentlich: Kuendigungsbutton (§ 312k BGB) + Widerrufsbutton (§ 356a BGB)
|
||
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const recentRequests = new Map() // ip -> [timestamps]
|
||
function rateLimited(ip) {
|
||
const now = Date.now()
|
||
const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000)
|
||
list.push(now)
|
||
recentRequests.set(ip, list)
|
||
return list.length > 10
|
||
}
|
||
|
||
function validDeclaration(body) {
|
||
const name = String(body?.name || '').trim().slice(0, 200)
|
||
const email = String(body?.email || '').trim().slice(0, 255)
|
||
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null
|
||
return {
|
||
name,
|
||
email,
|
||
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
|
||
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
|
||
reason: String(body?.reason || '').trim().slice(0, 2000),
|
||
effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '',
|
||
}
|
||
}
|
||
|
||
async function findActiveSubscriptionByEmail(email) {
|
||
const customer =
|
||
(await getCustomerByEmail(email).catch(() => null)) ||
|
||
(await getCustomerByEmail(email.toLowerCase()).catch(() => null))
|
||
if (!customer) return null
|
||
const sub = await getCurrentSubscription(customer.$id)
|
||
return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null
|
||
}
|
||
|
||
router.post('/public/cancel', async (req, res) => {
|
||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||
const d = validDeclaration(req.body)
|
||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||
const receivedAt = new Date()
|
||
|
||
let sub = null
|
||
let effectiveAt = null
|
||
try {
|
||
sub = await findActiveSubscriptionByEmail(d.email)
|
||
if (sub && d.kind === 'ordentlich') {
|
||
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
|
||
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested)
|
||
sub = result.subscription
|
||
effectiveAt = result.effectiveAt
|
||
}
|
||
if (sub) {
|
||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||
cancelRequestedAt: receivedAt.toISOString(),
|
||
})
|
||
}
|
||
} catch (err) {
|
||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||
}
|
||
|
||
const effectiveText = effectiveAt
|
||
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)} (Ende des Abrechnungsmonats).`
|
||
: d.kind === 'ausserordentlich'
|
||
? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.'
|
||
: 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
|
||
|
||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
|
||
`Guten Tag ${d.name},`,
|
||
'',
|
||
`wir haben Ihre ${d.kind === 'ausserordentlich' ? 'außerordentliche' : 'ordentliche'} Kündigung am ${fmtDateTime(receivedAt)} erhalten.`,
|
||
'',
|
||
` Name: ${d.name}`,
|
||
` E-Mail: ${d.email}`,
|
||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||
' Vertrag: Webklar Wartungs-Abo',
|
||
` Gewünschter Zeitpunkt: ${d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt'}`,
|
||
d.reason ? ` Grund: ${d.reason}` : null,
|
||
'',
|
||
effectiveText,
|
||
'Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und',
|
||
'Ihr Postfach stellen wir Ihnen auf Wunsch gemäß Ziff. 9 der Abo-Bedingungen zur Verfügung.',
|
||
'',
|
||
'Viele Grüße',
|
||
'Ihr Webklar-Team',
|
||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||
|
||
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
|
||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
|
||
d.reason ? `Grund: ${d.reason}` : '',
|
||
effectiveAt
|
||
? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
|
||
: sub
|
||
? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!`
|
||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
|
||
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
|
||
])
|
||
|
||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||
})
|
||
|
||
router.post('/public/withdraw', async (req, res) => {
|
||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||
const d = validDeclaration(req.body)
|
||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||
const receivedAt = new Date()
|
||
|
||
let sub = null
|
||
try {
|
||
sub = await findActiveSubscriptionByEmail(d.email)
|
||
if (sub) {
|
||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||
withdrawalRequestedAt: receivedAt.toISOString(),
|
||
})
|
||
}
|
||
} catch (err) {
|
||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||
}
|
||
|
||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihres Widerrufs – Webklar Wartungs-Abo', [
|
||
`Guten Tag ${d.name},`,
|
||
'',
|
||
`wir haben Ihren Widerruf des Vertrags „Webklar Wartungs-Abo" am ${fmtDateTime(receivedAt)} erhalten.`,
|
||
'',
|
||
` Name: ${d.name}`,
|
||
` E-Mail: ${d.email}`,
|
||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||
'',
|
||
'Es erfolgen keine weiteren Abbuchungen. Bereits gezahlte Beträge erstatten wir innerhalb von 14 Tagen',
|
||
'über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf',
|
||
'erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.',
|
||
'',
|
||
'Viele Grüße',
|
||
'Ihr Webklar-Team',
|
||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||
|
||
await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [
|
||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||
sub
|
||
? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.`
|
||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!',
|
||
'TODO: Widerrufsfrist prüfen (14 Tage ab Vertragsschluss), Abo in Stripe sofort beenden,',
|
||
'Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen erstatten (Stripe -> Rückerstattung).',
|
||
])
|
||
|
||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||
})
|
||
|
||
export default router
|