From 48b13f44887326631a6d4bf58b9665645c175055 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:34:33 +0000 Subject: [PATCH] Abo: Uebernahmegutschrift fuer beim Altanbieter bezahlte Leistungen - Kunde (Dashboard, nur mit laufendem Abo): Antrag mit Leistung, Anbieter, Domain, Rechnungsbetrag/-zeitraum, Grund, Rechnungsnachweis (PDF/JPG/PNG) und E-Mail fuer die Antwort; Eingangsbestaetigung per Mail, Status und Antwort auf Rueckfragen im Portal - Admin-Mail mit Link admin.html?gutschrift= (Login leitet zurueck) - Admin-Tab "Gutschriften": Kunde und Vertrag, Nachweise, Verlauf, automatische Pruefungen (Duplikate per Datei-Hash/Domain, Abo, Zeitraum, DNS-Abgleich Nameserver/MX mit Anbieter), Pflicht-Checkliste, Rechner (tagesgenau anteilig), Genehmigen/Rueckfrage/Ablehnen mit Kundenmail - Genehmigte Gutschrift als Stripe-Kundenguthaben (idempotent, Nachbuchen bei Stripe-Stoerung) - Nachweise in upload-data/ (gitignored), Loeschung abgelehnter nach 6 Monaten - Stundenkontingent auch fuer Monate, die ein Guthaben komplett deckt (invoice.total statt amount_paid) - Datenschutzerklaerung Abschnitt 7 ergaenzt, PDF neu erzeugt - Collection takeoverCredits in setup-abo-collections.mjs Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + docs/WEBklar-Datenschutzerklaerung.pdf | Bin 85123 -> 88575 bytes public/admin.html | 62 ++ public/admin.js | 403 +++++++++- public/app.js | 264 ++++++- public/dashboard.html | 37 + public/datenschutz.html | 1 + scripts/setup-abo-collections.mjs | 43 +- server/config.js | 18 + server/index.js | 17 +- server/routes/portalAdmin.js | 4 + server/routes/takeoverCredits.js | 224 ++++++ server/services/stripeBilling.js | 4 +- server/services/takeoverCredits.js | 971 +++++++++++++++++++++++++ 14 files changed, 2040 insertions(+), 9 deletions(-) create mode 100644 server/routes/takeoverCredits.js create mode 100644 server/services/takeoverCredits.js diff --git a/.gitignore b/.gitignore index 42d77f4..b5af02b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ preview-data/ server/graphify-out/ graphify-out/ +upload-data/ diff --git a/docs/WEBklar-Datenschutzerklaerung.pdf b/docs/WEBklar-Datenschutzerklaerung.pdf index 5ea52c0f83878698ba540114bf65f17123f1b191..41adad75f17fdd6df613921937de322398832039 100644 GIT binary patch delta 54171 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 diff --git a/public/admin.html b/public/admin.html index 45f486a..a5ec056 100644 --- a/public/admin.html +++ b/public/admin.html @@ -82,6 +82,49 @@ background: rgba(255,255,255,0.06); color: inherit; font: inherit; } .chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; } + + /* Übernahmegutschriften */ + .credit-filter { display: flex; gap: 8px; margin-bottom: 12px; } + .credit-filter button { + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); + color: inherit; padding: 5px 13px; border-radius: 999px; cursor: pointer; font: inherit; font-size: 13px; + } + .credit-filter button.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); } + tr.credit-active td { background: rgba(94,234,212,0.07); } + .credit-detail { margin-top: 18px; } + .credit-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } + .credit-head h3 { margin: 0; } + .credit-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 22px; } + .credit-cols h4 { margin: 16px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; } + .credit-cols h4:first-child { margin-top: 4px; } + .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 14px; margin: 0; } + .kv dt { opacity: 0.6; } + .kv dd { margin: 0; overflow-wrap: anywhere; } + .pre { white-space: pre-wrap; font-size: 14px; margin: 0; overflow-wrap: anywhere; } + .check-list { list-style: none; margin: 0; padding: 0; font-size: 14px; } + .check-list li { padding: 5px 0 5px 26px; position: relative; border-bottom: 1px solid rgba(255,255,255,0.06); } + .check-list li:last-child { border-bottom: none; } + .check-list li::before { position: absolute; left: 2px; top: 5px; font-weight: 700; } + .check-list li.ok::before { content: '✓'; color: #4ade80; } + .check-list li.warn::before { content: '!'; color: #fbbf24; left: 6px; } + .check-list li.info::before { content: 'i'; color: #7dd3fc; left: 6px; font-style: italic; } + .check-list small { display: block; opacity: 0.6; font-size: 12px; overflow-wrap: anywhere; } + .manual-checks label { display: flex; gap: 9px; align-items: flex-start; font-size: 14px; padding: 4px 0; cursor: pointer; } + .manual-checks input { margin-top: 3px; width: 16px; height: 16px; accent-color: #5eead4; flex: none; } + .calc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; } + .calc-grid label, .credit-field label { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 4px; } + .credit-input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 9px; border: 1px solid rgba(255,255,255,0.2); + background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 14px; } + .credit-input option { color: #111; } + textarea.credit-input { min-height: 80px; resize: vertical; } + .calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); } + .credit-field { margin-top: 10px; } + .credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } + .mini-btn:disabled { opacity: 0.45; cursor: default; } + .mini-btn.danger { border-color: rgba(248,113,113,0.5); } + .credit-msg { margin-top: 10px; font-size: 14px; } + .history-item { font-size: 13px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); } + .history-item:last-child { border-bottom: none; } @@ -107,6 +150,7 @@ + @@ -169,6 +213,24 @@ + + +
+
+ + +
+
+ + + + + +
EingangKundeLeistung / bisher beiRechnungBezahlt bisStatus
Lade…
+
+ +

Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.

+
diff --git a/public/admin.js b/public/admin.js index e08df62..dfa3386 100644 --- a/public/admin.js +++ b/public/admin.js @@ -399,6 +399,379 @@ async function openChatThread(customerId, customerName) { adminChat.threadTimer = setInterval(pollAdminThread, 15000) } +/* ---------------------------------------------- Übernahmegutschriften --- */ +const CREDIT_STATUS = { + offen: ['pill-warn', 'Zu prüfen'], + rueckfrage: ['pill-muted', 'Rückfrage beim Kunden'], + genehmigt: ['pill-ok', 'Genehmigt'], + abgelehnt: ['pill-red', 'Abgelehnt'], +} + +const CREDIT_HISTORY_LABELS = { + eingereicht: 'Antrag eingereicht', + rueckfrage: 'Rückfrage an den Kunden', + antwort: 'Antwort des Kunden', + genehmigt: 'Genehmigt', + abgelehnt: 'Abgelehnt', + verrechnet: 'In Stripe gutgeschrieben', + 'nachweise-geloescht': 'Nachweise gelöscht', +} + +const CREDIT_MONTHS = [1, 3, 6, 12, 24, 36] + +const creditState = { filter: 'open', activeId: '', detail: null, calcTimer: null, amountTouched: false } + +const euroFromCents = (cents) => fmtEuro((Number(cents) || 0) / 100) + +function fmtBytes(bytes) { + const n = Number(bytes || 0) + return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB` +} + +function creditStatusPill(status) { + const [cls, label] = CREDIT_STATUS[status] || ['pill-muted', status] + return `${label}` +} + +function updateCreditBadge(open) { + const badge = document.getElementById('admin-credit-badge') + if (!badge) return + badge.textContent = open + badge.classList.toggle('hidden', !(open > 0)) +} + +async function pollCreditBadge() { + if (document.hidden) return + try { + const { open } = await api('/api/portal-admin/takeover-credits/open-count') + updateCreditBadge(open) + } catch { + /* ignore */ + } +} + +async function loadCredits() { + const tbody = document.getElementById('credit-rows') + try { + const { requests, openCount } = await api(`/api/portal-admin/takeover-credits?status=${creditState.filter}`) + updateCreditBadge(openCount) + if (!requests.length) { + tbody.innerHTML = `${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}` + return + } + tbody.innerHTML = requests.map((r) => ` + + ${fmtDateTime(r.createdAt)}
${escapeHtml(r.ref)} + ${escapeHtml(r.customerName)}${r.customerCode ? `
Kd.-Nr. ${escapeHtml(r.customerCode)}` : ''} + ${escapeHtml(r.services)}
${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''} + ${euroFromCents(r.invoiceAmountCents)}
für ${r.billingMonths} Mon. + ${fmtDate(r.paidUntil)} + ${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `
${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}` : ''} + + `).join('') + tbody.querySelectorAll('[data-open-credit]').forEach((btn) => + btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit))) + } catch (err) { + tbody.innerHTML = `${escapeHtml(err.message)}` + } +} + +function setCreditUrl(id) { + const url = new URL(window.location.href) + if (id) url.searchParams.set('gutschrift', id) + else url.searchParams.delete('gutschrift') + window.history.replaceState(null, '', url) +} + +function closeCreditDetail() { + creditState.activeId = '' + document.getElementById('credit-detail').classList.add('hidden') + document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.remove('credit-active')) + setCreditUrl('') +} + +async function openCreditDetail(id, { message = '' } = {}) { + creditState.activeId = id + creditState.amountTouched = false + const panel = document.getElementById('credit-detail') + panel.classList.remove('hidden') + panel.innerHTML = '

Lade Antrag…

' + document.querySelectorAll('[data-credit-row]').forEach((tr) => + tr.classList.toggle('credit-active', tr.dataset.creditRow === id)) + setCreditUrl(id) + try { + const d = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}`) + creditState.detail = d + renderCreditDetail(panel, d, message) + } catch (err) { + panel.innerHTML = `

${escapeHtml(err.message)}

` + } + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }) +} + +function calcSummary(calc) { + return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` + + `Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${euroFromCents(calc.suggestedCents)}` +} + +function creditDecisionFormHtml(d) { + const r = d.request + const suggested = d.calc ? d.calc.suggestedCents : 0 + return ` +

Prüfung durch dich

+
+ ${d.requiredChecks.map((k) => ``).join('')} +
+

Berechnung

+
+
+
+
+
+
+
+
+
+
+
${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}
+

Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.

+
+
+
+
+
+ + + +
+

Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' Achtung: Der Kunde hat kein laufendes Abo.'}

+ ` +} + +function creditDecisionSummaryHtml(d) { + const r = d.request + if (r.status === 'genehmigt') { + return ` +

Entscheidung

