Alle 22 automatischen Mails gehen jetzt als HTML nach den Vorlagen aus
server/mail-templates raus, dazu eine aus dem HTML erzeugte Textfassung
(multipart/alternative). Werte werden HTML-sicher eingesetzt, optionale
Blöcke (WENN/ENDE) je nach Fall ein- oder ausgeblendet.
- Neu: services/mailTemplates.js (Vorlagen lesen, Platzhalter, Blöcke,
Textfassung); mailer.js nimmt { template, values, blocks }.
- K11 "Passwort geändert" war nicht im Design-Export: aus den Bausteinen
von K02 zusammengesetzt.
- T09 (Widerruf, intern): Fälle "kein Abo gefunden" und "Stripe-Kündigung
fehlgeschlagen" ergänzt; bisher stand dort immer "auf Kündigung gesetzt".
- Einheitlich: Datum TT.MM.JJJJ, Beträge deutsch (T04 "50,00 €"),
"WEBklar" statt "Webklar", Anrede mit Kundennamen, Team-Absender
"WEBklar Kundenportal", Betreff T05 "Kündigung (Stripe)".
- Logo für die Mails: public/webklar-logo.png (192 px, aus dem Design-SVG).
Tests: 52 neue (jede Vorlage: Kopf, Platzhalter, Blöcke, Rendern,
Escaping, Textfassung), npm test 97/97 auf Node 20.19.5; alle 40 Auslöser
einmal durch den Server geschickt (Appwrite-Mock, Stripe-Double,
SMTP-Falle mit STARTTLS).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
21 KiB
JavaScript
518 lines
21 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 { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||
import { getLedger } from '../services/hourLedger.js'
|
||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
||
import {
|
||
ACTIVE_STATUSES,
|
||
cancelAtPeriodEnd,
|
||
cancelSubscriptionAt,
|
||
constructWebhookEvent,
|
||
createAboCheckout,
|
||
createBillingPortalUrl,
|
||
getCurrentSubscription,
|
||
grantCreditForInvoice,
|
||
minTermMonthsFor,
|
||
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', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
: '–'
|
||
const fmtDateTime = (d) =>
|
||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||
const fmtEuro = (cents) =>
|
||
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||
|
||
function addMonthsIso(iso, months) {
|
||
const d = new Date(iso)
|
||
const day = d.getUTCDate()
|
||
d.setUTCDate(1)
|
||
d.setUTCMonth(d.getUTCMonth() + months)
|
||
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
|
||
d.setUTCDate(Math.min(day, last))
|
||
return d.toISOString()
|
||
}
|
||
|
||
function minTermEndOf(sub) {
|
||
const start = sub?.trialEnd || sub?.startedAt
|
||
const months = minTermMonthsFor(sub?.termsVersion)
|
||
return start && months ? addMonthsIso(start, months) : null
|
||
}
|
||
|
||
function publicSubscription(sub) {
|
||
if (!sub) return null
|
||
const active = ACTIVE_STATUSES.has(sub.status)
|
||
// Fristende 24 Uhr, bei Sa/So/Feiertag am nächsten Werktag (deadlines.js)
|
||
const withdrawalUntil = sub.startedAt ? withdrawalDeadline(sub.startedAt).endsAt : null
|
||
return {
|
||
status: sub.status,
|
||
active,
|
||
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
|
||
startedAt: sub.startedAt,
|
||
currentPeriodEnd: sub.currentPeriodEnd,
|
||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||
cancelAt: sub.cancelAt || (sub.cancelAtPeriodEnd ? sub.currentPeriodEnd : null),
|
||
minTermEnd: minTermEndOf(sub),
|
||
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,
|
||
// Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...")
|
||
offerText: sub.offerText || null,
|
||
}
|
||
}
|
||
|
||
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb).
|
||
// Ein individuelles Angebot schaltet die Bestellung fuer diesen Kunden ebenfalls frei.
|
||
function isPilotAllowed(customer, offer = null) {
|
||
const list = config.abo.pilotEmails
|
||
if (offer || !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, offer] = await Promise.all([
|
||
sub ? getLedger(customerId, { active }) : null,
|
||
active ? null : getOpenOffer(customerId).catch(() => null),
|
||
])
|
||
return res.json({
|
||
available: isStripeConfigured() && isPilotAllowed(customer, offer),
|
||
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
|
||
termsVersion: config.abo.termsVersion,
|
||
subscription: publicSubscription(sub),
|
||
offer: offer ? offerView(offer) : null,
|
||
ledger,
|
||
// Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite
|
||
lateStartAt: lateStartAt().toISOString(),
|
||
})
|
||
} 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, offerId, lateStartAt: shownLateStart } = 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))
|
||
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat
|
||
const offer = await getOpenOffer(customer.$id)
|
||
if ((offer?.$id || '') !== String(offerId || '')) {
|
||
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' })
|
||
}
|
||
if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) {
|
||
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
|
||
}
|
||
// Ebenso genau der Leistungsbeginn, den die Bestellseite gezeigt hat (verschiebt sich um Mitternacht)
|
||
const serviceStartAt = startChoice === 'nach_frist' ? lateStartAt() : null
|
||
if (serviceStartAt && shownLateStart !== serviceStartAt.toISOString()) {
|
||
return res.status(409).json({ error: 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.' })
|
||
}
|
||
// 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 : '',
|
||
serviceStartAt,
|
||
consentIp: req.ip,
|
||
consentUserAgent: req.get('user-agent') || '',
|
||
},
|
||
offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null
|
||
)
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
|
||
async function customerNameOf(sub, fallback = '') {
|
||
const customer = sub?.customerId
|
||
? await getDocument(config.collections.customers, sub.customerId).catch(() => null)
|
||
: null
|
||
return customer?.name || fallback || ''
|
||
}
|
||
|
||
async function sendContractConfirmation(subDoc, fallbackName = '') {
|
||
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'
|
||
await sendCustomerMail(subDoc.email, {
|
||
template: 'K01',
|
||
values: {
|
||
name: await customerNameOf(subDoc, fallbackName),
|
||
angebot_text: subDoc.offerText || '',
|
||
vertragsschluss: fmtDate(subDoc.startedAt),
|
||
leistungsbeginn: fmtDate(lateStart ? subDoc.trialEnd : subDoc.startedAt),
|
||
// Abos nach Bedingungen ohne Mindestlaufzeit (vor v3) entstehen nicht mehr; sie hätten hier "–"
|
||
mindestlaufzeit_bis: fmtDate(minTermEndOf(subDoc)),
|
||
bedingungen_version: subDoc.termsVersion || config.abo.termsVersion,
|
||
zustimmung_zeitpunkt: fmtDateTime(subDoc.termsAcceptedAt || Date.now()),
|
||
},
|
||
blocks: {
|
||
angebot: Boolean(subDoc.offerText),
|
||
sofortbeginn: !lateStart,
|
||
beginn_nach_widerrufsfrist: lateStart,
|
||
},
|
||
attachments: pdf
|
||
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
|
||
: [],
|
||
})
|
||
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
||
confirmationSentAt: new Date().toISOString(),
|
||
})
|
||
}
|
||
|
||
/** Kuendigung direkt bei Stripe (Dashboard/Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
|
||
async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
|
||
const endsAt = sub.cancelAt || sub.currentPeriodEnd
|
||
await sendCustomerMail(sub.email, {
|
||
template: 'K02',
|
||
values: {
|
||
name: await customerNameOf(sub),
|
||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||
vertragsende: fmtDate(endsAt),
|
||
},
|
||
})
|
||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||
cancelRequestedAt: receivedAt.toISOString(),
|
||
}).catch(() => {})
|
||
await sendAdminNotice({
|
||
template: 'T05',
|
||
values: { email: sub.email, stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt) },
|
||
})
|
||
}
|
||
|
||
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)
|
||
const offerProblem = await activateOfferFromCheckout(obj).then(
|
||
() => false,
|
||
(e) => {
|
||
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
|
||
return true
|
||
}
|
||
)
|
||
await sendAdminNotice({
|
||
template: 'T02',
|
||
values: {
|
||
email: sub?.email || obj.customer_details?.email || '',
|
||
kunde_name: obj.customer_details?.name || obj.customer_details?.email || '',
|
||
appwrite_id: sub?.customerId || obj.client_reference_id || '',
|
||
stripe_abo_id: obj.subscription,
|
||
angebot_text: obj.metadata?.offerText || '',
|
||
},
|
||
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem },
|
||
})
|
||
await sendContractConfirmation(sub, obj.customer_details?.name).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({
|
||
template: 'T04',
|
||
values: {
|
||
email: sub?.email || obj.customer_email || '',
|
||
rechnung_nr: obj.number || obj.id,
|
||
betrag: fmtEuro(obj.amount_due),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
case 'customer.subscription.created':
|
||
await syncSubscription(obj.id)
|
||
return
|
||
case 'customer.subscription.updated': {
|
||
const sub = await syncSubscription(obj.id)
|
||
const prev = event.data.previous_attributes || {}
|
||
const becameCanceled =
|
||
(prev.cancel_at_period_end === false && obj.cancel_at_period_end === true) ||
|
||
('cancel_at' in prev && !prev.cancel_at && obj.cancel_at)
|
||
const reactivated =
|
||
(prev.cancel_at_period_end === true && obj.cancel_at_period_end === false) ||
|
||
('cancel_at' in prev && prev.cancel_at && !obj.cancel_at)
|
||
// Kuendigung ueber das Stripe-Kundenportal oder das Dashboard: Bestaetigung hier verschicken.
|
||
// Ueber Kuendigungs-/Widerrufsseite gesetzte Kuendigungen tragen cancelSource und sind schon bestaetigt.
|
||
if (becameCanceled && !obj.metadata?.cancelSource && sub) {
|
||
await sendPortalCancellationConfirmation(sub, event.created).catch((e) =>
|
||
console.error('[abo] Kuendigungsbestaetigung (Kundenportal) fehlgeschlagen:', e.message)
|
||
)
|
||
}
|
||
if (reactivated && sub) {
|
||
await sendAdminNotice({ template: 'T06', values: { email: sub.email, stripe_abo_id: obj.id } })
|
||
}
|
||
return
|
||
}
|
||
case 'customer.subscription.deleted': {
|
||
const sub = await syncSubscription(obj.id)
|
||
await sendAdminNotice({
|
||
template: 'T07',
|
||
values: { email: sub?.email || '', stripe_abo_id: obj.id, status: obj.status },
|
||
})
|
||
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: ['ausserordentlich', 'geschaeftsaufgabe'].includes(body?.kind) ? body.kind : '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 !== 'ausserordentlich') {
|
||
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
|
||
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton', d.kind, receivedAt)
|
||
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 kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
|
||
const common = {
|
||
name: d.name,
|
||
email: d.email,
|
||
kundennummer: d.customerNumber,
|
||
wunschtermin: d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
|
||
grund: d.reason,
|
||
vertragsende: fmtDate(effectiveAt),
|
||
}
|
||
|
||
await sendCustomerMail(d.email, {
|
||
template: 'K03',
|
||
values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
|
||
blocks: {
|
||
kundennummer: Boolean(d.customerNumber),
|
||
grund: Boolean(d.reason),
|
||
automatisch_gekuendigt: Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||
sonderkuendigung: Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||
ausserordentlich: !effectiveAt && d.kind === 'ausserordentlich',
|
||
kein_abo_gefunden: !effectiveAt && d.kind !== 'ausserordentlich',
|
||
},
|
||
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||
|
||
await sendAdminNotice({
|
||
template: 'T08',
|
||
values: {
|
||
...common,
|
||
kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1),
|
||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||
},
|
||
blocks: {
|
||
kundennummer: Boolean(d.customerNumber),
|
||
grund: Boolean(d.reason),
|
||
automatisch_gekuendigt: Boolean(effectiveAt),
|
||
sonderkuendigung: d.kind === 'geschaeftsaufgabe',
|
||
// Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler
|
||
ausserordentlich: !effectiveAt && Boolean(sub),
|
||
kein_abo_gefunden: !sub,
|
||
},
|
||
})
|
||
|
||
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
|
||
let canceled = false
|
||
try {
|
||
sub = await findActiveSubscriptionByEmail(d.email)
|
||
if (sub) {
|
||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')
|
||
canceled = true
|
||
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, {
|
||
template: 'K04',
|
||
values: { name: d.name, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber },
|
||
blocks: { kundennummer: Boolean(d.customerNumber) },
|
||
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||
|
||
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss
|
||
const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null
|
||
await sendAdminNotice({
|
||
template: 'T09',
|
||
values: {
|
||
email: d.email,
|
||
name: d.name,
|
||
kundennummer: d.customerNumber,
|
||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||
abo_beginn: fmtDate(sub?.startedAt),
|
||
fristende: fmtDate(deadline?.endsAt),
|
||
rechtzeitig: !sub
|
||
? 'ungeprüft (kein Abo gefunden)'
|
||
: !deadline
|
||
? 'ungeprüft (Abo-Beginn fehlt)'
|
||
: receivedAt <= deadline.endsAt
|
||
? 'RECHTZEITIG'
|
||
: 'NACH Fristende, vor einer Ablehnung bitte prüfen',
|
||
},
|
||
blocks: {
|
||
kundennummer: Boolean(d.customerNumber),
|
||
abo_gefunden: Boolean(sub),
|
||
auf_kuendigung_gesetzt: canceled,
|
||
kein_abo_gefunden: !sub,
|
||
kuendigung_fehlgeschlagen: Boolean(sub) && !canceled,
|
||
},
|
||
})
|
||
|
||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||
})
|
||
|
||
export default router
|