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 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_nr: shortRef(doc.$id), leistung: servicesLabel(doc.services), bisheriger_anbieter: doc.provider, domain: doc.domain || '–', rechnung_betrag: fmtEuro(doc.invoiceAmountCents), rechnung_zeitraum: monthsLabel(doc.billingMonths), bezahlt_bis: fmtDate(doc.paidUntil), nachweise: files.map((f) => f.name).join(', '), } await sendCustomerMail(input.email, { template: 'K07', values: { name: customer.name || '', eingang_zeitpunkt: fmtDateTime(now), ...summary }, }).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message)) // Nachweise bewusst nicht als Anhang: sie bleiben auf dem Server (Datenschutzerklaerung Abschnitt 7) await sendAdminNotice({ template: 'T10', values: { kunde_name: name, kundennummer: customer.code || '–', email: input.email, ...summary, nachricht: input.message, admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`, }, blocks: { nachricht: Boolean(input.message) }, }) 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) }), }) await sendAdminNotice({ template: 'T11', values: { kunde_name: customer.name || customer.companyName || doc.email, antrag_nr: shortRef(doc.$id), antwort: text || '(ohne Text)', neue_nachweise: added.map((f) => f.name).join(', '), admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`, }, blocks: { neue_nachweise: added.length > 0 }, }) 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 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}` } } } async function mailDecision(doc, customer, action) { const common = { name: customer?.name || '', antrag_datum: fmtDate(doc.$createdAt), antrag_nr: shortRef(doc.$id) } const note = String(doc.decisionNote || '').trim() if (action === 'approve') { return sendCustomerMail(doc.email, { template: 'K09', values: { ...common, gutschrift_betrag: fmtEuro(doc.amountCents), leistung: servicesLabel(doc.services), bisheriger_anbieter: doc.provider, berechnung: doc.calculation || '–', anmerkung: note, }, blocks: { anmerkung: Boolean(note) }, }) } if (action === 'reject') { return sendCustomerMail(doc.email, { template: 'K10', values: { ...common, begruendung: note } }) } const question = [...historyOf(doc)].reverse().find((h) => h.type === 'rueckfrage')?.text || '' return sendCustomerMail(doc.email, { template: 'K08', values: { ...common, rueckfrage_text: question } }) } /** * 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 } }