+
+
Genehmigt
${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
+
Gutschrift
${euroFromCents(r.amountCents)}
+
Berechnung
${escapeHtml(r.calculation)}
+
Geprüft
${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}
+ ${r.decisionNote ? `
Nachricht
${escapeHtml(r.decisionNote)}
` : ''} +
Verrechnung
${r.appliedAt + ? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} (${escapeHtml(r.stripeBalanceTransactionId)})` + : 'noch nicht in Stripe gebucht'}
+
+ ${r.appliedAt ? '' : ` +
+ `}` + } + return ` +

Entscheidung

+
+
Abgelehnt
${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
+
Begründung
${escapeHtml(r.decisionNote)}
+
` +} + +function renderCreditDetail(panel, d, message) { + const r = d.request + const c = d.customer + const s = d.subscription + const pending = r.status === 'offen' || r.status === 'rueckfrage' + const fileNames = new Map(r.files.map((f) => [f.id, f.name])) + const fileHref = (f) => `/api/portal-admin/takeover-credits/${encodeURIComponent(r.id)}/files/${encodeURIComponent(f.id)}` + const aboPill = !s + ? 'kein Abo' + : `${escapeHtml(s.status)}` + const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '') + + panel.innerHTML = ` +
+
+

Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}

+ eingegangen ${fmtDateTime(r.createdAt)} ${creditStatusPill(r.status)} +
+ +
+ ${message ? `
${escapeHtml(message)}
` : ''} +
+
+

Antrag

+
+
Leistung
${escapeHtml(r.services)}
+
Bisher bei
${escapeHtml(r.provider)}
+
Domain
${r.domain ? `${escapeHtml(r.domain)}` : '–'}
+
Rechnung
${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}
+
Bezahlt bis
${fmtDate(r.paidUntil)}
+
Antwort an
${escapeHtml(r.email)}
+
Bestätigt
Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}
+
+

Grund / Nachricht

+

${escapeHtml(r.message)}

+

Nachweise

+ ${r.files.length + ? `` + : '

Keine Nachweise (mehr) vorhanden.

'} +

Verlauf

+ ${r.history.map((h) => ` +
+ ${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)} + ${fmtDateTime(h.at)}${staff(h.by)} + ${h.text ? `
${escapeHtml(h.text)}
` : ''} + ${(h.files || []).length ? `
Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}
` : ''} +
`).join('')} +
+
+

Kunde & Vertrag

+
+
Kunde
${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}
+
E-Mail
${escapeHtml(c.email || '–')}
+
Wartungs-Abo
${aboPill}
+ ${s ? ` +
Leistungsbeginn
${fmtDate(s.serviceStart)}
+
Nächste Abbuchung
${s.active ? fmtDate(s.currentPeriodEnd) : '–'}
+ ${s.cancelAt ? `
Gekündigt zum
${fmtDate(s.cancelAt)}
` : ''} +
Stripe-Guthaben
${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)}
` : ''} +
+
+ + ${s?.stripeUrl ? `In Stripe öffnen` : ''} +
+ ${d.otherRequests.length ? ` +

Weitere Anträge des Kunden

+
    ${d.otherRequests.map((o) => ` +
  • ${escapeHtml(o.ref)} · ${escapeHtml(o.services)} · ${fmtDate(o.createdAt)} + ${creditStatusPill(o.status)}${o.amountCents ? ` ${euroFromCents(o.amountCents)}` : ''}
  • `).join('')}
