- Bestellung auf der Portal-Startseite: Zusammenfassung, AGB- und Vorab-Leistungs-Zustimmung, Button 'Zahlungspflichtig bestellen', danach Stripe Checkout (Abo, Kleinunternehmer-Fusszeile) - Stripe-Webhook /api/abo/webhook spiegelt Abos nach Appwrite (subscriptions) und schreibt je bezahlter Rechnung 60 min gut (hourCredits, idempotent ueber Invoice-ID, 12 Monate gueltig) - Stundenkonto: FIFO-Verbrauch aus Worksheets mit countsToPlan, 15-Minuten-Takt, Mehraufwand separat ausgewiesen - Stripe-Kundenportal fuer Zahlungsart/Rechnungen/Kuendigung - Oeffentlicher Kuendigungsbutton (§ 312k) und Widerrufsbutton (§ 356a) mit Eingangsbestaetigung per Mail - Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung, als Anhang der Vertragsbestaetigung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
15 KiB
JavaScript
357 lines
15 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,
|
||
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 publicSubscription(sub) {
|
||
if (!sub) return null
|
||
return {
|
||
status: sub.status,
|
||
active: ACTIVE_STATUSES.has(sub.status),
|
||
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
|
||
startedAt: sub.startedAt,
|
||
currentPeriodEnd: sub.currentPeriodEnd,
|
||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||
endedAt: sub.endedAt,
|
||
priceAmount: sub.priceAmount,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Kunde (eingeloggt)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Abo-Status + Stundenkonto fuer die Startseite. */
|
||
router.get('/', requireSession, async (req, res) => {
|
||
try {
|
||
const customerId = getSessionCustomerId(req)
|
||
const sub = await getCurrentSubscription(customerId)
|
||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||
return res.json({
|
||
available: isStripeConfigured(),
|
||
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, earlyStart, termsVersion } = req.body || {}
|
||
if (acceptTerms !== true || earlyStart !== true) {
|
||
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' })
|
||
}
|
||
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))
|
||
const now = new Date().toISOString()
|
||
const url = await createAboCheckout(customer, { termsAcceptedAt: now, earlyStartConsentAt: now })
|
||
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 terms = await readFile(path.join(publicDir, 'abo-bedingungen.html'), 'utf8').catch(() => null)
|
||
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)',
|
||
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`,
|
||
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar',
|
||
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,',
|
||
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)',
|
||
'',
|
||
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`,
|
||
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.',
|
||
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
|
||
'',
|
||
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang',
|
||
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`,
|
||
'',
|
||
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
|
||
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
|
||
'',
|
||
'Viele Grüße',
|
||
'Ihr Webklar-Team',
|
||
'',
|
||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · kenso.gri@gmail.com',
|
||
],
|
||
terms
|
||
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }]
|
||
: []
|
||
)
|
||
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: String(body?.effective || '').trim().slice(0, 40),
|
||
}
|
||
}
|
||
|
||
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
|
||
try {
|
||
sub = await findActiveSubscriptionByEmail(d.email)
|
||
if (sub) {
|
||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||
cancelRequestedAt: receivedAt.toISOString(),
|
||
})
|
||
}
|
||
} catch (err) {
|
||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||
}
|
||
|
||
const effectiveText = sub
|
||
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).`
|
||
: 'Wir ordnen Ihre Kündigung manuell 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 || '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}` : '',
|
||
sub
|
||
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).`
|
||
: '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
|