- Kopfzeile mit Firmenname, Kundennr., Initialen-Avatar und Abmelden - Seitenleiste: Navigation Projekte/Fortschritt/Rechnungen/Chat mit Zaehlern (offene Rechnungen, ungelesene Nachrichten), "Dein Ansprechpartner" (Mitarbeiter des juengsten zugewiesenen Tickets, sonst WEBklar-Team) und Wartungs-Abo-Karte (Angebot bzw. Status, Stunden, naechste Abbuchung, Abo verwalten, Uebernahmegutschrift, Widerruf, Mindestlaufzeit, Kuendigen) - Projektkarte: Vorschau mit "Website oeffnen", Status, Vorschau-Adresse, Live-Domain, letzte Aenderung, Schnellzugriffe, Projektdetails - Abo-Bestellung und Stundenkonto/Verlauf sowie Uebernahmegutschrift als eigene Ansichten; Direktlinks #abo/#gutschrift (auch ueber den Login) - Pflichtlinks (Kuendigungsbutton, Widerruf, Bedingungen, Datenschutz, Impressum) als Fusszeile; Systemschriften statt externer Fonts - Mobil: Navigation im 2x2-Raster, kein horizontales Scrollen - API: /api/contact, updatedAt in /api/projects; Mail-Links auf Ansichten Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
972 lines
42 KiB
JavaScript
972 lines
42 KiB
JavaScript
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#gutschrift`,
|
||
'',
|
||
'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#gutschrift – 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 }
|
||
}
|