Kündigung und Widerruf: Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f66a713531
commit
7ffe95f24a
10 files changed
+992
-87
No files matched your search
+336
-54
@@ -5,7 +5,7 @@ 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 { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
|
||||
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
|
||||
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
|
||||
import { PRODUCT_KEYS, describeItems, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createAboCheckout,
|
||||
createBillingPortalUrl,
|
||||
getCurrentSubscription,
|
||||
getStripe,
|
||||
grantCreditForInvoice,
|
||||
loadInvoice,
|
||||
minTermMonthsFor,
|
||||
@@ -632,19 +633,67 @@ router.post('/webhook', async (req, res) => {
|
||||
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Ratenbegrenzung: hoechstens 10 Erklaerungen je Stunde und IP-Adresse (Datenschutzerklaerung Abschnitt 7: die
|
||||
// IP-Adresse dient nur dazu, die Zahl der Anfragen zu begrenzen, sie wird nicht mit der Erklaerung gespeichert).
|
||||
// Eintraege, die aelter als eine Stunde sind, fliegen bei jedem Aufruf raus - auch die anderer IP-Adressen (F30).
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000
|
||||
const RATE_MAX = 10
|
||||
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 purgeRateLimit(now) {
|
||||
for (const [ip, list] of recentRequests) {
|
||||
const fresh = list.filter((t) => now - t < RATE_WINDOW_MS)
|
||||
if (fresh.length) recentRequests.set(ip, fresh)
|
||||
else recentRequests.delete(ip)
|
||||
}
|
||||
}
|
||||
|
||||
function rateLimited(ip, now = Date.now()) {
|
||||
purgeRateLimit(now)
|
||||
const list = recentRequests.get(ip) || []
|
||||
list.push(now)
|
||||
recentRequests.set(ip, list)
|
||||
return list.length > RATE_MAX
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: alle Zaehler der Ratenbegrenzung loeschen. */
|
||||
export function resetPublicRateLimit() {
|
||||
recentRequests.clear()
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: Zahl der IP-Adressen, die zum Zeitpunkt now noch gezaehlt wuerden (aendert nichts). */
|
||||
export function publicRateLimitState({ now = Date.now() } = {}) {
|
||||
const ips = [...recentRequests.values()].filter((list) => list.some((t) => now - t < RATE_WINDOW_MS)).length
|
||||
return { ips }
|
||||
}
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher']
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
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
|
||||
const contract = CONTRACTS.includes(body?.contract) ? body.contract : 'abo'
|
||||
const order = ORDERS.includes(body?.order) ? body.order : 'abschluss'
|
||||
let packages = null
|
||||
if (contract === 'zusatzspeicher') {
|
||||
const raw = String(body?.packages ?? '').trim()
|
||||
if (raw) {
|
||||
packages = /^\d+$/.test(raw) ? Number(raw) : NaN
|
||||
if (!(packages >= 1 && packages <= config.mailStorage.maxUnits)) {
|
||||
return { error: `Bitte geben Sie 1 bis ${config.mailStorage.maxUnits} Pakete an oder lassen Sie das Feld leer (alle Pakete).` }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
@@ -652,9 +701,30 @@ function validDeclaration(body) {
|
||||
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) : '',
|
||||
contract,
|
||||
order,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ratenbegrenzung und Erklaerung pruefen; bei 429/400 ist die Antwort schon geschickt (null). */
|
||||
function readDeclaration(req, res) {
|
||||
if (rateLimited(req.ip)) {
|
||||
res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
return null
|
||||
}
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) {
|
||||
res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
return null
|
||||
}
|
||||
if (d.error) {
|
||||
res.status(400).json({ error: d.error })
|
||||
return null
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
async function findActiveSubscriptionByEmail(email) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(email).catch(() => null)) ||
|
||||
@@ -664,51 +734,177 @@ async function findActiveSubscriptionByEmail(email) {
|
||||
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()
|
||||
/** Name des Abos aus dem Katalog ("WEBklar Hosting-Abo"), null ohne Abo. */
|
||||
const productNameOf = (sub) => (sub ? config.products[productKeyOf(sub)]?.name || 'WEBklar Abo' : null)
|
||||
|
||||
let sub = null
|
||||
/** Beschriftung des gekuendigten Vertrags fuer K03/T08: das tatsaechliche Abo (Spec 4.5) bzw. der Zusatzspeicher. */
|
||||
function contractLabel(contract, sub) {
|
||||
const abo = productNameOf(sub) || 'WEBklar Abo (Hosting- oder Wartungs-Abo)'
|
||||
return contract === 'zusatzspeicher' ? `E-Mail-Zusatzspeicher${sub ? ` zum ${abo}` : ''}` : abo
|
||||
}
|
||||
|
||||
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
|
||||
function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachweis der Erklaerung als Vertragsereignis kuendigung bzw. widerruf (F31, Datenschutz Abschnitt 7: ohne
|
||||
* IP-Adresse und Browser). Feste ID je Abo und Eingangszeitpunkt (eventIdFor, 36 Zeichen). Laesst es sich
|
||||
* nicht speichern, geht die Erklaerung trotzdem weiter: die Mails sind der Nachweis, T08/T09 sagen es dem Team.
|
||||
*/
|
||||
async function recordDeclarationEvent(sub, type, receivedAt, details, status) {
|
||||
try {
|
||||
return await recordContractEvent({
|
||||
id: eventIdFor(type, `${sub.$id}:${receivedAt.toISOString()}`),
|
||||
customerId: sub.customerId,
|
||||
subscriptionId: sub.$id,
|
||||
type,
|
||||
productKey: productKeyOf(sub),
|
||||
details,
|
||||
consent: null,
|
||||
status,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[abo] Ereignis ${type} für Abo ${sub.$id} nicht gespeichert:`, err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kuendigung des Abos wie bisher: Stripe (ausser ausserordentlich), Eingang am Abo, Ereignis kuendigung. */
|
||||
async function cancelAbo(sub, d, receivedAt) {
|
||||
let effectiveAt = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub && d.kind !== 'ausserordentlich') {
|
||||
if (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
|
||||
sub = result.subscription || sub
|
||||
effectiveAt = result.effectiveAt
|
||||
}
|
||||
if (sub) {
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString() })
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||||
}
|
||||
// Erledigt nur, wenn nichts mehr zu tun ist; ausserordentlich, Geschaeftsaufgabe (Nachweis) und Stripe-Fehler
|
||||
// bleiben als Aufgabe offen
|
||||
const status = effectiveAt && d.kind === 'ordentlich' ? 'erledigt' : 'offen'
|
||||
const event = await recordDeclarationEvent(sub, 'kuendigung', receivedAt, { contract: 'abo', kind: d.kind, effectiveAt }, status)
|
||||
return { sub, effectiveAt, event, outcome: effectiveAt ? 'gekuendigt' : 'offen' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechsel ins Wartungs-Abo, der noch auf die Kartenzahlung wartet (offenes Ereignis wechsel ohne effectiveAt
|
||||
* oder pending_update am Stripe-Abo): eine Aenderung des Abos wuerde die Wechselrechnung stornieren. Ein
|
||||
* abgelaufener Wechsel wird vorher wie in GET /api/abo mit Stripe abgeglichen.
|
||||
*/
|
||||
async function pendingCardSwitch(sub, stripeSub) {
|
||||
const readOpen = async () =>
|
||||
(await listContractEvents(sub.customerId)).find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === sub.$id) || null
|
||||
let open = await readOpen()
|
||||
if (open && (await reconcilePendingSwitch(open))) open = await readOpen()
|
||||
return Boolean((open && !open.details?.effectiveAt) || stripeSub?.pending_update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kuendigung nur des Zusatzspeichers (Bedingungen § 8 Abs. 7/8, § 10 Abs. 5): gebuchte Pakete minus packages
|
||||
* (leer = alle) ueber changeStorage mit viaCancellation (Task 8: nur Verringerung, in jedem laufenden Status,
|
||||
* auch bei gekuendigtem Abo), wirksam zum Ende des Abrechnungsmonats. Ohne gebuchten Speicher, waehrend ein
|
||||
* Kartenwechsel aussteht oder wenn changeStorage scheitert, aendert sich nichts: K03 sagt "wir pruefen", T08
|
||||
* traegt den Grund (F20). Liefert { outcome, error, event, details? } (details nur nach einer Verringerung).
|
||||
*/
|
||||
async function cancelStorage(sub, d, receivedAt) {
|
||||
// Gebuchte Menge frisch aus Stripe (der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken); die
|
||||
// Antwort zeigt auch, ob eine Aenderung bis zur Zahlung zurueckgehalten wird
|
||||
let stripeSub = null
|
||||
let fromUnits = Number(sub.storageUnits) || 0
|
||||
try {
|
||||
stripeSub = await getStripe().subscriptions.retrieve(sub.$id)
|
||||
fromUnits = describeItems(stripeSub.items?.data).storageUnits
|
||||
} catch (err) {
|
||||
console.error(`[abo] Stripe-Abo ${sub.$id} nicht lesbar, Speicherstand aus dem Spiegel:`, err.message)
|
||||
}
|
||||
const base = { contract: 'zusatzspeicher', effectiveAt: null, packages: d.packages, fromUnits, toUnits: null }
|
||||
const pending = async (outcome, error = null) => ({
|
||||
outcome,
|
||||
error,
|
||||
event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, base, 'offen'),
|
||||
})
|
||||
if (fromUnits <= 0) return pending('kein_speicher')
|
||||
// Laesst sich nicht klaeren, ob ein Wechsel aussteht, bleibt Stripe unangetastet
|
||||
try {
|
||||
if (await pendingCardSwitch(sub, stripeSub)) return pending('wechsel_ausstehend')
|
||||
} catch (err) {
|
||||
console.error(`[abo] Wechsel-Prüfung für Abo ${sub.$id} nicht möglich:`, err.message)
|
||||
return pending('fehlgeschlagen', `Vertragsereignisse nicht lesbar: ${err.message}`)
|
||||
}
|
||||
const restUnits = Math.max(0, fromUnits - (d.packages ?? fromUnits))
|
||||
try {
|
||||
const { change } = await changeStorage(sub.customerId, restUnits, null, { viaCancellation: true })
|
||||
const details = {
|
||||
contract: 'zusatzspeicher',
|
||||
effectiveAt: change.effectiveAt,
|
||||
packages: change.fromUnits - change.toUnits,
|
||||
fromUnits: change.fromUnits,
|
||||
toUnits: change.toUnits,
|
||||
}
|
||||
return { outcome: 'reduziert', error: null, details, event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, details, 'erledigt') }
|
||||
} catch (err) {
|
||||
console.error(`[abo] Zusatzspeicher über die Kündigung nicht verringert (Abo ${sub.$id}):`, err.message)
|
||||
return pending('fehlgeschlagen', err.message || 'unbekannter Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/public/cancel', async (req, res) => {
|
||||
const d = readDeclaration(req, res)
|
||||
if (!d) return
|
||||
const receivedAt = new Date()
|
||||
const storage = d.contract === 'zusatzspeicher'
|
||||
|
||||
let sub = null
|
||||
let result = { outcome: 'kein_abo', effectiveAt: null, details: null, event: null, error: null }
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
result = storage ? await cancelStorage(sub, d, receivedAt) : await cancelAbo(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung nicht verarbeitbar:', err.message)
|
||||
}
|
||||
const reduced = result.outcome === 'reduziert'
|
||||
const effectiveAt = storage ? result.details?.effectiveAt || null : result.effectiveAt
|
||||
|
||||
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',
|
||||
vertrag: contractLabel(d.contract, sub),
|
||||
// Eine Verringerung des Speichers wirkt immer zum Ende des Abrechnungsmonats
|
||||
wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
|
||||
grund: d.reason,
|
||||
vertragsende: fmtDate(effectiveAt),
|
||||
// {{pakete}} = gekuendigte Pakete (F31); solange nichts verringert wurde, der Wunsch des Kunden
|
||||
pakete: reduced ? result.details.packages : d.packages ?? 'alle',
|
||||
}
|
||||
|
||||
await sendCustomerMail(d.email, {
|
||||
template: 'K03',
|
||||
values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
|
||||
values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', 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',
|
||||
abo: !storage,
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
|
||||
// Abo: nicht automatisch gekuendigt (kein Abo oder Stripe-Fehler) -> "wir ordnen zu"; Speicher: kein Abo
|
||||
kein_abo_gefunden: storage ? !sub : !effectiveAt && d.kind !== 'ausserordentlich',
|
||||
zusatzspeicher: reduced,
|
||||
pruefen: storage && Boolean(sub) && !reduced,
|
||||
},
|
||||
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
@@ -718,74 +914,160 @@ router.post('/public/cancel', async (req, res) => {
|
||||
...common,
|
||||
kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1),
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
fehler: result.error || '',
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
grund: Boolean(d.reason),
|
||||
automatisch_gekuendigt: Boolean(effectiveAt),
|
||||
sonderkuendigung: d.kind === 'geschaeftsaufgabe',
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt),
|
||||
sonderkuendigung: !storage && d.kind === 'geschaeftsaufgabe',
|
||||
// Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler
|
||||
ausserordentlich: !effectiveAt && Boolean(sub),
|
||||
ausserordentlich: !storage && !effectiveAt && Boolean(sub),
|
||||
kein_abo_gefunden: !sub,
|
||||
zusatzspeicher: reduced,
|
||||
kein_speicher: result.outcome === 'kein_speicher',
|
||||
wechsel_ausstehend: result.outcome === 'wechsel_ausstehend',
|
||||
speicher_fehlgeschlagen: result.outcome === 'fehlgeschlagen',
|
||||
nachweis_fehlt: Boolean(sub) && !result.event,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
/** Spaetestes Fristende einer Liste von Widerrufsfristen (openWithdrawals) oder null. */
|
||||
const latestUntil = (list) => list.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null)
|
||||
|
||||
/**
|
||||
* Widerruf der gewaehlten Bestellung: liegt eine offene Frist des Typs vor (openWithdrawals; Altbestand ohne
|
||||
* Ereignis abschluss: ab startedAt, F14), bekommen alle Bezugsereignisse den Status widerrufen (F03), beim
|
||||
* Abschluss wird das Abo wie bisher auf Kuendigung gesetzt; Wechsel und Zusatzspeicher setzt das Team von Hand
|
||||
* zurueck (T09). Ohne offene Frist aendert sich nichts. Immer entsteht das Ereignis widerruf mit
|
||||
* details { order, eventId, eventIds, inTime }.
|
||||
*/
|
||||
async function withdrawOrder(sub, d, receivedAt) {
|
||||
const type = ORDER_EVENT_TYPE[d.order]
|
||||
const events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type)
|
||||
let open = openWithdrawals(events, receivedAt)
|
||||
let deadline = latestUntil(open)
|
||||
if (type === 'abschluss' && !events.length && sub.startedAt) {
|
||||
const { endsAt } = withdrawalDeadline(sub.startedAt)
|
||||
deadline = endsAt
|
||||
if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }]
|
||||
}
|
||||
if (!deadline) {
|
||||
// Frist schon vorbei: das spaeteste Fristende der Bestellungen dieses Typs fuer den Hinweis ans Team
|
||||
const dates = events.map((e) => e.details?.contractAt ?? e.consentAt).filter(Boolean)
|
||||
deadline = latestUntil(dates.map((at) => ({ until: withdrawalDeadline(at).endsAt })))
|
||||
}
|
||||
const inTime = open.length > 0
|
||||
const ids = open.map((w) => w.eventId).filter(Boolean)
|
||||
const withdrawn = []
|
||||
let canceled = false
|
||||
let cancelFailed = false
|
||||
if (inTime) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await updateContractEvent(id, { status: 'widerrufen', doneAt: receivedAt.toISOString(), doneBy: 'widerrufsbutton' })
|
||||
withdrawn.push(events.find((e) => e.$id === id))
|
||||
} catch (err) {
|
||||
console.error(`[abo] Ereignis ${id} nicht auf widerrufen gesetzt:`, err.message)
|
||||
}
|
||||
}
|
||||
if (type === 'abschluss') {
|
||||
try {
|
||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||
sub = (await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')) || sub
|
||||
canceled = true
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, { withdrawalRequestedAt: receivedAt.toISOString() })
|
||||
} catch (err) {
|
||||
cancelFailed = true
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
|
||||
return { sub, inTime, deadline, withdrawn, canceled, cancelFailed, event }
|
||||
}
|
||||
|
||||
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
|
||||
function describeWithdrawn(events) {
|
||||
return events
|
||||
.map((e) => {
|
||||
const at = fmtDate(e.details?.contractAt ?? e.consentAt)
|
||||
const what = e.type === 'speicher' ? `${e.details?.fromUnits ?? '?'} → ${e.details?.toUnits ?? '?'} Pakete` : `${e.type} ${e.details?.switchedAt ? `vom ${fmtDate(e.details.switchedAt)}` : ''}`.trim()
|
||||
return `${e.$id}: ${what} (bestellt am ${at})`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
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 d = readDeclaration(req, res)
|
||||
if (!d) return
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
let canceled = false
|
||||
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null }
|
||||
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(),
|
||||
})
|
||||
result = await withdrawOrder(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
|
||||
}
|
||||
const { inTime } = result
|
||||
|
||||
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) },
|
||||
values: {
|
||||
name: d.name,
|
||||
bestellung: orderLabel(d.order, sub, { customer: true }),
|
||||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
pruefen: !inTime,
|
||||
},
|
||||
}).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
|
||||
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zaehlen muss
|
||||
await sendAdminNotice({
|
||||
template: 'T09',
|
||||
values: {
|
||||
email: d.email,
|
||||
name: d.name,
|
||||
kundennummer: d.customerNumber,
|
||||
bestellung: orderLabel(d.order, sub),
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
abo_beginn: fmtDate(sub?.startedAt),
|
||||
fristende: fmtDate(deadline?.endsAt),
|
||||
fristende: fmtDate(result.deadline),
|
||||
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',
|
||||
: inTime
|
||||
? 'RECHTZEITIG'
|
||||
: result.deadline
|
||||
? 'NACH Fristende, vor einer Ablehnung bitte prüfen'
|
||||
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
|
||||
ereignisse: describeWithdrawn(result.withdrawn),
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abo_gefunden: Boolean(sub),
|
||||
auf_kuendigung_gesetzt: canceled,
|
||||
auf_kuendigung_gesetzt: result.canceled,
|
||||
kein_abo_gefunden: !sub,
|
||||
kuendigung_fehlgeschlagen: Boolean(sub) && !canceled,
|
||||
kuendigung_fehlgeschlagen: result.cancelFailed,
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
keine_frist: Boolean(sub) && !inTime,
|
||||
nachweis_fehlt: Boolean(sub) && !result.event,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user