Abo: Uebernahmegutschrift fuer beim Altanbieter bezahlte Leistungen

- Kunde (Dashboard, nur mit laufendem Abo): Antrag mit Leistung, Anbieter,
  Domain, Rechnungsbetrag/-zeitraum, Grund, Rechnungsnachweis (PDF/JPG/PNG)
  und E-Mail fuer die Antwort; Eingangsbestaetigung per Mail, Status und
  Antwort auf Rueckfragen im Portal
- Admin-Mail mit Link admin.html?gutschrift=<id> (Login leitet zurueck)
- Admin-Tab "Gutschriften": Kunde und Vertrag, Nachweise, Verlauf,
  automatische Pruefungen (Duplikate per Datei-Hash/Domain, Abo, Zeitraum,
  DNS-Abgleich Nameserver/MX mit Anbieter), Pflicht-Checkliste, Rechner
  (tagesgenau anteilig), Genehmigen/Rueckfrage/Ablehnen mit Kundenmail
- Genehmigte Gutschrift als Stripe-Kundenguthaben (idempotent, Nachbuchen
  bei Stripe-Stoerung)
- Nachweise in upload-data/ (gitignored), Loeschung abgelehnter nach 6 Monaten
- Stundenkontingent auch fuer Monate, die ein Guthaben komplett deckt
  (invoice.total statt amount_paid)
- Datenschutzerklaerung Abschnitt 7 ergaenzt, PDF neu erzeugt
- Collection takeoverCredits in setup-abo-collections.mjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 19:34:33 +00:00
1 parent dbabbf1c0b
commit 48b13f4488
14 files changed
+2040 -9

No files matched your search

