Files
Webklar-Kundenbereich/server/services/takeoverCredits.js
T

944 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { getProduct, PRODUCT_KEYS } from './products.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-Postfächer',
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 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))
// Produkt des Abos: Altbestand ohne productKey und unbekannte Schluessel gelten als Wartungs-Abo
const productKeyOf = (sub) => (PRODUCT_KEYS.includes(sub?.productKey) ? sub.productKey : 'wartung')
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 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 Abo – eine Gutschrift kann nicht verrechnet werden' })
} else if (!isActiveSub(subscription)) {
checks.push({ key: 'abo', status: 'warn', label: `Abo ist beendet (Status ${subscription.status}) – keine Verrechnung möglich` })
} else {
checks.push({
key: 'abo',
status: subscription.cancelAtPeriodEnd ? 'info' : 'ok',
label: `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' }
)
}
// Plausibilitaet gegen die Monatsgebuehr des Abos des Kunden (productKeyOf: ohne bzw. mit unbekanntem Schluessel = Wartungs-Abo)
const monthly = doc.invoiceAmountCents / Math.max(1, doc.billingMonths)
const aboCents = getProduct(productKeyOf(subscription)).amountCents
if (monthly > aboCents) {
checks.push({
key: 'amount',
status: 'info',
label: `Beim bisherigen Anbieter ${fmtEuro(monthly)} pro Monat – mehr als die Monatsgebühr des Abos (${fmtEuro(aboCents)}). 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),
productKey: productKeyOf(subscription),
productName: getProduct(productKeyOf(subscription)).name,
// Zusatzspeicher (Pakete zu 5 GB) fuer die Zeile "Abo" im Admin (Task 13)
storageUnits: Number(subscription.storageUnits) || 0,
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 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 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 }
}