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:
ClaudeandClaude Fable 5.1 committed 2026-09-29 09:52:37 +00:00
1 parent f66a713531
commit 7ffe95f24a
10 files changed
+992 -87

No files matched your search

+336 -54
View File
@@ -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,
},
})