+3 -1
View File
@@ -243,7 +243,9 @@ export async function grantCreditForInvoice(invoice) {
const ref = subscriptionIdOfInvoice(invoice)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
if (!(invoice.amount_paid > 0)) return { skipped: 'Betrag 0' }
// total statt amount_paid: Eine Monatsgebuehr, die ein Guthaben (Uebernahmegutschrift) deckt,
// ist bezahlt, obwohl nichts abgebucht wurde. Nur Probezeit-Rechnungen (0 €) zaehlen nicht.
if (!(invoice.total > 0)) return { skipped: 'Betrag 0' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
+971
View File
@@ -0,0 +1,971 @@
import { createHash, randomUUID } from 'node:crypto'
import { Resolver } from 'node:dns/promises'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { domainToASCII, domainToUnicode } from 'node:url'
import { config } from '../config.js'
import { createDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
/**
* Uebernahmegutschrift.
*
* Hat ein Abo-Kunde Domain, Hosting oder E-Mail beim bisherigen Anbieter schon im Voraus
* bezahlt, zahlt er ab Leistungsbeginn des Abos fuer dieselbe Zeit doppelt. Diesen Anteil
* schreiben wir ihm gut:
* Antrag (Kunde, mit Rechnungsnachweis, nur mit laufendem Abo)
* -> Pruefung (Admin: automatische Checks + Pflicht-Checkliste)
* -> Entscheidung: Genehmigen / Rueckfrage / Ablehnen, Antwort per E-Mail an den Kunden
* -> Verrechnung als Stripe-Kundenguthaben (negative Balance Transaction), das Stripe
* automatisch mit den naechsten Abo-Rechnungen verrechnet.
* Die Nachweise liegen auf dem Server (config.takeover.uploadDir), nie unter public/.
*/
const COLLECTION = () => config.collections.takeoverCredits
export const STATUS = {
open: 'offen',
question: 'rueckfrage',
approved: 'genehmigt',
rejected: 'abgelehnt',
}
export const SERVICE_LABELS = {
domain: 'Domain',
hosting: 'Hosting / Website-Betrieb',
email: 'E-Mail-Postfach',
sonstiges: 'Sonstiges',
}
export const BILLING_MONTHS = [1, 3, 6, 12, 24, 36]
// Pflicht-Checkliste vor dem Genehmigen (wird mit Name und Zeitpunkt am Antrag gespeichert)
export const REQUIRED_CHECKS = [
{ key: 'recipient', label: 'Rechnung ist auf den Kunden ausgestellt (Name bzw. Firma stimmt)' },
{ key: 'service', label: 'Leistung und Domain auf der Rechnung stimmen mit dem Antrag überein' },
{ key: 'period', label: 'Betrag und bezahlter Zeitraum stimmen mit der Rechnung überein' },
{ key: 'replaced', label: 'Die Leistung wird durch das Wartungs-Abo ersetzt (Umzug zu WEBklar)' },
]
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
const DAY_MS = 24 * 60 * 60 * 1000
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
// ---------------------------------------------------------------------------
// Formatierung
// ---------------------------------------------------------------------------
const fmtEuro = (cents) =>
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
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' })
export function servicesLabel(services) {
return String(services || '')
.split(',')
.filter(Boolean)
.map((s) => SERVICE_LABELS[s] || s)
.join(', ')
}
const monthsLabel = (m) => (Number(m) === 1 ? '1 Monat' : `${m} Monate`)
/** Kurze, fuer Kunden lesbare Antragsnummer. */
export const shortRef = (id) => String(id || '').slice(0, 8).toUpperCase()
// ---------------------------------------------------------------------------
// Berechnung (reine Funktionen)
// ---------------------------------------------------------------------------
/** Kalendertag in Berlin als UTC-Mitternacht ('YYYY-MM-DD' oder ISO-Zeitpunkt). */
function dayOf(value) {
if (!value) return null
const raw = String(value)
const ymd = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? raw
: new Date(raw).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
const d = new Date(`${ymd}T00:00:00Z`)
return Number.isNaN(d.getTime()) ? null : d
}
function addDaysUtc(date, days) {
return new Date(date.getTime() + days * DAY_MS)
}
function addMonthsUtc(date, months) {
// Monatsende abfangen (31.03. - 1 Monat = 28./29.02.)
const d = new Date(date)
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
}
const daysBetween = (a, b) => Math.round((b.getTime() - a.getTime()) / DAY_MS)
const ymd = (d) => d.toISOString().slice(0, 10)
/**
* Anteilige Gutschrift, tagesgenau:
* Rechnungszeitraum = die letzten `billingMonths` Monate bis einschliesslich `paidUntil`
* doppelt bezahlt = ab `takeoverFrom` (Leistungsbeginn des Abos) bis `paidUntil`
* Vorschlag = Rechnungsbetrag * doppelt bezahlte Tage / Tage im Rechnungszeitraum
*/
export function computeTakeoverCredit({ invoiceAmountCents, billingMonths, paidUntil, takeoverFrom }) {
const amount = Math.round(Number(invoiceAmountCents) || 0)
const months = Math.round(Number(billingMonths) || 0)
const lastPaidDay = dayOf(paidUntil)
const from = dayOf(takeoverFrom)
if (!(amount > 0) || !(months > 0) || !lastPaidDay || !from) return null
const periodEndExclusive = addDaysUtc(lastPaidDay, 1)
const periodStart = addMonthsUtc(periodEndExclusive, -months)
const periodDays = Math.max(1, daysBetween(periodStart, periodEndExclusive))
const overlapStart = from > periodStart ? from : periodStart
const overlapDays = Math.max(0, daysBetween(overlapStart, periodEndExclusive))
return {
periodStart: ymd(periodStart),
periodEnd: ymd(lastPaidDay),
periodDays,
overlapStart: ymd(overlapStart),
overlapDays,
suggestedCents: Math.round((amount * overlapDays) / periodDays),
}
}
/** Rechnungswerte aus dem Admin-Rechner (fehlende Felder: Angaben aus dem Antrag). */
export function readInvoiceFigures(body, doc) {
const invoiceAmountCents =
body?.invoiceAmountCents == null ? doc.invoiceAmountCents : Math.round(Number(body.invoiceAmountCents))
const billingMonths = body?.billingMonths == null ? doc.billingMonths : Number(body.billingMonths)
const paidUntil = body?.paidUntil ? dayOf(body.paidUntil) : dayOf(doc.paidUntil)
if (!(invoiceAmountCents > 0) || invoiceAmountCents > 500000) throw httpError(400, 'Bitte einen gültigen Rechnungsbetrag angeben.')
if (!BILLING_MONTHS.includes(billingMonths)) throw httpError(400, 'Bitte den Abrechnungszeitraum der Rechnung wählen.')
if (!paidUntil) throw httpError(400, 'Bitte angeben, bis wann bezahlt ist.')
return { invoiceAmountCents, billingMonths, paidUntil: paidUntil.toISOString() }
}
export function calculationText(doc, calc, amountCents) {
if (!calc) return ''
const base = `${fmtEuro(doc.invoiceAmountCents)} für ${monthsLabel(doc.billingMonths)} (${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)}), davon ${calc.overlapDays} von ${calc.periodDays} Tagen ab ${fmtDate(calc.overlapStart)} doppelt bezahlt = ${fmtEuro(calc.suggestedCents)}`
return amountCents === calc.suggestedCents ? base : `${base}; festgesetzt auf ${fmtEuro(amountCents)}`
}
// ---------------------------------------------------------------------------
// Eingaben pruefen
// ---------------------------------------------------------------------------
const clean = (value, max) => String(value ?? '').trim().slice(0, max)
export function normalizeDomain(value) {
const raw = String(value || '').trim().toLowerCase()
if (!raw) return ''
const host = raw
.replace(/^[a-z]+:\/\//, '')
.replace(/[/?#].*$/, '')
.replace(/:\d+$/, '')
.replace(/^www\./, '')
.replace(/\.$/, '')
// Umlaut-Domains: Pruefung in Punycode, gespeichert wird die lesbare Schreibweise
const ascii = domainToASCII(host)
const valid = /^(?=.{3,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(ascii)
return valid ? domainToUnicode(ascii) : null
}
/** "119,88" / "1.234,56" / "119.88" / 119.88 -> Cent */
export function parseEuroCents(value) {
if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value * 100) : NaN
let s = String(value ?? '').replace(/[\s€]/g, '')
if (s.includes(',')) s = s.replace(/\./g, '').replace(',', '.')
if (!/^\d+(\.\d{1,2})?$/.test(s)) return NaN
return Math.round(Number(s) * 100)
}
const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
export function validateRequestInput(body) {
const services = [...new Set(Array.isArray(body?.services) ? body.services : [])].filter((s) => Object.hasOwn(SERVICE_LABELS, s))
if (!services.length) throw httpError(400, 'Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.')
const provider = clean(body.provider, 200)
if (!provider) throw httpError(400, 'Bitte nennen Sie Ihren bisherigen Anbieter.')
const domain = normalizeDomain(body.domain)
if (domain === null) throw httpError(400, 'Die Domain sieht nicht gültig aus (Beispiel: meine-firma.de).')
if (!domain && services.includes('domain')) throw httpError(400, 'Bitte geben Sie die Domain an.')
const invoiceAmountCents = parseEuroCents(body.invoiceAmount)
if (!(invoiceAmountCents > 0) || invoiceAmountCents > 500000) {
throw httpError(400, 'Bitte geben Sie den Rechnungsbetrag in Euro an.')
}
const billingMonths = Number(body.billingMonths)
if (!BILLING_MONTHS.includes(billingMonths)) throw httpError(400, 'Bitte wählen Sie den Abrechnungszeitraum der Rechnung.')
const paidUntilRaw = String(body.paidUntil || '')
const paidUntil = /^\d{4}-\d{2}-\d{2}$/.test(paidUntilRaw) ? dayOf(paidUntilRaw) : null
const now = Date.now()
if (!paidUntil || paidUntil.getTime() < now - 366 * DAY_MS || paidUntil.getTime() > now + 3 * 366 * DAY_MS) {
throw httpError(400, 'Bitte geben Sie an, bis wann Sie beim bisherigen Anbieter bezahlt haben.')
}
const message = clean(body.message, 4000)
if (message.length < 5) throw httpError(400, 'Bitte beschreiben Sie kurz den Grund Ihres Antrags.')
const email = clean(body.email, 255)
if (!isEmail(email)) throw httpError(400, 'Bitte geben Sie eine gültige E-Mail-Adresse für unsere Antwort an.')
if (body.consent !== true) {
throw httpError(400, 'Bitte bestätigen Sie Ihre Angaben und die Bedingungen der Übernahmegutschrift.')
}
return {
services,
provider,
domain,
invoiceAmountCents,
billingMonths,
paidUntil: paidUntil.toISOString(),
message,
email,
}
}
// ---------------------------------------------------------------------------
// Nachweise (Dateien)
// ---------------------------------------------------------------------------
const FILE_TYPES = [
// PDF-Kopf darf laut Spezifikation innerhalb der ersten 1024 Bytes stehen
{ mime: 'application/pdf', ext: 'pdf', test: (b) => b.subarray(0, 1024).includes('%PDF-') },
{ mime: 'image/png', ext: 'png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
{ mime: 'image/jpeg', ext: 'jpg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
]
function safeFileName(name, ext) {
const base = String(name || '')
.split(/[\\/]/)
.pop()
.replace(/[\u0000-\u001f"<>|:*?]/g, '')
.trim()
.slice(0, 120)
if (!base) return `nachweis.${ext}`
return /\.[a-z0-9]{2,5}$/i.test(base) ? base : `${base}.${ext}`
}
/** Base64-Uploads aus dem JSON-Body pruefen: Anzahl, Groesse, echter Dateityp (Magic Bytes). */
export function parseUploads(list, { required }) {
const items = Array.isArray(list) ? list : []
const { maxFiles, maxFileBytes, maxTotalBytes } = config.takeover
if (required && !items.length) throw httpError(400, 'Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.')
if (items.length > maxFiles) throw httpError(400, `Bitte höchstens ${maxFiles} Dateien auf einmal hochladen.`)
let total = 0
return items.map((item) => {
const buffer = Buffer.from(String(item?.data || ''), 'base64')
const label = clean(item?.name, 120) || 'Datei'
if (!buffer.length) throw httpError(400, `„${label}“ ist leer.`)
if (buffer.length > maxFileBytes) throw httpError(413, `„${label}“ ist größer als ${maxFileBytes / 1024 / 1024} MB.`)
total += buffer.length
if (total > maxTotalBytes) throw httpError(413, `Die Dateien sind zusammen größer als ${maxTotalBytes / 1024 / 1024} MB.`)
const kind = FILE_TYPES.find((t) => t.test(buffer))
if (!kind) throw httpError(400, `„${label}“: Bitte als PDF, JPG oder PNG hochladen.`)
return {
id: randomUUID(),
name: safeFileName(item?.name, kind.ext),
type: kind.mime,
ext: kind.ext,
size: buffer.length,
sha256: createHash('sha256').update(buffer).digest('hex'),
buffer,
}
})
}
function uploadPath(requestId, file) {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/.test(String(requestId)) || !/^[0-9a-f-]{36}$/.test(String(file?.id))) {
throw httpError(400, 'Ungültiger Dateiverweis')
}
return path.join(config.takeover.uploadDir, requestId, `${file.id}.${file.ext || 'bin'}`)
}
async function storeUploads(requestId, uploads, uploadedAt) {
if (!uploads.length) return []
await mkdir(path.join(config.takeover.uploadDir, requestId), { recursive: true, mode: 0o700 })
const stored = []
for (const { buffer, ...meta } of uploads) {
await writeFile(uploadPath(requestId, meta), buffer, { mode: 0o600 })
stored.push({ ...meta, uploadedAt })
}
return stored
}
export async function readUpload(doc, fileId) {
const file = filesOf(doc).find((f) => f.id === fileId)
if (!file) throw httpError(404, 'Datei nicht gefunden')
const content = await readFile(uploadPath(doc.$id, file)).catch(() => null)
if (!content) throw httpError(404, 'Datei ist nicht mehr vorhanden')
return { file, content }
}
// ---------------------------------------------------------------------------
// Dokument-Helfer
// ---------------------------------------------------------------------------
function parseJson(value, fallback) {
try {
return JSON.parse(value || '') ?? fallback
} catch {
return fallback
}
}
export const filesOf = (doc) => parseJson(doc?.files, [])
export const historyOf = (doc) => parseJson(doc?.history, [])
export const checksOf = (doc) => parseJson(doc?.checks, null)
/** Verlauf anhaengen; sehr lange Verlaeufe kuerzen, damit das Attribut (60.000 Zeichen) nie ueberlaeuft. */
function withHistory(doc, entry) {
const list = [...historyOf(doc), { at: new Date().toISOString(), ...entry }]
let json = JSON.stringify(list)
for (let i = 0; json.length > 58000 && i < list.length - 1; i += 1) {
list[i] = { ...list[i], text: String(list[i].text || '').slice(0, 200) }
json = JSON.stringify(list)
}
return json
}
const serviceStartOf = (sub) => sub?.trialEnd || sub?.startedAt || null
const isActiveSub = (sub) => Boolean(sub && ACTIVE_STATUSES.has(sub.status))
export async function getTakeoverRequest(id) {
return getDocument(COLLECTION(), id)
}
export async function listTakeoverRequests({ customerId } = {}) {
const queries = [Query.orderDesc('$createdAt'), Query.limit(1000)]
if (customerId) queries.unshift(Query.equal('customerId', customerId))
return listDocuments(COLLECTION(), queries)
}
/** Ansicht fuer den Kunden: keine Pruefdetails, keine internen Notizen. */
export function customerView(doc) {
const history = historyOf(doc)
const lastQuestion = [...history].reverse().find((h) => h.type === 'rueckfrage')
return {
id: doc.$id,
ref: shortRef(doc.$id),
createdAt: doc.$createdAt,
services: servicesLabel(doc.services),
provider: doc.provider || '',
domain: doc.domain || '',
invoiceAmountCents: doc.invoiceAmountCents || 0,
billingMonths: doc.billingMonths || 0,
paidUntil: doc.paidUntil || null,
status: doc.status,
question: doc.status === STATUS.question ? lastQuestion?.text || '' : '',
amountCents: doc.status === STATUS.approved ? doc.amountCents || 0 : 0,
decisionNote: [STATUS.approved, STATUS.rejected].includes(doc.status) ? doc.decisionNote || '' : '',
}
}
// ---------------------------------------------------------------------------
// Kunde: Antrag stellen, Rueckfrage beantworten
// ---------------------------------------------------------------------------
export async function createTakeoverRequest({ customer, input, uploads, consentIp }) {
const sub = await getCurrentSubscription(customer.$id)
if (!isActiveSub(sub)) {
throw httpError(409, 'Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft.')
}
const own = await listTakeoverRequests({ customerId: customer.$id })
const open = own.filter((r) => r.status === STATUS.open || r.status === STATUS.question)
if (open.length >= config.takeover.maxOpenPerCustomer) {
throw httpError(429, 'Sie haben bereits mehrere offene Anträge. Bitte warten Sie unsere Antwort ab.')
}
const id = randomUUID()
const now = new Date().toISOString()
const files = await storeUploads(id, uploads, now)
let doc
try {
doc = await createDocument(
COLLECTION(),
{
customerId: customer.$id,
email: input.email,
services: input.services.join(','),
provider: input.provider,
domain: input.domain,
invoiceAmountCents: input.invoiceAmountCents,
billingMonths: input.billingMonths,
paidUntil: input.paidUntil,
message: input.message,
files: JSON.stringify(files),
status: STATUS.open,
history: JSON.stringify([
{ at: now, type: 'eingereicht', by: 'customer', text: '', files: files.map((f) => f.id) },
]),
conditionsVersion: config.takeover.conditionsVersion,
consentAt: now,
consentIp: String(consentIp || '').slice(0, 64),
},
id
)
} catch (err) {
await rm(path.join(config.takeover.uploadDir, id), { recursive: true, force: true }).catch(() => {})
throw err
}
const name = customer.name || customer.companyName || input.email
const summary = [
` Antrag: ${shortRef(doc.$id)}`,
` Leistung: ${servicesLabel(doc.services)}`,
` Bisher bei: ${doc.provider}`,
doc.domain ? ` Domain: ${doc.domain}` : null,
` Rechnung: ${fmtEuro(doc.invoiceAmountCents)} für ${monthsLabel(doc.billingMonths)}`,
` Bezahlt bis: ${fmtDate(doc.paidUntil)}`,
` Nachweise: ${files.map((f) => f.name).join(', ')}`,
].filter((l) => l !== null)
await sendCustomerMail(input.email, 'Eingangsbestätigung: Ihr Antrag auf Übernahmegutschrift – WEBklar', [
`Guten Tag ${customer.name || ''}`.trim() + ',',
'',
`wir haben Ihren Antrag auf eine Übernahmegutschrift am ${fmtDateTime(now)} erhalten.`,
'',
...summary,
'',
'Wir prüfen Ihre Angaben und den Nachweis und antworten Ihnen per E-Mail an diese Adresse.',
`Den Stand Ihres Antrags sehen Sie auch im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
]).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message))
// Nachweise bewusst nicht als Anhang: sie bleiben auf dem Server (Datenschutzerklaerung Abschnitt 7)
await sendAdminNotice(`Übernahmegutschrift beantragt: ${name}`, [
`${name}${customer.code ? ` (Kd.-Nr. ${customer.code})` : ''} hat eine Übernahmegutschrift beantragt.`,
`Antwort an: ${input.email}`,
'',
...summary,
'',
'Grund / Nachricht des Kunden:',
input.message,
'',
`Nachweise ansehen, prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
])
return doc
}
export async function answerQuestion({ customer, doc, message, uploads }) {
if (doc.customerId !== customer.$id) throw httpError(404, 'Antrag nicht gefunden')
if (doc.status !== STATUS.question) throw httpError(409, 'Zu diesem Antrag ist keine Rückfrage offen.')
const text = clean(message, 4000)
if (!text && !uploads.length) throw httpError(400, 'Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.')
const now = new Date().toISOString()
const added = await storeUploads(doc.$id, uploads, now)
const updated = await updateDocument(COLLECTION(), doc.$id, {
status: STATUS.open,
files: JSON.stringify([...filesOf(doc), ...added]),
history: withHistory(doc, { type: 'antwort', by: 'customer', text, files: added.map((f) => f.id) }),
})
const name = customer.name || customer.companyName || doc.email
await sendAdminNotice(`Übernahmegutschrift: Antwort von ${name}`, [
`${name} hat auf die Rückfrage zum Antrag ${shortRef(doc.$id)} geantwortet:`,
'',
text || '(ohne Text)',
...(added.length ? ['', `Neue Nachweise: ${added.map((f) => f.name).join(', ')}`] : []),
'',
`Prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
])
return updated
}
// ---------------------------------------------------------------------------
// Automatische Pruefungen (Hinweise fuer den Admin, keine Entscheidung)
// ---------------------------------------------------------------------------
// Nameserver-/MX-Hosts bekannter Anbieter -> Name; Aliase fuer den Abgleich mit der Angabe im Antrag
const PROVIDERS = [
{ name: 'IONOS (1&1)', hosts: /(^|\.)(ui-dns\.(de|com|org|biz)|ionos\.[a-z]+|kundenserver\.de|1and1\.[a-z]+|schlund\.de)$/, aliases: ['ionos', '1und1', '1and1', '11', 'united internet'] },
{ name: 'STRATO', hosts: /(^|\.)(rzone\.de|strato\.[a-z]+)$/, aliases: ['strato'] },
{ name: 'Hetzner', hosts: /(^|\.)(hetzner\.(com|de)|your-server\.de|second-ns\.(de|com))$/, aliases: ['hetzner'] },
{ name: 'ALL-INKL.COM', hosts: /(^|\.)(kasserver\.com|all-inkl\.com)$/, aliases: ['allinkl', 'kasserver'] },
{ name: 'netcup', hosts: /(^|\.)netcup\.(net|de)$/, aliases: ['netcup'] },
{ name: 'Hostinger', hosts: /(^|\.)(hostinger\.[a-z]+|dns-parking\.com)$/, aliases: ['hostinger'] },
{ name: 'GoDaddy', hosts: /(^|\.)(domaincontrol\.com|secureserver\.net)$/, aliases: ['godaddy', 'host europe', 'hosteurope'] },
{ name: 'Host Europe', hosts: /(^|\.)hosteurope\.de$/, aliases: ['hosteurope'] },
{ name: 'Cloudflare', hosts: /(^|\.)cloudflare\.com$/, aliases: ['cloudflare'] },
{ name: 'Google', hosts: /(^|\.)(google\.com|googledomains\.com)$/, aliases: ['google', 'workspace', 'gsuite'] },
{ name: 'Microsoft 365', hosts: /(^|\.)(outlook\.com|office365\.com)$/, aliases: ['microsoft', 'office', 'outlook', 'm365'] },
{ name: 'united-domains', hosts: /(^|\.)(udag\.(de|net|org)|united-domains\.de)$/, aliases: ['uniteddomains', 'udag'] },
{ name: 'domainfactory', hosts: /(^|\.)(namespace4you\.de|ispgateway\.de|df\.eu)$/, aliases: ['domainfactory', 'df'] },
{ name: 'one.com', hosts: /(^|\.)one\.com$/, aliases: ['onecom'] },
{ name: 'OVH', hosts: /(^|\.)ovh\.(net|de|com)$/, aliases: ['ovh'] },
{ name: 'checkdomain', hosts: /(^|\.)checkdomain\.de$/, aliases: ['checkdomain'] },
{ name: 'Namecheap', hosts: /(^|\.)(registrar-servers\.com|namecheap\.com)$/, aliases: ['namecheap'] },
{ name: 'Porkbun', hosts: /(^|\.)porkbun\.com$/, aliases: ['porkbun'] },
{ name: 'Wix', hosts: /(^|\.)wixdns\.net$/, aliases: ['wix'] },
{ name: 'Jimdo', hosts: /(^|\.)jimdo\.com$/, aliases: ['jimdo'] },
{ name: 'Mittwald', hosts: /(^|\.)mittwald\.de$/, aliases: ['mittwald'] },
]
const squash = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '')
function detectProviders(hosts) {
const found = new Map()
for (const host of hosts) {
const h = String(host || '').toLowerCase().replace(/\.$/, '')
const p = PROVIDERS.find((x) => x.hosts.test(h))
if (p) found.set(p.name, p)
}
return [...found.values()]
}
function providerMatches(provider, claimed) {
const c = squash(claimed)
if (!c) return false
return [provider.name, ...provider.aliases].some((a) => {
const s = squash(a)
return s.length >= 2 && (c.includes(s) || s.includes(c))
})
}
let ownIpsCache = null
async function ownServerIps(resolver) {
if (!ownIpsCache?.length) {
ownIpsCache = await resolver.resolve4(new URL(config.publicBaseUrl).hostname).catch(() => [])
}
return ownIpsCache
}
async function dnsCheck(domain, claimedProvider) {
const resolver = new Resolver({ timeout: 3000, tries: 2 })
const settle = (p) => p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, code: e.code }))
const host = domainToASCII(domain)
const [ns, mx, a, own] = await Promise.all([
settle(resolver.resolveNs(host)),
settle(resolver.resolveMx(host)),
settle(resolver.resolve4(host)),
ownServerIps(resolver),
])
const nsHosts = ns.ok ? ns.v : []
const mxHosts = mx.ok ? mx.v.sort((x, y) => x.priority - y.priority).map((m) => m.exchange) : []
const ips = a.ok ? a.v : []
const detail = [
`Nameserver: ${nsHosts.join(', ') || '–'}`,
`Mailserver: ${mxHosts.join(', ') || '–'}`,
`IP: ${ips.join(', ') || '–'}`,
].join(' · ')
if (!ns.ok && !a.ok && ['ENOTFOUND', 'ENODATA'].includes(ns.code)) {
return { key: 'dns', status: 'warn', label: `Domain ${domain} ist im DNS nicht auffindbar`, detail }
}
const results = []
if (ips.length && ips.some((ip) => own.includes(ip))) {
results.push({ key: 'dns-webklar', status: 'info', label: 'Die Domain zeigt bereits auf den WEBklar-Server', detail: `IP: ${ips.join(', ')}` })
}
const detected = detectProviders([...nsHosts, ...mxHosts])
const match = detected.find((p) => providerMatches(p, claimedProvider))
if (match) {
results.push({ key: 'dns', status: 'ok', label: `DNS passt zum angegebenen Anbieter (${match.name})`, detail })
} else if (detected.length) {
results.push({
key: 'dns',
status: 'info',
label: `DNS zeigt auf ${detected.map((p) => p.name).join(' / ')}, im Antrag steht „${claimedProvider}“ – mit der Rechnung abgleichen`,
detail,
})
} else {
results.push({ key: 'dns', status: 'info', label: 'Anbieter aus dem DNS nicht erkennbar – Einträge mit der Rechnung abgleichen', detail })
}
return results
}
/**
* Hinweise fuer die Pruefung: doppelte Nachweise, schon gutgeschriebene Domain, Zeitraum,
* Abo-Status, Plausibilitaet des Betrags und ein DNS-Abgleich der Domain mit dem Anbieter.
*/
export async function runAutoChecks(doc, { allRequests, subscription, calc }) {
const checks = []
const others = allRequests.filter((r) => r.$id !== doc.$id)
const ref = (r) => `${shortRef(r.$id)} (${r.status}, ${fmtDate(r.$createdAt)})`
const hashes = new Set(filesOf(doc).map((f) => f.sha256))
const dupFiles = others.filter((r) => filesOf(r).some((f) => hashes.has(f.sha256)))
checks.push(
dupFiles.length
? { key: 'duplicate-file', status: 'warn', label: 'Derselbe Nachweis wurde schon mit einem anderen Antrag eingereicht', detail: dupFiles.map(ref).join(', ') }
: { key: 'duplicate-file', status: 'ok', label: 'Nachweis wurde noch nicht mit einem anderen Antrag eingereicht' }
)
if (doc.domain) {
const sameDomain = others.filter((r) => r.domain === doc.domain && r.status === STATUS.approved)
checks.push(
sameDomain.length
? { key: 'duplicate-domain', status: 'warn', label: `Für ${doc.domain} wurde schon eine Gutschrift genehmigt`, detail: sameDomain.map(ref).join(', ') }
: { key: 'duplicate-domain', status: 'ok', label: `Für ${doc.domain} gibt es noch keine genehmigte Gutschrift` }
)
}
if (!subscription) {
checks.push({ key: 'abo', status: 'warn', label: 'Kunde hat kein Wartungs-Abo – eine Gutschrift kann nicht verrechnet werden' })
} else if (!isActiveSub(subscription)) {
checks.push({ key: 'abo', status: 'warn', label: `Wartungs-Abo ist beendet (Status ${subscription.status}) – keine Verrechnung möglich` })
} else {
checks.push({
key: 'abo',
status: subscription.cancelAtPeriodEnd ? 'info' : 'ok',
label: `Wartungs-Abo läuft, Leistungsbeginn ${fmtDate(serviceStartOf(subscription))}${subscription.cancelAtPeriodEnd ? `, gekündigt zum ${fmtDate(subscription.cancelAt || subscription.currentPeriodEnd)}` : ''}`,
})
}
if (calc) {
checks.push(
calc.overlapDays > 0
? { key: 'period', status: 'ok', label: `Doppelt bezahlt: ${calc.overlapDays} Tage (${fmtDate(calc.overlapStart)} – ${fmtDate(calc.periodEnd)})` }
: { key: 'period', status: 'warn', label: 'Der bezahlte Zeitraum endet vor dem Leistungsbeginn des Abos – keine Doppelzahlung' }
)
}
const monthly = doc.invoiceAmountCents / Math.max(1, doc.billingMonths)
if (monthly > config.stripe.priceAmountCents) {
checks.push({
key: 'amount',
status: 'info',
label: `Beim bisherigen Anbieter ${fmtEuro(monthly)} pro Monat – mehr als das Abo. Enthält die Rechnung weitere Leistungen?`,
})
}
if (doc.domain) {
try {
checks.push(...[].concat(await dnsCheck(doc.domain, doc.provider)))
} catch (err) {
checks.push({ key: 'dns', status: 'info', label: 'DNS-Abfrage fehlgeschlagen', detail: err.message })
}
} else {
checks.push({ key: 'dns', status: 'info', label: 'Keine Domain angegeben – kein DNS-Abgleich möglich' })
}
return checks
}
// ---------------------------------------------------------------------------
// Admin: Detailansicht, Entscheidung, Verrechnung
// ---------------------------------------------------------------------------
function defaultTakeoverFrom(doc, subscription) {
return doc.takeoverFrom || serviceStartOf(subscription) || new Date().toISOString()
}
async function stripeCreditBalance(stripeCustomerId) {
if (!stripeCustomerId || !isStripeConfigured()) return null
try {
const customer = await getStripe().customers.retrieve(stripeCustomerId)
if (customer.deleted) return null
// Stripe: negativer Saldo = Guthaben des Kunden
return Math.max(0, -(customer.balance || 0))
} catch (err) {
console.warn('[takeover] Stripe-Guthaben nicht abrufbar:', err.message)
return null
}
}
export async function getAdminDetail(id) {
const doc = await getTakeoverRequest(id)
const [customer, subscription, allRequests] = await Promise.all([
getDocument(config.collections.customers, doc.customerId).catch(() => null),
getCurrentSubscription(doc.customerId).catch(() => null),
listTakeoverRequests(),
])
const takeoverFrom = defaultTakeoverFrom(doc, subscription)
const calc = computeTakeoverCredit({ ...doc, takeoverFrom })
const [checks, creditBalanceCents] = await Promise.all([
runAutoChecks(doc, { allRequests, subscription, calc }),
stripeCreditBalance(subscription?.stripeCustomerId),
])
const testMode = /^(sk|rk)_test_/.test(config.stripe.secretKey)
return {
request: {
id: doc.$id,
ref: shortRef(doc.$id),
createdAt: doc.$createdAt,
email: doc.email,
services: servicesLabel(doc.services),
serviceKeys: String(doc.services || '').split(',').filter(Boolean),
provider: doc.provider,
domain: doc.domain,
invoiceAmountCents: doc.invoiceAmountCents,
billingMonths: doc.billingMonths,
paidUntil: doc.paidUntil,
// Kalendertage fuer <input type="date"> im Rechner
paidUntilDate: ymd(dayOf(doc.paidUntil)),
takeoverFromDate: ymd(dayOf(takeoverFrom)),
message: doc.message,
status: doc.status,
files: filesOf(doc).map(({ id: fileId, name, type, size, uploadedAt }) => ({ id: fileId, name, type, size, uploadedAt })),
history: historyOf(doc),
takeoverFrom,
amountCents: doc.amountCents ?? null,
calculation: doc.calculation || '',
checks: checksOf(doc),
decisionNote: doc.decisionNote || '',
decidedAt: doc.decidedAt || null,
decidedBy: doc.decidedBy || '',
appliedAt: doc.appliedAt || null,
stripeBalanceTransactionId: doc.stripeBalanceTransactionId || '',
conditionsVersion: doc.conditionsVersion || '',
consentAt: doc.consentAt || null,
},
customer: customer
? { id: customer.$id, code: customer.code || '', name: customer.name || '', companyName: customer.companyName || '', email: customer.email || '' }
: { id: doc.customerId, code: '', name: '(Kunde nicht gefunden)', companyName: '', email: '' },
subscription: subscription
? {
status: subscription.status,
active: isActiveSub(subscription),
startedAt: subscription.startedAt,
serviceStart: serviceStartOf(subscription),
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAt: subscription.cancelAtPeriodEnd ? subscription.cancelAt || subscription.currentPeriodEnd : null,
stripeCustomerId: subscription.stripeCustomerId,
stripeUrl: subscription.stripeCustomerId
? `https://dashboard.stripe.com/${testMode ? 'test/' : ''}customers/${subscription.stripeCustomerId}`
: '',
creditBalanceCents,
}
: null,
calc,
autoChecks: checks,
requiredChecks: REQUIRED_CHECKS,
otherRequests: allRequests
.filter((r) => r.customerId === doc.customerId && r.$id !== doc.$id)
.map((r) => ({ id: r.$id, ref: shortRef(r.$id), createdAt: r.$createdAt, status: r.status, amountCents: r.amountCents ?? null, services: servicesLabel(r.services) })),
}
}
async function findBalanceTransaction(stripeCustomerId, requestId) {
const list = await getStripe().customers.listBalanceTransactions(stripeCustomerId, { limit: 100 })
return list.data.find((t) => t.metadata?.webklarTakeoverCreditId === requestId) || null
}
/**
* Genehmigte Gutschrift als Stripe-Kundenguthaben buchen. Doppelt abgesichert gegen
* Doppelbuchung: vorhandene Buchung mit dieser Antrags-ID wird wiederverwendet, und der
* Idempotency-Key faengt parallele Klicks ab.
*/
export async function applyTakeoverCredit(doc) {
if (doc.status !== STATUS.approved) throw httpError(409, 'Nur genehmigte Anträge können verrechnet werden.')
if (doc.appliedAt) return { applied: true, doc }
if (!isStripeConfigured()) return { applied: false, doc, reason: 'Stripe ist nicht eingerichtet.' }
const sub = await getCurrentSubscription(doc.customerId)
if (!isActiveSub(sub) || !sub.stripeCustomerId) {
return { applied: false, doc, reason: 'Der Kunde hat kein laufendes Wartungs-Abo.' }
}
try {
const txn =
(await findBalanceTransaction(sub.stripeCustomerId, doc.$id)) ||
(await getStripe().customers.createBalanceTransaction(
sub.stripeCustomerId,
{
amount: -doc.amountCents,
currency: 'eur',
description: `Übernahmegutschrift ${shortRef(doc.$id)} (${servicesLabel(doc.services)})`,
metadata: { webklarTakeoverCreditId: doc.$id, webklarCustomerId: doc.customerId },
},
{ idempotencyKey: `webklar-takeover-credit-${doc.$id}` }
))
const updated = await updateDocument(COLLECTION(), doc.$id, {
appliedAt: new Date(txn.created * 1000).toISOString(),
stripeCustomerId: sub.stripeCustomerId,
stripeBalanceTransactionId: txn.id,
history: withHistory(doc, { type: 'verrechnet', by: 'system', text: `Stripe-Guthaben ${fmtEuro(doc.amountCents)} gebucht (${txn.id})` }),
})
return { applied: true, doc: updated }
} catch (err) {
console.error(`[takeover] Verrechnung ${doc.$id} fehlgeschlagen:`, err.message)
return { applied: false, doc, reason: `Stripe: ${err.message}` }
}
}
function customerGreeting(doc, customer) {
return `Guten Tag ${customer?.name || ''}`.trim() + ','
}
async function mailDecision(doc, customer, action) {
const ref = shortRef(doc.$id)
const submitted = fmtDate(doc.$createdAt)
const note = String(doc.decisionNote || '').trim()
const footer = ['', 'Viele Grüße', 'Ihr WEBklar-Team', '', FOOTER]
if (action === 'approve') {
return sendCustomerMail(doc.email, 'Ihre Übernahmegutschrift ist genehmigt – WEBklar', [
customerGreeting(doc, customer),
'',
`wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} geprüft und genehmigt.`,
'',
` Antrag: ${ref}`,
` Leistung: ${servicesLabel(doc.services)} (bisher bei ${doc.provider})`,
` Gutschrift: ${fmtEuro(doc.amountCents)}`,
` Berechnung: ${doc.calculation}`,
...(note ? ['', note] : []),
'',
'Die Gutschrift verrechnen wir automatisch mit den nächsten Monatsgebühren Ihres Wartungs-Abos.',
'Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben. Eine Auszahlung ist nicht möglich;',
'ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
...footer,
])
}
if (action === 'reject') {
return sendCustomerMail(doc.email, 'Ihr Antrag auf Übernahmegutschrift – WEBklar', [
customerGreeting(doc, customer),
'',
`wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) geprüft und können ihn leider nicht genehmigen.`,
'',
note,
'',
'Bei Fragen antworten Sie einfach auf diese E-Mail.',
...footer,
])
}
const question = [...historyOf(doc)].reverse().find((h) => h.type === 'rueckfrage')?.text || ''
return sendCustomerMail(doc.email, 'Rückfrage zu Ihrem Antrag auf Übernahmegutschrift – WEBklar', [
customerGreeting(doc, customer),
'',
`zu Ihrem Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) haben wir eine Rückfrage:`,
'',
question,
'',
`Bitte antworten Sie im Kundenportal unter ${config.publicBaseUrl}/dashboard.html – dort können Sie`,
'auch weitere Nachweise hochladen. Alternativ antworten Sie einfach auf diese E-Mail.',
...footer,
])
}
/**
* Entscheidung des Admins.
* action: 'approve' (amountCents, takeoverFrom, checks, note optional) | 'question' (note) | 'reject' (note)
*/
export async function decideTakeoverRequest(id, body, admin) {
const doc = await getTakeoverRequest(id)
if (![STATUS.open, STATUS.question].includes(doc.status)) {
throw httpError(409, 'Über diesen Antrag wurde bereits entschieden.')
}
const action = String(body?.action || '')
const note = clean(body?.note, 4000)
const by = admin?.name ? `${admin.name}${admin.email ? ` <${admin.email}>` : ''}` : admin?.email || 'Admin'
const now = new Date().toISOString()
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
if (action === 'question' || action === 'reject') {
if (note.length < 5) {
throw httpError(400, action === 'question' ? 'Bitte formulieren Sie die Rückfrage an den Kunden.' : 'Bitte begründen Sie die Ablehnung für den Kunden.')
}
const data =
action === 'question'
? { status: STATUS.question, history: withHistory(doc, { type: 'rueckfrage', by, text: note }) }
: {
status: STATUS.rejected,
decisionNote: note,
decidedAt: now,
decidedBy: by,
history: withHistory(doc, { type: 'abgelehnt', by, text: note }),
}
const updated = await updateDocument(COLLECTION(), doc.$id, data)
const mail = await mailDecision(updated, customer, action).then(
() => ({ sent: true }),
(e) => ({ sent: false, reason: e.message })
)
return { doc: updated, mail }
}
if (action !== 'approve') throw httpError(400, 'Unbekannte Aktion')
const amountCents = Math.round(Number(body?.amountCents))
if (!(amountCents > 0) || amountCents > config.takeover.maxAmountCents) {
throw httpError(400, `Bitte einen Gutschriftsbetrag zwischen 0,01 € und ${fmtEuro(config.takeover.maxAmountCents)} angeben.`)
}
const missing = REQUIRED_CHECKS.filter((c) => body?.checks?.[c.key] !== true)
if (missing.length) {
throw httpError(400, `Bitte zuerst prüfen und abhaken: ${missing.map((c) => c.label).join('; ')}`)
}
const takeoverFrom = dayOf(body?.takeoverFrom)
if (!takeoverFrom) throw httpError(400, 'Bitte das Datum „Übernahme ab“ angeben.')
// Werte laut Rechnung: der Admin kann Tippfehler des Kunden im Rechner korrigieren
const verified = readInvoiceFigures(body, doc)
const sub = await getCurrentSubscription(doc.customerId)
if (!isActiveSub(sub)) {
throw httpError(409, 'Der Kunde hat kein laufendes Wartungs-Abo – die Gutschrift kann nicht verrechnet werden.')
}
const corrections = [
verified.invoiceAmountCents !== doc.invoiceAmountCents &&
`Rechnungsbetrag ${fmtEuro(doc.invoiceAmountCents)} → ${fmtEuro(verified.invoiceAmountCents)}`,
verified.billingMonths !== doc.billingMonths &&
`Zeitraum ${monthsLabel(doc.billingMonths)} → ${monthsLabel(verified.billingMonths)}`,
ymd(dayOf(verified.paidUntil)) !== ymd(dayOf(doc.paidUntil)) &&
`bezahlt bis ${fmtDate(doc.paidUntil)} → ${fmtDate(verified.paidUntil)}`,
].filter(Boolean)
const calc = computeTakeoverCredit({ ...verified, takeoverFrom: takeoverFrom.toISOString() })
const approved = await updateDocument(COLLECTION(), doc.$id, {
...verified,
status: STATUS.approved,
amountCents,
takeoverFrom: takeoverFrom.toISOString(),
calculation: calculationText(verified, calc, amountCents).slice(0, 1000),
checks: JSON.stringify({ items: Object.fromEntries(REQUIRED_CHECKS.map((c) => [c.key, true])), by, at: now }),
decisionNote: note,
decidedAt: now,
decidedBy: by,
history: withHistory(doc, {
type: 'genehmigt',
by,
text: [
fmtEuro(amountCents),
corrections.length ? `Angaben nach Prüfung korrigiert: ${corrections.join(', ')}` : '',
note,
].filter(Boolean).join(' – '),
}),
})
const result = await applyTakeoverCredit(approved)
const mail = await mailDecision(result.doc, customer, 'approve').then(
() => ({ sent: true }),
(e) => ({ sent: false, reason: e.message })
)
return { doc: result.doc, applied: result.applied, reason: result.reason, mail }
}
// ---------------------------------------------------------------------------
// Aufbewahrung: Nachweise abgelehnter Antraege nach Ablauf der Frist loeschen
// ---------------------------------------------------------------------------
export async function purgeExpiredUploads() {
const cutoff = Date.now() - config.takeover.rejectedRetentionDays * DAY_MS
const docs = await listDocuments(COLLECTION(), [Query.equal('status', STATUS.rejected), Query.limit(1000)])
let purged = 0
for (const doc of docs) {
if (!filesOf(doc).length || !doc.decidedAt || new Date(doc.decidedAt).getTime() > cutoff) continue
await rm(path.join(config.takeover.uploadDir, doc.$id), { recursive: true, force: true })
await updateDocument(COLLECTION(), doc.$id, {
files: '[]',
history: withHistory(doc, { type: 'nachweise-geloescht', by: 'system', text: 'Aufbewahrungsfrist abgelaufen' }),
})
purged += 1
}
return { purged }
}