` : ''} +

Automatische Prüfung

+
    ${d.autoChecks.map((k) => ` +
  • ${escapeHtml(k.label)}${k.detail ? `${escapeHtml(k.detail)}` : ''}
  • `).join('')}
+ ${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)} +
+
` + + panel.querySelector('#credit-close')?.addEventListener('click', closeCreditDetail) + panel.querySelector('#credit-view-as')?.addEventListener('click', () => viewAsCustomer(c.id)) + panel.querySelectorAll('[data-open-credit]').forEach((a) => + a.addEventListener('click', (e) => { + e.preventDefault() + openCreditDetail(a.dataset.openCredit) + })) + panel.querySelector('#credit-apply')?.addEventListener('click', () => applyCredit(r.id)) + if (pending) bindCreditDecision(panel, r.id) +} + +function refreshApproveState(panel) { + const btn = panel.querySelector('#credit-approve') + if (!btn) return + const allChecked = [...panel.querySelectorAll('[data-check]')].every((cb) => cb.checked) + btn.disabled = !allChecked || !(Number(panel.querySelector('#credit-amount').value) > 0) +} + +function calcFigures(panel) { + return { + invoiceAmountCents: Math.round(Number(panel.querySelector('#calc-amount').value) * 100), + billingMonths: Number(panel.querySelector('#calc-months').value), + paidUntil: panel.querySelector('#calc-paid-until').value, + takeoverFrom: panel.querySelector('#calc-from').value, + } +} + +async function recalcCredit(panel) { + const out = panel.querySelector('#calc-result') + try { + const { calc } = await api('/api/portal-admin/takeover-credits/calc', { + method: 'POST', + body: JSON.stringify(calcFigures(panel)), + }) + out.textContent = calcSummary(calc) + if (!creditState.amountTouched) panel.querySelector('#credit-amount').value = (calc.suggestedCents / 100).toFixed(2) + } catch (err) { + out.textContent = err.message + } + refreshApproveState(panel) +} + +function bindCreditDecision(panel, id) { + panel.querySelectorAll('[data-check]').forEach((cb) => cb.addEventListener('change', () => refreshApproveState(panel))) + panel.querySelector('#credit-amount').addEventListener('input', () => { + creditState.amountTouched = true + refreshApproveState(panel) + }) + panel.querySelectorAll('#calc-amount, #calc-months, #calc-paid-until, #calc-from').forEach((el) => + el.addEventListener('input', () => { + clearTimeout(creditState.calcTimer) + creditState.calcTimer = setTimeout(() => recalcCredit(panel), 300) + })) + panel.querySelector('#credit-approve').addEventListener('click', () => submitCreditDecision(id, 'approve', panel)) + panel.querySelector('#credit-question').addEventListener('click', () => submitCreditDecision(id, 'question', panel)) + panel.querySelector('#credit-reject').addEventListener('click', () => submitCreditDecision(id, 'reject', panel)) +} + +async function submitCreditDecision(id, action, panel) { + const errEl = panel.querySelector('#credit-error') + errEl.classList.add('hidden') + const note = panel.querySelector('#credit-note').value.trim() + const amountCents = Math.round(Number(panel.querySelector('#credit-amount').value) * 100) + const who = creditState.detail?.customer?.name || 'den Kunden' + if (action !== 'approve' && note.length < 5) { + errEl.textContent = action === 'question' + ? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.' + : 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.' + errEl.classList.remove('hidden') + return + } + const question = { + approve: `Gutschrift über ${euroFromCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`, + question: `Rückfrage per E-Mail an ${who} senden?`, + reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`, + }[action] + if (!window.confirm(question)) return + + const buttons = panel.querySelectorAll('.credit-actions button') + buttons.forEach((b) => { b.disabled = true }) + try { + const checks = Object.fromEntries([...panel.querySelectorAll('[data-check]')].map((cb) => [cb.dataset.check, cb.checked])) + const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/decision`, { + method: 'POST', + body: JSON.stringify({ action, note, amountCents, checks, ...calcFigures(panel) }), + }) + const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.'] + if (action === 'approve') { + parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`) + } + parts.push(result.mailSent + ? 'Die E-Mail an den Kunden ist raus.' + : `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`) + await loadCredits() + await openCreditDetail(id, { message: parts.join(' ') }) + } catch (err) { + errEl.textContent = err.message + errEl.classList.remove('hidden') + buttons.forEach((b) => { b.disabled = false }) + refreshApproveState(panel) + } +} + +async function applyCredit(id) { + const panel = document.getElementById('credit-detail') + const btn = panel.querySelector('#credit-apply') + btn.disabled = true + try { + const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/apply`, { method: 'POST' }) + await loadCredits() + await openCreditDetail(id, { + message: result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`, + }) + } catch (err) { + const errEl = panel.querySelector('#credit-error') + errEl.textContent = err.message + errEl.classList.remove('hidden') + btn.disabled = false + } +} + /* ----------------------------------------------------------------- Init --- */ async function initAdminPage() { document.getElementById('logout-btn')?.addEventListener('click', async () => { @@ -406,10 +779,12 @@ async function initAdminPage() { window.location.href = '/login.html' }) + // Nach dem Login zurück zur aufgerufenen Adresse (z. B. Link aus der Gutschrift-Mail) + const loginUrl = `/login.html?next=${encodeURIComponent(window.location.pathname + window.location.search)}` try { const me = await api('/api/auth/me') if (!me.authenticated || me.role !== 'admin') { - window.location.href = '/login.html' + window.location.href = loginUrl return } if (me.viewAs) { @@ -419,11 +794,12 @@ async function initAdminPage() { document.getElementById('admin-meta').textContent = `${me.admin?.name || 'Admin'} (${me.admin?.email || ''})` } catch { - window.location.href = '/login.html' + window.location.href = loginUrl return } let chatListLoaded = false + let creditsLoaded = false document.querySelectorAll('.admin-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active')) @@ -434,9 +810,28 @@ async function initAdminPage() { chatListLoaded = true loadChatList() } + if (tab.dataset.tab === 'credits' && !creditsLoaded) { + creditsLoaded = true + loadCredits() + } }) }) + document.querySelectorAll('[data-credit-filter]').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn)) + creditState.filter = btn.dataset.creditFilter + loadCredits() + }) + }) + + // Direktlink aus der Admin-Mail: /admin.html?gutschrift= + const creditFromLink = new URLSearchParams(window.location.search).get('gutschrift') + if (creditFromLink) { + document.querySelector('.admin-tab[data-tab="credits"]')?.click() + openCreditDetail(creditFromLink) + } + document.getElementById('project-search')?.addEventListener('input', (e) => { clearTimeout(projectSearchTimer) projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250) @@ -450,9 +845,11 @@ async function initAdminPage() { loadProjects() loadCustomers() - // Chat-Badge global pollen + // Chat- und Gutschrift-Badge global pollen pollAdminChatBadge() setInterval(pollAdminChatBadge, 60000) + pollCreditBadge() + setInterval(pollCreditBadge, 60000) } window.initAdminPage = initAdminPage diff --git a/public/app.js b/public/app.js index 9833f7e..56200ff 100644 --- a/public/app.js +++ b/public/app.js @@ -282,6 +282,12 @@ async function postLoginRedirect() { window.location.href = await resolvePostLoginTarget() } +// Links aus Admin-Mails (z. B. /admin.html?gutschrift=) nach dem Login wieder aufnehmen +function adminLoginTarget() { + const next = getQueryParam('next') + return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html' +} + /* -------------------------------------------------------------- Login-Page --- */ async function initLoginPage() { const form = document.getElementById('login-form') @@ -328,7 +334,7 @@ async function initLoginPage() { try { const me = await api('/api/auth/me') if (me.authenticated && me.role === 'admin' && !me.viewAs) { - window.location.href = '/admin.html' + window.location.href = adminLoginTarget() return } if (me.authenticated && me.customer) { @@ -356,7 +362,7 @@ async function initLoginPage() { }), }) if (result.role === 'admin') { - window.location.href = '/admin.html' + window.location.href = adminLoginTarget() return } await postLoginRedirect() @@ -616,6 +622,7 @@ function renderAboOffer(card, data, { ended } = {}) {
50 €pro Monat · 12 Monate Mindestlaufzeit
${aboFeaturesHtml()} + ${available ? '

Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.

' : ''}
${available @@ -779,11 +786,263 @@ async function loadAboCard({ afterCheckout = false } = {}) { renderAboOffer(card, data, { ended: Boolean(data.subscription) }) } card.classList.remove('hidden') + loadTakeoverCard({ abo: data }) } catch (err) { console.warn('Abo konnte nicht geladen werden:', err.message) } } +// --------------------------------------------------------------------------- +// Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten +// --------------------------------------------------------------------------- + +const takeoverState = { abo: null, customer: null } + +const TAKEOVER_STATUS = { + offen: ['pill-warn', 'In Prüfung'], + rueckfrage: ['pill-amber', 'Rückfrage'], + genehmigt: ['pill-ok', 'Genehmigt'], + abgelehnt: ['pill-muted', 'Nicht genehmigt'], +} + +// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen +const TAKEOVER_CONDITIONS = [ + 'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.', + 'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.', + 'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.', + 'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.', + 'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.', +] + +const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']] + +function takeoverStatusPill(status) { + const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status] + return `${label}` +} + +function renderTakeoverItem(r) { + const facts = [ + `eingereicht am ${fmtDateShort(r.createdAt)}`, + `bisher bei ${escapeHtml(r.provider)}${r.domain ? ` (${escapeHtml(r.domain)})` : ''}`, + `${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDateShort(r.paidUntil)}`, + ] + let body + if (r.status === 'genehmigt') { + body = `

Gutschrift: ${fmtEuro(r.amountCents / 100)} – wird mit Ihren nächsten Monatsgebühren verrechnet.

+ ${r.decisionNote ? `

${escapeHtml(r.decisionNote)}

` : ''}` + } else if (r.status === 'abgelehnt') { + body = `

${escapeHtml(r.decisionNote)}

` + } else if (r.status === 'rueckfrage') { + body = ` +
Unsere Rückfrage:
${escapeHtml(r.question)}
+
+ + + +
+
` + } else { + body = '

Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.

' + } + return `
+
Antrag ${escapeHtml(r.ref)} · ${escapeHtml(r.services)}${takeoverStatusPill(r.status)}
+
${facts.join(' · ')}
+ ${body} +
` +} + +function takeoverFormHtml(limits) { + const email = takeoverState.customer?.email || '' + const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024) + return ` + ` +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result).split(',')[1] || '') + reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`)) + reader.readAsDataURL(file) + }) +} + +async function collectTakeoverFiles(input, limits, { required }) { + const files = [...(input?.files || [])] + const mb = (bytes) => Math.round(bytes / 1024 / 1024) + if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.') + if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`) + const tooBig = files.find((f) => f.size > limits.maxFileBytes) + if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`) + if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) { + throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`) + } + return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) }))) +} + +async function submitTakeoverForm(form, limits) { + const errEl = form.querySelector('#tk-error') + const btn = form.querySelector('#tk-submit') + hideError(errEl) + const value = (id) => form.querySelector(id).value.trim() + const payload = { + services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value), + provider: value('#tk-provider'), + domain: value('#tk-domain'), + invoiceAmount: value('#tk-amount'), + billingMonths: Number(value('#tk-months')), + paidUntil: value('#tk-paid-until'), + message: value('#tk-message'), + email: value('#tk-email'), + consent: form.querySelector('#tk-consent').checked, + } + try { + if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.') + if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.') + if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) { + throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.') + } + if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.') + btn.disabled = true + btn.textContent = 'Wird gesendet…' + payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true }) + await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) }) + await loadTakeoverCard({ + notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`, + }) + } catch (err) { + showError(errEl, err.message) + btn.disabled = false + btn.textContent = 'Antrag absenden' + } +} + +async function submitTakeoverReply(form, limits) { + const errEl = form.querySelector('.error') + const btn = form.querySelector('button[type="submit"]') + hideError(errEl) + btn.disabled = true + try { + const message = form.querySelector('textarea').value.trim() + const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false }) + if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.') + await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, { + method: 'POST', + body: JSON.stringify({ message, files }), + }) + await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' }) + } catch (err) { + showError(errEl, err.message) + btn.disabled = false + } +} + +async function loadTakeoverCard({ abo, notice = '' } = {}) { + const card = document.getElementById('takeover-card') + if (!card) return + if (abo) takeoverState.abo = abo + const active = Boolean(takeoverState.abo?.subscription?.active) + if (!takeoverState.abo?.subscription) { + card.classList.add('hidden') + return + } + let data + try { + data = await api('/api/abo/takeover-credits') + } catch (err) { + console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message) + return + } + const requests = data.requests || [] + if (!active && !requests.length) { + card.classList.add('hidden') + return + } + + card.innerHTML = ` +
+
+

Wechsel ohne doppelte Kosten

+

Übernahmegutschrift

+
+
+

Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.

+ ${notice ? `
${escapeHtml(notice)}
` : ''} + ${active ? ` +
+ + ${chatState.viewAs ? 'Im Ansichtsmodus kann kein Antrag gestellt werden.' : ''} +
+ ${takeoverFormHtml(data.limits)}` : ''} + ${requests.length ? `
${requests.map(renderTakeoverItem).join('')}
` : ''}` + card.classList.remove('hidden') + + const form = card.querySelector('#tk-form') + const startBtn = card.querySelector('#tk-start') + startBtn?.addEventListener('click', () => { + form.classList.remove('hidden') + startBtn.classList.add('hidden') + form.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }) + card.querySelector('#tk-cancel')?.addEventListener('click', () => { + form.classList.add('hidden') + startBtn?.classList.remove('hidden') + }) + form?.addEventListener('submit', (ev) => { + ev.preventDefault() + submitTakeoverForm(form, data.limits) + }) + card.querySelectorAll('form[data-reply-for]').forEach((f) => + f.addEventListener('submit', (ev) => { + ev.preventDefault() + submitTakeoverReply(f, data.limits) + })) +} + async function initDashboardPage() { const meta = document.getElementById('customer-meta') const list = document.getElementById('projects') @@ -840,6 +1099,7 @@ async function initDashboardPage() { const customer = me.customer chatState.viewAs = Boolean(me.viewAs) + takeoverState.customer = customer renderViewAsBanner(me) const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : '' diff --git a/public/dashboard.html b/public/dashboard.html index c5bab79..98bad5b 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -157,6 +157,40 @@ .abo-summary td:last-child { text-align: left; } .abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; } .abo-withdraw:hover { background: #fff; color: #1c1c1c; } + + /* Übernahmegutschrift */ + .pill-amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); } + .takeover-card { + margin: -8px 0 26px; padding: 22px 28px; border-radius: 22px; + background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.14); + } + .takeover-card .abo-title { font-size: 20px; } + .takeover-card a { color: #d4b4ff; } + .tk-intro { margin: 8px 0 14px; max-width: 70ch; } + .tk-notice { padding: 10px 14px; border-radius: 12px; margin: 12px 0; font-size: 14px; background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.4); } + .tk-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.14); display: grid; gap: 14px; } + .tk-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; } + .tk-field { border: none; padding: 0; margin: 0; min-width: 0; } + .tk-field > label, .tk-field > legend { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } + .tk-field small { font-weight: 400; opacity: 0.65; } + .tk-field input:not([type=checkbox]), .tk-field select, .tk-field textarea, .tk-reply textarea, .tk-reply input[type=file] { + width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; + border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.06); color: inherit; font: inherit; + } + .tk-field select option { color: #111; } + .tk-field textarea, .tk-reply textarea { min-height: 90px; resize: vertical; } + .tk-services { display: flex; flex-wrap: wrap; gap: 8px 20px; } + .tk-services label { display: inline-flex; gap: 8px; align-items: center; font-size: 14px; cursor: pointer; } + .tk-services input { width: 17px; height: 17px; accent-color: #9d3dfe; } + .tk-conditions summary { cursor: pointer; font-size: 14px; } + .tk-conditions ol { margin: 8px 0 0; padding-left: 20px; font-size: 13.5px; opacity: 0.85; } + .tk-conditions li { margin: 4px 0; } + .tk-list { margin-top: 16px; display: grid; gap: 10px; } + .tk-item { padding: 12px 16px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); font-size: 14px; } + .tk-item p { margin: 8px 0 0; } + .tk-item-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 4px; } + .tk-question { margin: 10px 0 0; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45); } + .tk-reply { margin-top: 10px; display: grid; gap: 8px; } @@ -182,6 +216,9 @@ + + +
diff --git a/public/datenschutz.html b/public/datenschutz.html index 5aa02df..87b0a43 100644 --- a/public/datenschutz.html +++ b/public/datenschutz.html @@ -84,6 +84,7 @@

Schließen Sie das Wartungs-Abo ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.

Um den Vertragsschluss nachweisen zu können, speichern wir bei der Bestellung Zeitpunkt, Version der Vertragsbedingungen, Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses. Diese Angaben löschen wir drei Jahre nach Vertragsende.

Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.

+

Beantragen Sie im Kundenportal eine Übernahmegutschrift, verarbeiten wir Ihre Angaben zum bisherigen Anbieter (Anbieter, Leistung, Domain, Rechnungsbetrag, bezahlter Zeitraum), Ihre Nachricht, die hochgeladenen Nachweise wie Rechnung oder Kündigungsbestätigung, die E-Mail-Adresse für unsere Antwort sowie Zeitpunkt und IP-Adresse Ihrer Bestätigung. Die Nachweise speichern wir auf unserem Server; unsere Mitarbeiter erhalten per E-Mail nur einen Hinweis auf den Antrag. Zur Prüfung fragen wir die öffentlichen DNS-Einträge der angegebenen Domain ab (Nameserver, Mailserver, IP-Adresse). Eine genehmigte Gutschrift buchen wir bei Stripe als Guthaben auf Ihr Kundenkonto. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, für den Nachweis Ihrer Bestätigung Art. 6 Abs. 1 lit. f DSGVO. Nachweise zu abgelehnten Anträgen löschen wir sechs Monate nach der Entscheidung. Bei genehmigten Anträgen sind Antrag und Nachweise Beleg für die Gutschrift und werden wie in Abschnitt 9 beschrieben aufbewahrt.

8. E-Mail

Vertragsbestätigungen, Benachrichtigungen und unsere sonstige Korrespondenz versenden und empfangen wir derzeit über den E-Mail-Dienst der Porkbun LLC, 11575 SW Pacific Hwy PMB 40649, Tigard, OR 97223, USA. Dabei werden Inhalte und Verbindungsdaten der E-Mails in den USA verarbeitet. Für Porkbun besteht kein Angemessenheitsbeschluss der EU-Kommission. Die Übermittlung erfolgt, soweit sie zur Durchführung eines Vertrags mit Ihnen oder zur Beantwortung Ihrer Anfrage erforderlich ist (Art. 49 Abs. 1 lit. b DSGVO). Wir stellen unseren E-Mail-Dienst in Kürze auf die Hetzner Online GmbH (Rechenzentrum Nürnberg, Deutschland) um.

diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs index da1654d..0b820f2 100644 --- a/scripts/setup-abo-collections.mjs +++ b/scripts/setup-abo-collections.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** - * Wartungs-Abo: legt die Collections subscriptions + hourCredits in woms-database an - * und ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto"). + * Wartungs-Abo: legt die Collections subscriptions, hourCredits und takeoverCredits + * (Uebernahmegutschriften) in woms-database an und ergaenzt worksheets um das Flag + * countsToPlan ("zaehlt aufs Abo-Stundenkonto"). * Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen. * Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs */ @@ -134,6 +135,44 @@ await ensureCollection( [{ key: 'idx_customer', type: 'key', attributes: ['customerId'] }] ) +// Uebernahmegutschrift: Antrag (Kunde) -> Pruefung/Entscheidung (Admin) -> Verrechnung (Stripe-Guthaben). +// Lange Texte mit size > 16381 werden in Appwrite zu TEXT-Spalten und zaehlen nicht zur Zeilenbreite. +await ensureCollection( + 'takeoverCredits', + 'Uebernahmegutschriften', + [ + { type: 'string', key: 'customerId', size: 64 }, + { type: 'string', key: 'email', size: 255 }, + { type: 'string', key: 'services', size: 128 }, + { type: 'string', key: 'provider', size: 200 }, + { type: 'string', key: 'domain', size: 255 }, + { type: 'integer', key: 'invoiceAmountCents' }, + { type: 'integer', key: 'billingMonths' }, + { type: 'datetime', key: 'paidUntil' }, + { type: 'string', key: 'message', size: 20000 }, + { type: 'string', key: 'files', size: 20000 }, + { type: 'string', key: 'status', size: 20 }, + { type: 'string', key: 'history', size: 60000 }, + { type: 'datetime', key: 'takeoverFrom' }, + { type: 'integer', key: 'amountCents' }, + { type: 'string', key: 'calculation', size: 1000 }, + { type: 'string', key: 'checks', size: 1000 }, + { type: 'string', key: 'decisionNote', size: 20000 }, + { type: 'datetime', key: 'decidedAt' }, + { type: 'string', key: 'decidedBy', size: 255 }, + { type: 'string', key: 'stripeCustomerId', size: 64 }, + { type: 'string', key: 'stripeBalanceTransactionId', size: 64 }, + { type: 'datetime', key: 'appliedAt' }, + { type: 'string', key: 'conditionsVersion', size: 32 }, + { type: 'datetime', key: 'consentAt' }, + { type: 'string', key: 'consentIp', size: 64 }, + ], + [ + { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, + { key: 'idx_status', type: 'key', attributes: ['status'] }, + ] +) + // Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen await ensure('worksheets.countsToPlan', () => aw(`${col('worksheets')}/attributes/boolean`, { diff --git a/server/config.js b/server/config.js index 8ba41d4..7d06f75 100644 --- a/server/config.js +++ b/server/config.js @@ -1,4 +1,5 @@ import 'dotenv/config' +import path from 'node:path' /** Einzige erlaubte Datenbank (Ticketsystem). Keine separate Portal-DB mehr. */ export const WOMS_DATABASE_ID = 'woms-database' @@ -49,6 +50,7 @@ export const config = { portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages', subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions', hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits', + takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), @@ -94,6 +96,22 @@ export const config = { testCouponId: 'WEBKLAR-TEST-050', }, + // Uebernahmegutschrift: beim bisherigen Anbieter bereits bezahlte Zeit wird mit den Abo-Gebuehren verrechnet + takeover: { + // Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert) + conditionsVersion: '2026-09-25', + // Nachweise liegen ausserhalb von public/ (gitignored, ueberlebt Deploys wie preview-data/) + uploadDir: process.env.TAKEOVER_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'takeover-credits'), + maxFiles: 3, + maxFileBytes: 8 * 1024 * 1024, + maxTotalBytes: 10 * 1024 * 1024, + // Plausibilitaetsgrenze fuer eine einzelne Gutschrift (Tippfehler abfangen) + maxAmountCents: 100000, + maxOpenPerCustomer: 3, + // Nachweise abgelehnter Antraege werden nach dieser Frist geloescht (Datenschutzerklaerung Abschnitt 7) + rejectedRetentionDays: 183, + }, + smtp: { host: process.env.SMTP_HOST || '', port: Number(process.env.SMTP_PORT) || 587, diff --git a/server/index.js b/server/index.js index b91366b..59a40b7 100644 --- a/server/index.js +++ b/server/index.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' import { verifyDatabaseAccess } from './services/appwriteAdmin.js' import { syncPreviewTls } from './services/previewDeploy.js' -import { sessionMiddleware } from './middleware/session.js' +import { requireSession, sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' import featuresRoutes from './routes/features.js' @@ -12,6 +12,8 @@ import customerDataRoutes from './routes/customerData.js' import portalAdminRoutes from './routes/portalAdmin.js' import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' +import takeoverCreditRoutes from './routes/takeoverCredits.js' +import { purgeExpiredUploads } from './services/takeoverCredits.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import adminGiteaRoutes from './routes/admin/gitea.js' import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' @@ -28,6 +30,10 @@ assertServerConfig() const app = express() app.set('trust proxy', 1) app.use(sessionMiddleware()) +// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, +// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung +// zuerst, damit niemand ohne Session grosse Bodies parsen laesst. +app.use('/api/abo/takeover-credits', requireSession, express.json({ limit: '15mb' })) // rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } })) @@ -49,6 +55,7 @@ app.use('/api/projects', projectsRoutes) app.use('/api/features', featuresRoutes) app.use('/api', customerDataRoutes) app.use('/api/chat', chatRoutes) +app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/portal-admin', portalAdminRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) @@ -103,6 +110,14 @@ const server = app.listen(config.port, '0.0.0.0', () => { ) if (result.reason) console.error(`[startup] Appwrite: ${result.reason}`) }) + + // Nachweise abgelehnter Uebernahmegutschriften nach Ablauf der Aufbewahrungsfrist loeschen (taeglich) + const purge = () => + purgeExpiredUploads() + .then((r) => r.purged && console.log(`[takeover] Nachweise von ${r.purged} abgelehnten Anträgen gelöscht`)) + .catch((err) => console.warn('[takeover] Aufräumen fehlgeschlagen:', err.message)) + setTimeout(purge, 60 * 1000).unref() + setInterval(purge, 24 * 60 * 60 * 1000).unref() }) server.on('error', (err) => { diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js index df9af7f..4bcb1b9 100644 --- a/server/routes/portalAdmin.js +++ b/server/routes/portalAdmin.js @@ -17,11 +17,15 @@ import { listAppointments, saveAppointment } from '../services/appointments.js' import { getCustomerActivity } from '../services/customerActivity.js' import { listInvoicesForClient } from '../services/invoiceNinja.js' import { sanitizeMessage } from './chat.js' +import { takeoverAdminRouter } from './takeoverCredits.js' const router = Router() router.use(requireAdminSession) +// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften") +router.use('/takeover-credits', takeoverAdminRouter) + function norm(value) { return String(value || '').toLowerCase() } diff --git a/server/routes/takeoverCredits.js b/server/routes/takeoverCredits.js new file mode 100644 index 0000000..95f0015 --- /dev/null +++ b/server/routes/takeoverCredits.js @@ -0,0 +1,224 @@ +import { Router } from 'express' +import { config } from '../config.js' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js' +import { + answerQuestion, + applyTakeoverCredit, + calculationText, + computeTakeoverCredit, + createTakeoverRequest, + customerView, + decideTakeoverRequest, + getAdminDetail, + getTakeoverRequest, + listTakeoverRequests, + parseUploads, + readInvoiceFigures, + readUpload, + servicesLabel, + shortRef, + STATUS, + validateRequestInput, +} from '../services/takeoverCredits.js' + +/** + * Uebernahmegutschrift. + * Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten) + * Admin: /api/portal-admin/takeover-credits (Liste, Pruefung, Entscheidung, Nachweise) + * Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js. + */ + +// Missbrauchsschutz: je Kunde hoechstens 20 Versuche pro Stunde (zusaetzlich max. 3 offene Antraege) +const recentByCustomer = new Map() +function rateLimited(customerId) { + const now = Date.now() + const list = (recentByCustomer.get(customerId) || []).filter((t) => now - t < 60 * 60 * 1000) + list.push(now) + recentByCustomer.set(customerId, list) + return list.length > 20 +} + +function sendError(res, err, fallback) { + const status = err.status || 500 + if (status >= 500) console.error('[takeover]', fallback, err.message) + return res.status(status).json({ error: err.message || fallback }) +} + +// --------------------------------------------------------------------------- +// Kunde (eingeloggt) +// --------------------------------------------------------------------------- + +const router = Router() +router.use(requireSession) + +router.get('/', async (req, res) => { + try { + const docs = await listTakeoverRequests({ customerId: getSessionCustomerId(req) }) + return res.json({ + requests: docs.map(customerView), + conditionsVersion: config.takeover.conditionsVersion, + limits: { + maxFiles: config.takeover.maxFiles, + maxFileBytes: config.takeover.maxFileBytes, + maxTotalBytes: config.takeover.maxTotalBytes, + }, + }) + } catch (err) { + return sendError(res, err, 'Anträge konnten nicht geladen werden') + } +}) + +router.post('/', async (req, res) => { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann kein Antrag gestellt werden.' }) + } + const customerId = getSessionCustomerId(req) + if (rateLimited(customerId)) { + return res.status(429).json({ error: 'Zu viele Anträge in kurzer Zeit. Bitte später erneut versuchen.' }) + } + try { + const input = validateRequestInput(req.body || {}) + const uploads = parseUploads(req.body.files, { required: true }) + const customer = await getDocument(config.collections.customers, customerId) + const doc = await createTakeoverRequest({ customer, input, uploads, consentIp: req.ip }) + return res.status(201).json({ request: customerView(doc) }) + } catch (err) { + return sendError(res, err, 'Antrag konnte nicht gespeichert werden') + } +}) + +router.post('/:id/reply', async (req, res) => { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' }) + } + const customerId = getSessionCustomerId(req) + if (rateLimited(customerId)) { + return res.status(429).json({ error: 'Zu viele Anfragen in kurzer Zeit. Bitte später erneut versuchen.' }) + } + try { + const doc = await getTakeoverRequest(req.params.id).catch(() => null) + if (!doc || doc.customerId !== customerId) return res.status(404).json({ error: 'Antrag nicht gefunden' }) + const uploads = parseUploads(req.body?.files, { required: false }) + const customer = await getDocument(config.collections.customers, customerId) + const updated = await answerQuestion({ customer, doc, message: req.body?.message, uploads }) + return res.json({ request: customerView(updated) }) + } catch (err) { + return sendError(res, err, 'Antwort konnte nicht gespeichert werden') + } +}) + +export default router + +// --------------------------------------------------------------------------- +// Admin (eingehaengt in routes/portalAdmin.js, dort hinter requireAdminSession) +// --------------------------------------------------------------------------- + +export const takeoverAdminRouter = Router() + +takeoverAdminRouter.get('/', async (req, res) => { + try { + const [docs, customers] = await Promise.all([ + listTakeoverRequests(), + listDocuments(config.collections.customers, [Query.limit(1000)]), + ]) + const byId = new Map(customers.map((c) => [c.$id, c])) + const onlyOpen = req.query.status !== 'all' + const requests = docs + .filter((d) => !onlyOpen || d.status === STATUS.open || d.status === STATUS.question) + .map((d) => { + const c = byId.get(d.customerId) + return { + id: d.$id, + ref: shortRef(d.$id), + createdAt: d.$createdAt, + customerId: d.customerId, + customerName: c?.name || c?.companyName || d.email, + customerCode: c?.code || '', + services: servicesLabel(d.services), + provider: d.provider || '', + domain: d.domain || '', + invoiceAmountCents: d.invoiceAmountCents || 0, + billingMonths: d.billingMonths || 0, + paidUntil: d.paidUntil || null, + status: d.status, + amountCents: d.amountCents ?? null, + appliedAt: d.appliedAt || null, + } + }) + return res.json({ requests, openCount: docs.filter((d) => d.status === STATUS.open).length }) + } catch (err) { + return sendError(res, err, 'Anträge konnten nicht geladen werden') + } +}) + +/** Rechner im Pruefformular: dieselbe Berechnung wie beim Genehmigen (eine Quelle der Wahrheit). */ +takeoverAdminRouter.post('/calc', (req, res) => { + try { + const figures = readInvoiceFigures(req.body, {}) + const calc = computeTakeoverCredit({ ...figures, takeoverFrom: req.body?.takeoverFrom }) + if (!calc) return res.status(400).json({ error: 'Bitte das Datum „Übernahme ab“ angeben.' }) + return res.json({ calc, text: calculationText(figures, calc, calc.suggestedCents) }) + } catch (err) { + return sendError(res, err, 'Berechnung fehlgeschlagen') + } +}) + +takeoverAdminRouter.get('/open-count', async (_req, res) => { + try { + const docs = await listTakeoverRequests() + return res.json({ open: docs.filter((d) => d.status === STATUS.open).length }) + } catch { + return res.json({ open: 0 }) + } +}) + +takeoverAdminRouter.get('/:id', async (req, res) => { + try { + return res.json(await getAdminDetail(req.params.id)) + } catch (err) { + return sendError(res, err, 'Antrag konnte nicht geladen werden') + } +}) + +takeoverAdminRouter.get('/:id/files/:fileId', async (req, res) => { + try { + const doc = await getTakeoverRequest(req.params.id) + const { file, content } = await readUpload(doc, req.params.fileId) + res.setHeader('Content-Type', file.type) + res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`) + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Cache-Control', 'private, no-store') + return res.send(content) + } catch (err) { + return res.status(err.status || 500).send(err.message || 'Datei nicht verfügbar') + } +}) + +takeoverAdminRouter.post('/:id/decision', async (req, res) => { + try { + const admin = { name: req.session.adminName || req.session.name, email: req.session.adminEmail || req.session.email } + const result = await decideTakeoverRequest(req.params.id, req.body || {}, admin) + return res.json({ + success: true, + status: result.doc.status, + applied: result.applied ?? null, + reason: result.reason || '', + mailSent: result.mail?.sent ?? false, + mailError: result.mail?.reason || '', + }) + } catch (err) { + return sendError(res, err, 'Entscheidung konnte nicht gespeichert werden') + } +}) + +/** Genehmigte, aber noch nicht verrechnete Gutschrift erneut an Stripe senden. */ +takeoverAdminRouter.post('/:id/apply', async (req, res) => { + try { + const doc = await getTakeoverRequest(req.params.id) + const result = await applyTakeoverCredit(doc) + return res.json({ success: result.applied, applied: result.applied, reason: result.reason || '' }) + } catch (err) { + return sendError(res, err, 'Verrechnung fehlgeschlagen') + } +}) diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 3e7429b..e0f6901 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -243,7 +243,9 @@ export async function grantCreditForInvoice(invoice) { const ref = subscriptionIdOfInvoice(invoice) const subscriptionId = typeof ref === 'string' ? ref : ref?.id if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } - if (!(invoice.amount_paid > 0)) return { skipped: 'Betrag 0' } + // total statt amount_paid: Eine Monatsgebuehr, die ein Guthaben (Uebernahmegutschrift) deckt, + // ist bezahlt, obwohl nichts abgebucht wurde. Nur Probezeit-Rechnungen (0 €) zaehlen nicht. + if (!(invoice.total > 0)) return { skipped: 'Betrag 0' } const sub = await syncSubscription(subscriptionId) if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' } diff --git a/server/services/takeoverCredits.js b/server/services/takeoverCredits.js new file mode 100644 index 0000000..f20dba5 --- /dev/null +++ b/server/services/takeoverCredits.js @@ -0,0 +1,971 @@ +import { createHash, randomUUID } from 'node:crypto' +import { Resolver } from 'node:dns/promises' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { domainToASCII, domainToUnicode } from 'node:url' +import { config } from '../config.js' +import { createDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js' +import { sendAdminNotice, sendCustomerMail } from './mailer.js' +import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js' + +/** + * Uebernahmegutschrift. + * + * Hat ein Abo-Kunde Domain, Hosting oder E-Mail beim bisherigen Anbieter schon im Voraus + * bezahlt, zahlt er ab Leistungsbeginn des Abos fuer dieselbe Zeit doppelt. Diesen Anteil + * schreiben wir ihm gut: + * Antrag (Kunde, mit Rechnungsnachweis, nur mit laufendem Abo) + * -> Pruefung (Admin: automatische Checks + Pflicht-Checkliste) + * -> Entscheidung: Genehmigen / Rueckfrage / Ablehnen, Antwort per E-Mail an den Kunden + * -> Verrechnung als Stripe-Kundenguthaben (negative Balance Transaction), das Stripe + * automatisch mit den naechsten Abo-Rechnungen verrechnet. + * Die Nachweise liegen auf dem Server (config.takeover.uploadDir), nie unter public/. + */ + +const COLLECTION = () => config.collections.takeoverCredits + +export const STATUS = { + open: 'offen', + question: 'rueckfrage', + approved: 'genehmigt', + rejected: 'abgelehnt', +} + +export const SERVICE_LABELS = { + domain: 'Domain', + hosting: 'Hosting / Website-Betrieb', + email: 'E-Mail-Postfach', + sonstiges: 'Sonstiges', +} + +export const BILLING_MONTHS = [1, 3, 6, 12, 24, 36] + +// Pflicht-Checkliste vor dem Genehmigen (wird mit Name und Zeitpunkt am Antrag gespeichert) +export const REQUIRED_CHECKS = [ + { key: 'recipient', label: 'Rechnung ist auf den Kunden ausgestellt (Name bzw. Firma stimmt)' }, + { key: 'service', label: 'Leistung und Domain auf der Rechnung stimmen mit dem Antrag überein' }, + { key: 'period', label: 'Betrag und bezahlter Zeitraum stimmen mit der Rechnung überein' }, + { key: 'replaced', label: 'Die Leistung wird durch das Wartungs-Abo ersetzt (Umzug zu WEBklar)' }, +] + +const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com' +const DAY_MS = 24 * 60 * 60 * 1000 + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const fmtEuro = (cents) => + (Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) + +const fmtDate = (iso) => + iso + ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) + : '–' + +const fmtDateTime = (d) => + new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' }) + +export function servicesLabel(services) { + return String(services || '') + .split(',') + .filter(Boolean) + .map((s) => SERVICE_LABELS[s] || s) + .join(', ') +} + +const monthsLabel = (m) => (Number(m) === 1 ? '1 Monat' : `${m} Monate`) + +/** Kurze, fuer Kunden lesbare Antragsnummer. */ +export const shortRef = (id) => String(id || '').slice(0, 8).toUpperCase() + +// --------------------------------------------------------------------------- +// Berechnung (reine Funktionen) +// --------------------------------------------------------------------------- + +/** Kalendertag in Berlin als UTC-Mitternacht ('YYYY-MM-DD' oder ISO-Zeitpunkt). */ +function dayOf(value) { + if (!value) return null + const raw = String(value) + const ymd = /^\d{4}-\d{2}-\d{2}$/.test(raw) + ? raw + : new Date(raw).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) + const d = new Date(`${ymd}T00:00:00Z`) + return Number.isNaN(d.getTime()) ? null : d +} + +function addDaysUtc(date, days) { + return new Date(date.getTime() + days * DAY_MS) +} + +function addMonthsUtc(date, months) { + // Monatsende abfangen (31.03. - 1 Monat = 28./29.02.) + const d = new Date(date) + const day = d.getUTCDate() + d.setUTCDate(1) + d.setUTCMonth(d.getUTCMonth() + months) + const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate() + d.setUTCDate(Math.min(day, last)) + return d +} + +const daysBetween = (a, b) => Math.round((b.getTime() - a.getTime()) / DAY_MS) +const ymd = (d) => d.toISOString().slice(0, 10) + +/** + * Anteilige Gutschrift, tagesgenau: + * Rechnungszeitraum = die letzten `billingMonths` Monate bis einschliesslich `paidUntil` + * doppelt bezahlt = ab `takeoverFrom` (Leistungsbeginn des Abos) bis `paidUntil` + * Vorschlag = Rechnungsbetrag * doppelt bezahlte Tage / Tage im Rechnungszeitraum + */ +export function computeTakeoverCredit({ invoiceAmountCents, billingMonths, paidUntil, takeoverFrom }) { + const amount = Math.round(Number(invoiceAmountCents) || 0) + const months = Math.round(Number(billingMonths) || 0) + const lastPaidDay = dayOf(paidUntil) + const from = dayOf(takeoverFrom) + if (!(amount > 0) || !(months > 0) || !lastPaidDay || !from) return null + + const periodEndExclusive = addDaysUtc(lastPaidDay, 1) + const periodStart = addMonthsUtc(periodEndExclusive, -months) + const periodDays = Math.max(1, daysBetween(periodStart, periodEndExclusive)) + const overlapStart = from > periodStart ? from : periodStart + const overlapDays = Math.max(0, daysBetween(overlapStart, periodEndExclusive)) + return { + periodStart: ymd(periodStart), + periodEnd: ymd(lastPaidDay), + periodDays, + overlapStart: ymd(overlapStart), + overlapDays, + suggestedCents: Math.round((amount * overlapDays) / periodDays), + } +} + +/** Rechnungswerte aus dem Admin-Rechner (fehlende Felder: Angaben aus dem Antrag). */ +export function readInvoiceFigures(body, doc) { + const invoiceAmountCents = + body?.invoiceAmountCents == null ? doc.invoiceAmountCents : Math.round(Number(body.invoiceAmountCents)) + const billingMonths = body?.billingMonths == null ? doc.billingMonths : Number(body.billingMonths) + const paidUntil = body?.paidUntil ? dayOf(body.paidUntil) : dayOf(doc.paidUntil) + if (!(invoiceAmountCents > 0) || invoiceAmountCents > 500000) throw httpError(400, 'Bitte einen gültigen Rechnungsbetrag angeben.') + if (!BILLING_MONTHS.includes(billingMonths)) throw httpError(400, 'Bitte den Abrechnungszeitraum der Rechnung wählen.') + if (!paidUntil) throw httpError(400, 'Bitte angeben, bis wann bezahlt ist.') + return { invoiceAmountCents, billingMonths, paidUntil: paidUntil.toISOString() } +} + +export function calculationText(doc, calc, amountCents) { + if (!calc) return '' + const base = `${fmtEuro(doc.invoiceAmountCents)} für ${monthsLabel(doc.billingMonths)} (${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)}), davon ${calc.overlapDays} von ${calc.periodDays} Tagen ab ${fmtDate(calc.overlapStart)} doppelt bezahlt = ${fmtEuro(calc.suggestedCents)}` + return amountCents === calc.suggestedCents ? base : `${base}; festgesetzt auf ${fmtEuro(amountCents)}` +} + +// --------------------------------------------------------------------------- +// Eingaben pruefen +// --------------------------------------------------------------------------- + +const clean = (value, max) => String(value ?? '').trim().slice(0, max) + +export function normalizeDomain(value) { + const raw = String(value || '').trim().toLowerCase() + if (!raw) return '' + const host = raw + .replace(/^[a-z]+:\/\//, '') + .replace(/[/?#].*$/, '') + .replace(/:\d+$/, '') + .replace(/^www\./, '') + .replace(/\.$/, '') + // Umlaut-Domains: Pruefung in Punycode, gespeichert wird die lesbare Schreibweise + const ascii = domainToASCII(host) + const valid = /^(?=.{3,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(ascii) + return valid ? domainToUnicode(ascii) : null +} + +/** "119,88" / "1.234,56" / "119.88" / 119.88 -> Cent */ +export function parseEuroCents(value) { + if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value * 100) : NaN + let s = String(value ?? '').replace(/[\s€]/g, '') + if (s.includes(',')) s = s.replace(/\./g, '').replace(',', '.') + if (!/^\d+(\.\d{1,2})?$/.test(s)) return NaN + return Math.round(Number(s) * 100) +} + +const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) + +export function validateRequestInput(body) { + const services = [...new Set(Array.isArray(body?.services) ? body.services : [])].filter((s) => Object.hasOwn(SERVICE_LABELS, s)) + if (!services.length) throw httpError(400, 'Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.') + + const provider = clean(body.provider, 200) + if (!provider) throw httpError(400, 'Bitte nennen Sie Ihren bisherigen Anbieter.') + + const domain = normalizeDomain(body.domain) + if (domain === null) throw httpError(400, 'Die Domain sieht nicht gültig aus (Beispiel: meine-firma.de).') + if (!domain && services.includes('domain')) throw httpError(400, 'Bitte geben Sie die Domain an.') + + const invoiceAmountCents = parseEuroCents(body.invoiceAmount) + if (!(invoiceAmountCents > 0) || invoiceAmountCents > 500000) { + throw httpError(400, 'Bitte geben Sie den Rechnungsbetrag in Euro an.') + } + + const billingMonths = Number(body.billingMonths) + if (!BILLING_MONTHS.includes(billingMonths)) throw httpError(400, 'Bitte wählen Sie den Abrechnungszeitraum der Rechnung.') + + const paidUntilRaw = String(body.paidUntil || '') + const paidUntil = /^\d{4}-\d{2}-\d{2}$/.test(paidUntilRaw) ? dayOf(paidUntilRaw) : null + const now = Date.now() + if (!paidUntil || paidUntil.getTime() < now - 366 * DAY_MS || paidUntil.getTime() > now + 3 * 366 * DAY_MS) { + throw httpError(400, 'Bitte geben Sie an, bis wann Sie beim bisherigen Anbieter bezahlt haben.') + } + + const message = clean(body.message, 4000) + if (message.length < 5) throw httpError(400, 'Bitte beschreiben Sie kurz den Grund Ihres Antrags.') + + const email = clean(body.email, 255) + if (!isEmail(email)) throw httpError(400, 'Bitte geben Sie eine gültige E-Mail-Adresse für unsere Antwort an.') + + if (body.consent !== true) { + throw httpError(400, 'Bitte bestätigen Sie Ihre Angaben und die Bedingungen der Übernahmegutschrift.') + } + + return { + services, + provider, + domain, + invoiceAmountCents, + billingMonths, + paidUntil: paidUntil.toISOString(), + message, + email, + } +} + +// --------------------------------------------------------------------------- +// Nachweise (Dateien) +// --------------------------------------------------------------------------- + +const FILE_TYPES = [ + // PDF-Kopf darf laut Spezifikation innerhalb der ersten 1024 Bytes stehen + { mime: 'application/pdf', ext: 'pdf', test: (b) => b.subarray(0, 1024).includes('%PDF-') }, + { mime: 'image/png', ext: 'png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }, + { mime: 'image/jpeg', ext: 'jpg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, +] + +function safeFileName(name, ext) { + const base = String(name || '') + .split(/[\\/]/) + .pop() + .replace(/[\u0000-\u001f"<>|:*?]/g, '') + .trim() + .slice(0, 120) + if (!base) return `nachweis.${ext}` + return /\.[a-z0-9]{2,5}$/i.test(base) ? base : `${base}.${ext}` +} + +/** Base64-Uploads aus dem JSON-Body pruefen: Anzahl, Groesse, echter Dateityp (Magic Bytes). */ +export function parseUploads(list, { required }) { + const items = Array.isArray(list) ? list : [] + const { maxFiles, maxFileBytes, maxTotalBytes } = config.takeover + if (required && !items.length) throw httpError(400, 'Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.') + if (items.length > maxFiles) throw httpError(400, `Bitte höchstens ${maxFiles} Dateien auf einmal hochladen.`) + let total = 0 + return items.map((item) => { + const buffer = Buffer.from(String(item?.data || ''), 'base64') + const label = clean(item?.name, 120) || 'Datei' + if (!buffer.length) throw httpError(400, `„${label}“ ist leer.`) + if (buffer.length > maxFileBytes) throw httpError(413, `„${label}“ ist größer als ${maxFileBytes / 1024 / 1024} MB.`) + total += buffer.length + if (total > maxTotalBytes) throw httpError(413, `Die Dateien sind zusammen größer als ${maxTotalBytes / 1024 / 1024} MB.`) + const kind = FILE_TYPES.find((t) => t.test(buffer)) + if (!kind) throw httpError(400, `„${label}“: Bitte als PDF, JPG oder PNG hochladen.`) + return { + id: randomUUID(), + name: safeFileName(item?.name, kind.ext), + type: kind.mime, + ext: kind.ext, + size: buffer.length, + sha256: createHash('sha256').update(buffer).digest('hex'), + buffer, + } + }) +} + +function uploadPath(requestId, file) { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/.test(String(requestId)) || !/^[0-9a-f-]{36}$/.test(String(file?.id))) { + throw httpError(400, 'Ungültiger Dateiverweis') + } + return path.join(config.takeover.uploadDir, requestId, `${file.id}.${file.ext || 'bin'}`) +} + +async function storeUploads(requestId, uploads, uploadedAt) { + if (!uploads.length) return [] + await mkdir(path.join(config.takeover.uploadDir, requestId), { recursive: true, mode: 0o700 }) + const stored = [] + for (const { buffer, ...meta } of uploads) { + await writeFile(uploadPath(requestId, meta), buffer, { mode: 0o600 }) + stored.push({ ...meta, uploadedAt }) + } + return stored +} + +export async function readUpload(doc, fileId) { + const file = filesOf(doc).find((f) => f.id === fileId) + if (!file) throw httpError(404, 'Datei nicht gefunden') + const content = await readFile(uploadPath(doc.$id, file)).catch(() => null) + if (!content) throw httpError(404, 'Datei ist nicht mehr vorhanden') + return { file, content } +} + +// --------------------------------------------------------------------------- +// Dokument-Helfer +// --------------------------------------------------------------------------- + +function parseJson(value, fallback) { + try { + return JSON.parse(value || '') ?? fallback + } catch { + return fallback + } +} + +export const filesOf = (doc) => parseJson(doc?.files, []) +export const historyOf = (doc) => parseJson(doc?.history, []) +export const checksOf = (doc) => parseJson(doc?.checks, null) + +/** Verlauf anhaengen; sehr lange Verlaeufe kuerzen, damit das Attribut (60.000 Zeichen) nie ueberlaeuft. */ +function withHistory(doc, entry) { + const list = [...historyOf(doc), { at: new Date().toISOString(), ...entry }] + let json = JSON.stringify(list) + for (let i = 0; json.length > 58000 && i < list.length - 1; i += 1) { + list[i] = { ...list[i], text: String(list[i].text || '').slice(0, 200) } + json = JSON.stringify(list) + } + return json +} + +const serviceStartOf = (sub) => sub?.trialEnd || sub?.startedAt || null +const isActiveSub = (sub) => Boolean(sub && ACTIVE_STATUSES.has(sub.status)) + +export async function getTakeoverRequest(id) { + return getDocument(COLLECTION(), id) +} + +export async function listTakeoverRequests({ customerId } = {}) { + const queries = [Query.orderDesc('$createdAt'), Query.limit(1000)] + if (customerId) queries.unshift(Query.equal('customerId', customerId)) + return listDocuments(COLLECTION(), queries) +} + +/** Ansicht fuer den Kunden: keine Pruefdetails, keine internen Notizen. */ +export function customerView(doc) { + const history = historyOf(doc) + const lastQuestion = [...history].reverse().find((h) => h.type === 'rueckfrage') + return { + id: doc.$id, + ref: shortRef(doc.$id), + createdAt: doc.$createdAt, + services: servicesLabel(doc.services), + provider: doc.provider || '', + domain: doc.domain || '', + invoiceAmountCents: doc.invoiceAmountCents || 0, + billingMonths: doc.billingMonths || 0, + paidUntil: doc.paidUntil || null, + status: doc.status, + question: doc.status === STATUS.question ? lastQuestion?.text || '' : '', + amountCents: doc.status === STATUS.approved ? doc.amountCents || 0 : 0, + decisionNote: [STATUS.approved, STATUS.rejected].includes(doc.status) ? doc.decisionNote || '' : '', + } +} + +// --------------------------------------------------------------------------- +// Kunde: Antrag stellen, Rueckfrage beantworten +// --------------------------------------------------------------------------- + +export async function createTakeoverRequest({ customer, input, uploads, consentIp }) { + const sub = await getCurrentSubscription(customer.$id) + if (!isActiveSub(sub)) { + throw httpError(409, 'Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft.') + } + const own = await listTakeoverRequests({ customerId: customer.$id }) + const open = own.filter((r) => r.status === STATUS.open || r.status === STATUS.question) + if (open.length >= config.takeover.maxOpenPerCustomer) { + throw httpError(429, 'Sie haben bereits mehrere offene Anträge. Bitte warten Sie unsere Antwort ab.') + } + + const id = randomUUID() + const now = new Date().toISOString() + const files = await storeUploads(id, uploads, now) + let doc + try { + doc = await createDocument( + COLLECTION(), + { + customerId: customer.$id, + email: input.email, + services: input.services.join(','), + provider: input.provider, + domain: input.domain, + invoiceAmountCents: input.invoiceAmountCents, + billingMonths: input.billingMonths, + paidUntil: input.paidUntil, + message: input.message, + files: JSON.stringify(files), + status: STATUS.open, + history: JSON.stringify([ + { at: now, type: 'eingereicht', by: 'customer', text: '', files: files.map((f) => f.id) }, + ]), + conditionsVersion: config.takeover.conditionsVersion, + consentAt: now, + consentIp: String(consentIp || '').slice(0, 64), + }, + id + ) + } catch (err) { + await rm(path.join(config.takeover.uploadDir, id), { recursive: true, force: true }).catch(() => {}) + throw err + } + + const name = customer.name || customer.companyName || input.email + const summary = [ + ` Antrag: ${shortRef(doc.$id)}`, + ` Leistung: ${servicesLabel(doc.services)}`, + ` Bisher bei: ${doc.provider}`, + doc.domain ? ` Domain: ${doc.domain}` : null, + ` Rechnung: ${fmtEuro(doc.invoiceAmountCents)} für ${monthsLabel(doc.billingMonths)}`, + ` Bezahlt bis: ${fmtDate(doc.paidUntil)}`, + ` Nachweise: ${files.map((f) => f.name).join(', ')}`, + ].filter((l) => l !== null) + + await sendCustomerMail(input.email, 'Eingangsbestätigung: Ihr Antrag auf Übernahmegutschrift – WEBklar', [ + `Guten Tag ${customer.name || ''}`.trim() + ',', + '', + `wir haben Ihren Antrag auf eine Übernahmegutschrift am ${fmtDateTime(now)} erhalten.`, + '', + ...summary, + '', + 'Wir prüfen Ihre Angaben und den Nachweis und antworten Ihnen per E-Mail an diese Adresse.', + `Den Stand Ihres Antrags sehen Sie auch im Kundenportal: ${config.publicBaseUrl}/dashboard.html`, + '', + 'Viele Grüße', + 'Ihr WEBklar-Team', + '', + FOOTER, + ]).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message)) + + // Nachweise bewusst nicht als Anhang: sie bleiben auf dem Server (Datenschutzerklaerung Abschnitt 7) + await sendAdminNotice(`Übernahmegutschrift beantragt: ${name}`, [ + `${name}${customer.code ? ` (Kd.-Nr. ${customer.code})` : ''} hat eine Übernahmegutschrift beantragt.`, + `Antwort an: ${input.email}`, + '', + ...summary, + '', + 'Grund / Nachricht des Kunden:', + input.message, + '', + `Nachweise ansehen, prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`, + ]) + + return doc +} + +export async function answerQuestion({ customer, doc, message, uploads }) { + if (doc.customerId !== customer.$id) throw httpError(404, 'Antrag nicht gefunden') + if (doc.status !== STATUS.question) throw httpError(409, 'Zu diesem Antrag ist keine Rückfrage offen.') + const text = clean(message, 4000) + if (!text && !uploads.length) throw httpError(400, 'Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.') + + const now = new Date().toISOString() + const added = await storeUploads(doc.$id, uploads, now) + const updated = await updateDocument(COLLECTION(), doc.$id, { + status: STATUS.open, + files: JSON.stringify([...filesOf(doc), ...added]), + history: withHistory(doc, { type: 'antwort', by: 'customer', text, files: added.map((f) => f.id) }), + }) + + const name = customer.name || customer.companyName || doc.email + await sendAdminNotice(`Übernahmegutschrift: Antwort von ${name}`, [ + `${name} hat auf die Rückfrage zum Antrag ${shortRef(doc.$id)} geantwortet:`, + '', + text || '(ohne Text)', + ...(added.length ? ['', `Neue Nachweise: ${added.map((f) => f.name).join(', ')}`] : []), + '', + `Prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`, + ]) + return updated +} + +// --------------------------------------------------------------------------- +// Automatische Pruefungen (Hinweise fuer den Admin, keine Entscheidung) +// --------------------------------------------------------------------------- + +// Nameserver-/MX-Hosts bekannter Anbieter -> Name; Aliase fuer den Abgleich mit der Angabe im Antrag +const PROVIDERS = [ + { name: 'IONOS (1&1)', hosts: /(^|\.)(ui-dns\.(de|com|org|biz)|ionos\.[a-z]+|kundenserver\.de|1and1\.[a-z]+|schlund\.de)$/, aliases: ['ionos', '1und1', '1and1', '11', 'united internet'] }, + { name: 'STRATO', hosts: /(^|\.)(rzone\.de|strato\.[a-z]+)$/, aliases: ['strato'] }, + { name: 'Hetzner', hosts: /(^|\.)(hetzner\.(com|de)|your-server\.de|second-ns\.(de|com))$/, aliases: ['hetzner'] }, + { name: 'ALL-INKL.COM', hosts: /(^|\.)(kasserver\.com|all-inkl\.com)$/, aliases: ['allinkl', 'kasserver'] }, + { name: 'netcup', hosts: /(^|\.)netcup\.(net|de)$/, aliases: ['netcup'] }, + { name: 'Hostinger', hosts: /(^|\.)(hostinger\.[a-z]+|dns-parking\.com)$/, aliases: ['hostinger'] }, + { name: 'GoDaddy', hosts: /(^|\.)(domaincontrol\.com|secureserver\.net)$/, aliases: ['godaddy', 'host europe', 'hosteurope'] }, + { name: 'Host Europe', hosts: /(^|\.)hosteurope\.de$/, aliases: ['hosteurope'] }, + { name: 'Cloudflare', hosts: /(^|\.)cloudflare\.com$/, aliases: ['cloudflare'] }, + { name: 'Google', hosts: /(^|\.)(google\.com|googledomains\.com)$/, aliases: ['google', 'workspace', 'gsuite'] }, + { name: 'Microsoft 365', hosts: /(^|\.)(outlook\.com|office365\.com)$/, aliases: ['microsoft', 'office', 'outlook', 'm365'] }, + { name: 'united-domains', hosts: /(^|\.)(udag\.(de|net|org)|united-domains\.de)$/, aliases: ['uniteddomains', 'udag'] }, + { name: 'domainfactory', hosts: /(^|\.)(namespace4you\.de|ispgateway\.de|df\.eu)$/, aliases: ['domainfactory', 'df'] }, + { name: 'one.com', hosts: /(^|\.)one\.com$/, aliases: ['onecom'] }, + { name: 'OVH', hosts: /(^|\.)ovh\.(net|de|com)$/, aliases: ['ovh'] }, + { name: 'checkdomain', hosts: /(^|\.)checkdomain\.de$/, aliases: ['checkdomain'] }, + { name: 'Namecheap', hosts: /(^|\.)(registrar-servers\.com|namecheap\.com)$/, aliases: ['namecheap'] }, + { name: 'Porkbun', hosts: /(^|\.)porkbun\.com$/, aliases: ['porkbun'] }, + { name: 'Wix', hosts: /(^|\.)wixdns\.net$/, aliases: ['wix'] }, + { name: 'Jimdo', hosts: /(^|\.)jimdo\.com$/, aliases: ['jimdo'] }, + { name: 'Mittwald', hosts: /(^|\.)mittwald\.de$/, aliases: ['mittwald'] }, +] + +const squash = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '') + +function detectProviders(hosts) { + const found = new Map() + for (const host of hosts) { + const h = String(host || '').toLowerCase().replace(/\.$/, '') + const p = PROVIDERS.find((x) => x.hosts.test(h)) + if (p) found.set(p.name, p) + } + return [...found.values()] +} + +function providerMatches(provider, claimed) { + const c = squash(claimed) + if (!c) return false + return [provider.name, ...provider.aliases].some((a) => { + const s = squash(a) + return s.length >= 2 && (c.includes(s) || s.includes(c)) + }) +} + +let ownIpsCache = null +async function ownServerIps(resolver) { + if (!ownIpsCache?.length) { + ownIpsCache = await resolver.resolve4(new URL(config.publicBaseUrl).hostname).catch(() => []) + } + return ownIpsCache +} + +async function dnsCheck(domain, claimedProvider) { + const resolver = new Resolver({ timeout: 3000, tries: 2 }) + const settle = (p) => p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, code: e.code })) + const host = domainToASCII(domain) + const [ns, mx, a, own] = await Promise.all([ + settle(resolver.resolveNs(host)), + settle(resolver.resolveMx(host)), + settle(resolver.resolve4(host)), + ownServerIps(resolver), + ]) + const nsHosts = ns.ok ? ns.v : [] + const mxHosts = mx.ok ? mx.v.sort((x, y) => x.priority - y.priority).map((m) => m.exchange) : [] + const ips = a.ok ? a.v : [] + const detail = [ + `Nameserver: ${nsHosts.join(', ') || '–'}`, + `Mailserver: ${mxHosts.join(', ') || '–'}`, + `IP: ${ips.join(', ') || '–'}`, + ].join(' · ') + + if (!ns.ok && !a.ok && ['ENOTFOUND', 'ENODATA'].includes(ns.code)) { + return { key: 'dns', status: 'warn', label: `Domain ${domain} ist im DNS nicht auffindbar`, detail } + } + const results = [] + if (ips.length && ips.some((ip) => own.includes(ip))) { + results.push({ key: 'dns-webklar', status: 'info', label: 'Die Domain zeigt bereits auf den WEBklar-Server', detail: `IP: ${ips.join(', ')}` }) + } + const detected = detectProviders([...nsHosts, ...mxHosts]) + const match = detected.find((p) => providerMatches(p, claimedProvider)) + if (match) { + results.push({ key: 'dns', status: 'ok', label: `DNS passt zum angegebenen Anbieter (${match.name})`, detail }) + } else if (detected.length) { + results.push({ + key: 'dns', + status: 'info', + label: `DNS zeigt auf ${detected.map((p) => p.name).join(' / ')}, im Antrag steht „${claimedProvider}“ – mit der Rechnung abgleichen`, + detail, + }) + } else { + results.push({ key: 'dns', status: 'info', label: 'Anbieter aus dem DNS nicht erkennbar – Einträge mit der Rechnung abgleichen', detail }) + } + return results +} + +/** + * Hinweise fuer die Pruefung: doppelte Nachweise, schon gutgeschriebene Domain, Zeitraum, + * Abo-Status, Plausibilitaet des Betrags und ein DNS-Abgleich der Domain mit dem Anbieter. + */ +export async function runAutoChecks(doc, { allRequests, subscription, calc }) { + const checks = [] + const others = allRequests.filter((r) => r.$id !== doc.$id) + const ref = (r) => `${shortRef(r.$id)} (${r.status}, ${fmtDate(r.$createdAt)})` + + const hashes = new Set(filesOf(doc).map((f) => f.sha256)) + const dupFiles = others.filter((r) => filesOf(r).some((f) => hashes.has(f.sha256))) + checks.push( + dupFiles.length + ? { key: 'duplicate-file', status: 'warn', label: 'Derselbe Nachweis wurde schon mit einem anderen Antrag eingereicht', detail: dupFiles.map(ref).join(', ') } + : { key: 'duplicate-file', status: 'ok', label: 'Nachweis wurde noch nicht mit einem anderen Antrag eingereicht' } + ) + + if (doc.domain) { + const sameDomain = others.filter((r) => r.domain === doc.domain && r.status === STATUS.approved) + checks.push( + sameDomain.length + ? { key: 'duplicate-domain', status: 'warn', label: `Für ${doc.domain} wurde schon eine Gutschrift genehmigt`, detail: sameDomain.map(ref).join(', ') } + : { key: 'duplicate-domain', status: 'ok', label: `Für ${doc.domain} gibt es noch keine genehmigte Gutschrift` } + ) + } + + if (!subscription) { + checks.push({ key: 'abo', status: 'warn', label: 'Kunde hat kein Wartungs-Abo – eine Gutschrift kann nicht verrechnet werden' }) + } else if (!isActiveSub(subscription)) { + checks.push({ key: 'abo', status: 'warn', label: `Wartungs-Abo ist beendet (Status ${subscription.status}) – keine Verrechnung möglich` }) + } else { + checks.push({ + key: 'abo', + status: subscription.cancelAtPeriodEnd ? 'info' : 'ok', + label: `Wartungs-Abo läuft, Leistungsbeginn ${fmtDate(serviceStartOf(subscription))}${subscription.cancelAtPeriodEnd ? `, gekündigt zum ${fmtDate(subscription.cancelAt || subscription.currentPeriodEnd)}` : ''}`, + }) + } + + if (calc) { + checks.push( + calc.overlapDays > 0 + ? { key: 'period', status: 'ok', label: `Doppelt bezahlt: ${calc.overlapDays} Tage (${fmtDate(calc.overlapStart)} – ${fmtDate(calc.periodEnd)})` } + : { key: 'period', status: 'warn', label: 'Der bezahlte Zeitraum endet vor dem Leistungsbeginn des Abos – keine Doppelzahlung' } + ) + } + + const monthly = doc.invoiceAmountCents / Math.max(1, doc.billingMonths) + if (monthly > config.stripe.priceAmountCents) { + checks.push({ + key: 'amount', + status: 'info', + label: `Beim bisherigen Anbieter ${fmtEuro(monthly)} pro Monat – mehr als das Abo. Enthält die Rechnung weitere Leistungen?`, + }) + } + + if (doc.domain) { + try { + checks.push(...[].concat(await dnsCheck(doc.domain, doc.provider))) + } catch (err) { + checks.push({ key: 'dns', status: 'info', label: 'DNS-Abfrage fehlgeschlagen', detail: err.message }) + } + } else { + checks.push({ key: 'dns', status: 'info', label: 'Keine Domain angegeben – kein DNS-Abgleich möglich' }) + } + return checks +} + +// --------------------------------------------------------------------------- +// Admin: Detailansicht, Entscheidung, Verrechnung +// --------------------------------------------------------------------------- + +function defaultTakeoverFrom(doc, subscription) { + return doc.takeoverFrom || serviceStartOf(subscription) || new Date().toISOString() +} + +async function stripeCreditBalance(stripeCustomerId) { + if (!stripeCustomerId || !isStripeConfigured()) return null + try { + const customer = await getStripe().customers.retrieve(stripeCustomerId) + if (customer.deleted) return null + // Stripe: negativer Saldo = Guthaben des Kunden + return Math.max(0, -(customer.balance || 0)) + } catch (err) { + console.warn('[takeover] Stripe-Guthaben nicht abrufbar:', err.message) + return null + } +} + +export async function getAdminDetail(id) { + const doc = await getTakeoverRequest(id) + const [customer, subscription, allRequests] = await Promise.all([ + getDocument(config.collections.customers, doc.customerId).catch(() => null), + getCurrentSubscription(doc.customerId).catch(() => null), + listTakeoverRequests(), + ]) + const takeoverFrom = defaultTakeoverFrom(doc, subscription) + const calc = computeTakeoverCredit({ ...doc, takeoverFrom }) + const [checks, creditBalanceCents] = await Promise.all([ + runAutoChecks(doc, { allRequests, subscription, calc }), + stripeCreditBalance(subscription?.stripeCustomerId), + ]) + const testMode = /^(sk|rk)_test_/.test(config.stripe.secretKey) + return { + request: { + id: doc.$id, + ref: shortRef(doc.$id), + createdAt: doc.$createdAt, + email: doc.email, + services: servicesLabel(doc.services), + serviceKeys: String(doc.services || '').split(',').filter(Boolean), + provider: doc.provider, + domain: doc.domain, + invoiceAmountCents: doc.invoiceAmountCents, + billingMonths: doc.billingMonths, + paidUntil: doc.paidUntil, + // Kalendertage fuer im Rechner + paidUntilDate: ymd(dayOf(doc.paidUntil)), + takeoverFromDate: ymd(dayOf(takeoverFrom)), + message: doc.message, + status: doc.status, + files: filesOf(doc).map(({ id: fileId, name, type, size, uploadedAt }) => ({ id: fileId, name, type, size, uploadedAt })), + history: historyOf(doc), + takeoverFrom, + amountCents: doc.amountCents ?? null, + calculation: doc.calculation || '', + checks: checksOf(doc), + decisionNote: doc.decisionNote || '', + decidedAt: doc.decidedAt || null, + decidedBy: doc.decidedBy || '', + appliedAt: doc.appliedAt || null, + stripeBalanceTransactionId: doc.stripeBalanceTransactionId || '', + conditionsVersion: doc.conditionsVersion || '', + consentAt: doc.consentAt || null, + }, + customer: customer + ? { id: customer.$id, code: customer.code || '', name: customer.name || '', companyName: customer.companyName || '', email: customer.email || '' } + : { id: doc.customerId, code: '', name: '(Kunde nicht gefunden)', companyName: '', email: '' }, + subscription: subscription + ? { + status: subscription.status, + active: isActiveSub(subscription), + startedAt: subscription.startedAt, + serviceStart: serviceStartOf(subscription), + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAt: subscription.cancelAtPeriodEnd ? subscription.cancelAt || subscription.currentPeriodEnd : null, + stripeCustomerId: subscription.stripeCustomerId, + stripeUrl: subscription.stripeCustomerId + ? `https://dashboard.stripe.com/${testMode ? 'test/' : ''}customers/${subscription.stripeCustomerId}` + : '', + creditBalanceCents, + } + : null, + calc, + autoChecks: checks, + requiredChecks: REQUIRED_CHECKS, + otherRequests: allRequests + .filter((r) => r.customerId === doc.customerId && r.$id !== doc.$id) + .map((r) => ({ id: r.$id, ref: shortRef(r.$id), createdAt: r.$createdAt, status: r.status, amountCents: r.amountCents ?? null, services: servicesLabel(r.services) })), + } +} + +async function findBalanceTransaction(stripeCustomerId, requestId) { + const list = await getStripe().customers.listBalanceTransactions(stripeCustomerId, { limit: 100 }) + return list.data.find((t) => t.metadata?.webklarTakeoverCreditId === requestId) || null +} + +/** + * Genehmigte Gutschrift als Stripe-Kundenguthaben buchen. Doppelt abgesichert gegen + * Doppelbuchung: vorhandene Buchung mit dieser Antrags-ID wird wiederverwendet, und der + * Idempotency-Key faengt parallele Klicks ab. + */ +export async function applyTakeoverCredit(doc) { + if (doc.status !== STATUS.approved) throw httpError(409, 'Nur genehmigte Anträge können verrechnet werden.') + if (doc.appliedAt) return { applied: true, doc } + if (!isStripeConfigured()) return { applied: false, doc, reason: 'Stripe ist nicht eingerichtet.' } + const sub = await getCurrentSubscription(doc.customerId) + if (!isActiveSub(sub) || !sub.stripeCustomerId) { + return { applied: false, doc, reason: 'Der Kunde hat kein laufendes Wartungs-Abo.' } + } + try { + const txn = + (await findBalanceTransaction(sub.stripeCustomerId, doc.$id)) || + (await getStripe().customers.createBalanceTransaction( + sub.stripeCustomerId, + { + amount: -doc.amountCents, + currency: 'eur', + description: `Übernahmegutschrift ${shortRef(doc.$id)} (${servicesLabel(doc.services)})`, + metadata: { webklarTakeoverCreditId: doc.$id, webklarCustomerId: doc.customerId }, + }, + { idempotencyKey: `webklar-takeover-credit-${doc.$id}` } + )) + const updated = await updateDocument(COLLECTION(), doc.$id, { + appliedAt: new Date(txn.created * 1000).toISOString(), + stripeCustomerId: sub.stripeCustomerId, + stripeBalanceTransactionId: txn.id, + history: withHistory(doc, { type: 'verrechnet', by: 'system', text: `Stripe-Guthaben ${fmtEuro(doc.amountCents)} gebucht (${txn.id})` }), + }) + return { applied: true, doc: updated } + } catch (err) { + console.error(`[takeover] Verrechnung ${doc.$id} fehlgeschlagen:`, err.message) + return { applied: false, doc, reason: `Stripe: ${err.message}` } + } +} + +function customerGreeting(doc, customer) { + return `Guten Tag ${customer?.name || ''}`.trim() + ',' +} + +async function mailDecision(doc, customer, action) { + const ref = shortRef(doc.$id) + const submitted = fmtDate(doc.$createdAt) + const note = String(doc.decisionNote || '').trim() + const footer = ['', 'Viele Grüße', 'Ihr WEBklar-Team', '', FOOTER] + if (action === 'approve') { + return sendCustomerMail(doc.email, 'Ihre Übernahmegutschrift ist genehmigt – WEBklar', [ + customerGreeting(doc, customer), + '', + `wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} geprüft und genehmigt.`, + '', + ` Antrag: ${ref}`, + ` Leistung: ${servicesLabel(doc.services)} (bisher bei ${doc.provider})`, + ` Gutschrift: ${fmtEuro(doc.amountCents)}`, + ` Berechnung: ${doc.calculation}`, + ...(note ? ['', note] : []), + '', + 'Die Gutschrift verrechnen wir automatisch mit den nächsten Monatsgebühren Ihres Wartungs-Abos.', + 'Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben. Eine Auszahlung ist nicht möglich;', + 'ein bei Vertragsende noch nicht verrechneter Rest entfällt.', + ...footer, + ]) + } + if (action === 'reject') { + return sendCustomerMail(doc.email, 'Ihr Antrag auf Übernahmegutschrift – WEBklar', [ + customerGreeting(doc, customer), + '', + `wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) geprüft und können ihn leider nicht genehmigen.`, + '', + note, + '', + 'Bei Fragen antworten Sie einfach auf diese E-Mail.', + ...footer, + ]) + } + const question = [...historyOf(doc)].reverse().find((h) => h.type === 'rueckfrage')?.text || '' + return sendCustomerMail(doc.email, 'Rückfrage zu Ihrem Antrag auf Übernahmegutschrift – WEBklar', [ + customerGreeting(doc, customer), + '', + `zu Ihrem Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) haben wir eine Rückfrage:`, + '', + question, + '', + `Bitte antworten Sie im Kundenportal unter ${config.publicBaseUrl}/dashboard.html – dort können Sie`, + 'auch weitere Nachweise hochladen. Alternativ antworten Sie einfach auf diese E-Mail.', + ...footer, + ]) +} + +/** + * Entscheidung des Admins. + * action: 'approve' (amountCents, takeoverFrom, checks, note optional) | 'question' (note) | 'reject' (note) + */ +export async function decideTakeoverRequest(id, body, admin) { + const doc = await getTakeoverRequest(id) + if (![STATUS.open, STATUS.question].includes(doc.status)) { + throw httpError(409, 'Über diesen Antrag wurde bereits entschieden.') + } + const action = String(body?.action || '') + const note = clean(body?.note, 4000) + const by = admin?.name ? `${admin.name}${admin.email ? ` <${admin.email}>` : ''}` : admin?.email || 'Admin' + const now = new Date().toISOString() + const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null) + + if (action === 'question' || action === 'reject') { + if (note.length < 5) { + throw httpError(400, action === 'question' ? 'Bitte formulieren Sie die Rückfrage an den Kunden.' : 'Bitte begründen Sie die Ablehnung für den Kunden.') + } + const data = + action === 'question' + ? { status: STATUS.question, history: withHistory(doc, { type: 'rueckfrage', by, text: note }) } + : { + status: STATUS.rejected, + decisionNote: note, + decidedAt: now, + decidedBy: by, + history: withHistory(doc, { type: 'abgelehnt', by, text: note }), + } + const updated = await updateDocument(COLLECTION(), doc.$id, data) + const mail = await mailDecision(updated, customer, action).then( + () => ({ sent: true }), + (e) => ({ sent: false, reason: e.message }) + ) + return { doc: updated, mail } + } + + if (action !== 'approve') throw httpError(400, 'Unbekannte Aktion') + + const amountCents = Math.round(Number(body?.amountCents)) + if (!(amountCents > 0) || amountCents > config.takeover.maxAmountCents) { + throw httpError(400, `Bitte einen Gutschriftsbetrag zwischen 0,01 € und ${fmtEuro(config.takeover.maxAmountCents)} angeben.`) + } + const missing = REQUIRED_CHECKS.filter((c) => body?.checks?.[c.key] !== true) + if (missing.length) { + throw httpError(400, `Bitte zuerst prüfen und abhaken: ${missing.map((c) => c.label).join('; ')}`) + } + const takeoverFrom = dayOf(body?.takeoverFrom) + if (!takeoverFrom) throw httpError(400, 'Bitte das Datum „Übernahme ab“ angeben.') + // Werte laut Rechnung: der Admin kann Tippfehler des Kunden im Rechner korrigieren + const verified = readInvoiceFigures(body, doc) + const sub = await getCurrentSubscription(doc.customerId) + if (!isActiveSub(sub)) { + throw httpError(409, 'Der Kunde hat kein laufendes Wartungs-Abo – die Gutschrift kann nicht verrechnet werden.') + } + + const corrections = [ + verified.invoiceAmountCents !== doc.invoiceAmountCents && + `Rechnungsbetrag ${fmtEuro(doc.invoiceAmountCents)} → ${fmtEuro(verified.invoiceAmountCents)}`, + verified.billingMonths !== doc.billingMonths && + `Zeitraum ${monthsLabel(doc.billingMonths)} → ${monthsLabel(verified.billingMonths)}`, + ymd(dayOf(verified.paidUntil)) !== ymd(dayOf(doc.paidUntil)) && + `bezahlt bis ${fmtDate(doc.paidUntil)} → ${fmtDate(verified.paidUntil)}`, + ].filter(Boolean) + const calc = computeTakeoverCredit({ ...verified, takeoverFrom: takeoverFrom.toISOString() }) + const approved = await updateDocument(COLLECTION(), doc.$id, { + ...verified, + status: STATUS.approved, + amountCents, + takeoverFrom: takeoverFrom.toISOString(), + calculation: calculationText(verified, calc, amountCents).slice(0, 1000), + checks: JSON.stringify({ items: Object.fromEntries(REQUIRED_CHECKS.map((c) => [c.key, true])), by, at: now }), + decisionNote: note, + decidedAt: now, + decidedBy: by, + history: withHistory(doc, { + type: 'genehmigt', + by, + text: [ + fmtEuro(amountCents), + corrections.length ? `Angaben nach Prüfung korrigiert: ${corrections.join(', ')}` : '', + note, + ].filter(Boolean).join(' – '), + }), + }) + const result = await applyTakeoverCredit(approved) + const mail = await mailDecision(result.doc, customer, 'approve').then( + () => ({ sent: true }), + (e) => ({ sent: false, reason: e.message }) + ) + return { doc: result.doc, applied: result.applied, reason: result.reason, mail } +} + +// --------------------------------------------------------------------------- +// Aufbewahrung: Nachweise abgelehnter Antraege nach Ablauf der Frist loeschen +// --------------------------------------------------------------------------- + +export async function purgeExpiredUploads() { + const cutoff = Date.now() - config.takeover.rejectedRetentionDays * DAY_MS + const docs = await listDocuments(COLLECTION(), [Query.equal('status', STATUS.rejected), Query.limit(1000)]) + let purged = 0 + for (const doc of docs) { + if (!filesOf(doc).length || !doc.decidedAt || new Date(doc.decidedAt).getTime() > cutoff) continue + await rm(path.join(config.takeover.uploadDir, doc.$id), { recursive: true, force: true }) + await updateDocument(COLLECTION(), doc.$id, { + files: '[]', + history: withHistory(doc, { type: 'nachweise-geloescht', by: 'system', text: 'Aufbewahrungsfrist abgelaufen' }), + }) + purged += 1 + } + return { purged } +}