Files
DogFatherGitandClaude Opus 5 65b98ace89 Die Uhr laeuft erst, wenn die Anzahlung da ist
Bisher begann der Liefertermin mit der Annahme. Das ist unfair in beide
Richtungen: Wer zehn Tage bis zur Zahlung braucht, verbraucht zehn Tage
der zugesagten Zeit, ohne dass ein Handschlag Arbeit passiert waere --
und ich stehe am Ende als der da, der seinen Termin reisst.

Ein Projekt hat jetzt drei Abschnitte statt zwei:
  1. angenommen, wartet auf Anzahlung  -> Uhr steht
  2. Anzahlung da                      -> Uhr laeuft, Termin ab HEUTE neu
  3. uebergeben oder abgebrochen       -> Uhr steht wieder

Der Zahlungseingang loest alles Weitere von selbst aus: Uhr starten,
Termin neu rechnen, Status von briefing auf design, 'wer ist am Zug' auf
mich, Benachrichtigung in der Verwaltung. Der bei der Annahme genannte
Termin bleibt als termin_geplant_am erhalten, und die Meldung nennt
BEIDE -- so sieht man, dass sich etwas verschoben hat, ohne nachrechnen
zu muessen.

Eingehaengt an der Stelle, an der beide Wege zusammenlaufen (PayPals
Meldung UND das Vermerken von Hand). Nur am Webhook haenge sich die
Seite verschieden verhalten, je nachdem WIE das Geld ankam -- eine von
Hand verbuchte Zahlung startete die Uhr nie.

Zwei Grundsaetze fuer die Automatik: Sie setzt Dinge in Gang, nimmt aber
nie eine Entscheidung zurueck, die ein Mensch getroffen hat (ein von Hand
pausiertes Projekt wird nicht kommentarlos wieder gestartet). Und jeder
Schritt hinterlaesst eine Spur im Verlauf UND als Meldung -- eine
Automatik, die stillschweigend arbeitet, ist kein Helfer, sondern ein
Raetsel.

ABBRECHEN
Ein angenommener Auftrag bleibt abbrechbar: Der Kunde zahlt nicht,
meldet sich nicht, springt ab. Vorschau und Ausfuehrung sind getrennt --
die Seite rechnet aus dem Aufgabenfortschritt vor, wie viel Leistung
erbracht wurde, und schlaegt daraus einen Erstattungsbetrag vor. Der
Betrag ist ein VORSCHLAG: Ob im Einzelfall mehr oder weniger angemessen
ist, haengt an Dingen, die keine Tabelle kennt. Offene Rechnungen werden
storniert (eine Zahlungsaufforderung ohne Gegenleistung), bezahlte
bleiben unangetastet, und die Rueckzahlung loest die Seite bewusst NICHT
selbst aus -- PayPal-Rueckzahlungen sind nicht umkehrbar.

BENACHRICHTIGUNGEN
Eigene Tabelle statt im Verlauf: Der Verlauf haelt fest, WAS geschehen
ist -- vollstaendig, zum Nachschlagen. Eine Benachrichtigung ist ein
Anstupsen, das gelesen und weggelegt wird. Beides in einer Tabelle
hiesse: entweder ein Verlauf voller Rauschen oder Meldungen, die man
nicht wegklicken kann. Wegklicken markiert nur als gelesen, loescht
nichts.

Dazu die Liste der Projekte, die seit ueber einer Woche auf ihre
Anzahlung warten. Sie stehen in keiner anderen Zahl, weil ihre Uhr nie
zu laufen begann -- ohne diesen Hinweis vergisst man sie.

Geprueft: 44 gegen eine echte Datenbank. Darunter der Kern -- die
Annahme wird zehn Tage zurueckdatiert, und der Termin muss danach
trotzdem volle 20 Werktage entfernt liegen. Beim Bauen des Tests selbst
ein Fehler gefunden: Die erste Fassung datierte nur die Annahme zurueck,
nicht den damals errechneten Termin, und bildete damit genau den Fall
nicht ab, um den es geht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 11:56:08 +02:00

775 lines
32 KiB
JavaScript

/* =====================================================================
routes/webdesign-zahlungen.js — Zahlungen im Kundenportal
Das PayPal-Modul (lib/webdesign-paypal.js) und die Tabellen standen
seit dem 22.08.2026. Was fehlte, waren die Wege dorthin.
DREI DINGE, DIE HIER ANDERS SIND ALS BEI EINEM ÜBLICHEN BEZAHLKNOPF
1. DIE ZUSTIMMUNG IST TEIL DER ZAHLUNG, nicht ein Häkchen daneben.
Die 30-%-Anzahlung wird fällig, BEVOR die 14-tägige Widerrufsfrist
abgelaufen ist. Damit die Arbeit trotzdem sofort beginnen darf,
verlangt § 356 Abs. 4 BGB zweierlei vom Verbraucher: die
ausdrückliche Zustimmung zum vorzeitigen Beginn UND die Bestätigung,
dass er dadurch sein Widerrufsrecht verliert.
Die Beweislast für beides liegt beim Unternehmer. Ohne Nachweis
bleibt das Widerrufsrecht bestehen — und die bereits geleistete
Arbeit wäre im Streit schwer durchsetzbar.
Deshalb wird die Zustimmung nicht nur als Wahrheitswert gespeichert,
sondern mit dem WORTLAUT, den der Kunde gesehen hat, und mit
Zeitstempel. Im Streit zählt nicht "hat zugestimmt", sondern WOZU.
Und weil das nur Verbraucher betrifft: Bei einem Geschäftskunden
wird gar nicht erst danach gefragt. Ein Unternehmer hat kein
Widerrufsrecht, ihn eine Verzichtserklärung unterschreiben zu
lassen wäre sinnlos und würde nur Misstrauen wecken.
2. NICHT EINGERICHTET IST EIN ZUSTAND, KEIN FEHLER.
Solange die PayPal-Zugangsdaten fehlen, sagt die Seite das
freundlich und nennt den Weg per Überweisung. Ein Knopf, der beim
Klick eine technische Fehlermeldung wirft, ist schlechter als
keiner — er sieht nach einer kaputten Seite aus, und niemand
bezahlt gern auf einer kaputten Seite.
3. DER WEBHOOK IST DIE WAHRHEIT, NICHT DIE RÜCKKEHR DES BROWSERS.
Der Kunde kann das Fenster schliessen, bevor er zurückgeleitet
wird. Die Zahlung ist dann trotzdem erfolgt. Deshalb schreibt der
Webhook den endgültigen Stand — der Rückkehrweg ist nur der
schnellere von beiden, nicht der verlässlichere.
===================================================================== */
import { db } from "../db.js";
import { json } from "../lib/http.js";
import { requireSession } from "../lib/auth.js";
import { hasPermission } from "../lib/permissions.js";
import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
import { brauchtKunde } from "./webdesign-portal.js";
import { zustimmungSpeichern } from "./webdesign-widerruf.js";
import * as paypal from "../lib/webdesign-paypal.js";
import * as geheim from "../lib/webdesign-geheimnisse.js";
import { zahlungVerbucht, meldungenLesen, meldungenOffen, erstattungsVorschlag, wartetAufAnzahlung }
from "../lib/webdesign-automatik.js";
import {
jetzt, neueId, naechsteNummer, text, verlauf, centZuText,
besucherIp, spracheOderDe,
} from "../lib/webdesign-helfer.js";
/* Der Wortlaut, dem der Verbraucher zustimmt. Er steht HIER und nicht
im Browser: Was gespeichert wird, muss das sein, was der Server
kennt — sonst könnte man ihm einen beliebigen Text unterschieben.
Die Fassung wandert mit in die Datenbank, damit später nachvollziehbar
bleibt, welche Formulierung galt. */
export const SOFORTBEGINN_FASSUNG = "2026-08-23";
export const SOFORTBEGINN_TEXT = {
de: "Ich verlange ausdrücklich, dass Dogfather Webdesign vor Ablauf der "
+ "Widerrufsfrist mit der Arbeit beginnt. Mir ist bekannt, dass ich mein "
+ "Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
"de-CH": "Ich verlang usdrücklich, dass Dogfather Webdesign vor Ablauf vo dr "
+ "Widerrüefsfrist mit dr Arbet aafangt. Mir isch bekannt, dass ich mis "
+ "Widerrüefsrächt verlüre, sobald dr Vertrag vollständig erfüllt isch.",
en: "I expressly request that Dogfather Web Design begin work before the "
+ "withdrawal period expires. I am aware that I lose my right of withdrawal "
+ "once the contract has been fully performed.",
fr: "Je demande expressément que Dogfather Web Design commence le travail avant "
+ "l'expiration du délai de rétractation. Je sais que je perds mon droit de "
+ "rétractation dès que le contrat est intégralement exécuté.",
pt: "Solicito expressamente que a Dogfather Web Design comece o trabalho antes "
+ "do fim do prazo de rescisão. Sei que perco o meu direito de rescisão assim "
+ "que o contrato estiver integralmente cumprido.",
};
async function darf(req, recht) {
const ausweis = ausweisPruefen(req);
if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
const sitzung = await requireSession(req);
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
if (sitzung.isOwner) return { erlaubt: true, sitzung };
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
}
const BASIS = () => process.env.SITE_BASIS_URL || "https://dogfather-universe.com";
/* ---------------------------------------------------------------------
ZUSTAND ABFRAGEN — braucht der Kunde überhaupt einen Bezahlknopf?
GET /webdesign/portal/zahlung/:id
--------------------------------------------------------------------- */
export function zahlungAnsehen(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const z = db
.prepare(`SELECT * FROM wd_zahlungen WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!z) return json(res, { ok: false, error: "Zahlung nicht gefunden." }, 404);
const sprache = spracheOderDe(kunde.sprache);
/* Nur Verbraucher bekommen die Zustimmungsfrage, und auch nur, solange
sie noch nicht erteilt wurde. */
const brauchtZustimmung = z.kundenart !== "unternehmen"
&& z.art === "anzahlung"
&& !z.widerruf_zugestimmt;
return json(res, {
ok: true,
zahlung: {
id: z.id, nummer: z.nummer, art: z.art, zweck: z.zweck_text,
betragText: centZuText(z.betrag_cent, z.waehrung),
status: z.status,
},
eingerichtet: paypal.istEingerichtet("einmal"),
brauchtZustimmung,
zustimmungstext: brauchtZustimmung
? (SOFORTBEGINN_TEXT[sprache] || SOFORTBEGINN_TEXT.de)
: null,
});
}
/* ---------------------------------------------------------------------
ZAHLUNG STARTEN
POST /webdesign/portal/zahlung/:id/start
--------------------------------------------------------------------- */
export async function zahlungStarten(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const z = db
.prepare(`SELECT * FROM wd_zahlungen WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!z) return json(res, { ok: false, error: "Zahlung nicht gefunden." }, 404);
if (z.status === "bezahlt") {
return json(res, { ok: false, error: "Diese Zahlung ist bereits beglichen." }, 409);
}
/* Ohne Zugangsdaten NICHT scheitern, sondern ehrlich sein. */
if (!paypal.istEingerichtet("einmal")) {
return json(res, {
ok: false,
nichtEingerichtet: true,
error: "Die Zahlung über PayPal ist noch nicht freigeschaltet. "
+ "Schreib kurz über das Postfach — dann bekommst du die Bankverbindung.",
}, 503);
}
const sprache = spracheOderDe(kunde.sprache);
/* ---- § 356 Abs. 4 BGB: Zustimmung zum vorzeitigen Leistungsbeginn ---- */
const brauchtZustimmung = z.kundenart !== "unternehmen"
&& z.art === "anzahlung"
&& !z.widerruf_zugestimmt;
if (brauchtZustimmung) {
/* === true, nicht bloss "wahrheitsähnlich". Bei einer Erklärung, mit
der jemand ein Recht aufgibt, darf eine "1" oder ein "ja" nicht
genügen — die Absicht muss eindeutig übermittelt worden sein. */
if (req.body?.sofortbeginn !== true) {
return json(res, {
ok: false,
brauchtZustimmung: true,
zustimmungstext: SOFORTBEGINN_TEXT[sprache] || SOFORTBEGINN_TEXT.de,
error: "Bitte bestätige zuerst, dass sofort begonnen werden darf.",
}, 400);
}
const wortlaut = SOFORTBEGINN_TEXT[sprache] || SOFORTBEGINN_TEXT.de;
const nun = jetzt();
db.prepare(
`UPDATE wd_zahlungen SET widerruf_zugestimmt = 1, widerruf_zugestimmt_am = ?,
aktualisiert_am = ? WHERE id = ?`
).run(nun, nun, z.id);
/* Zusätzlich als eigenständiger Nachweis — die Zahlungszeile kann
sich ändern, der Nachweis darf das nicht. */
zustimmungSpeichern({
kundeId: kunde.id,
projektId: z.projekt_id,
art: "sofortbeginn",
wortlaut,
fassung: SOFORTBEGINN_FASSUNG,
sprache,
ip: besucherIp(req),
});
verlauf("zahlung", z.id, "sofortbeginn_zugestimmt", "kunde:" + kunde.id, { fassung: SOFORTBEGINN_FASSUNG });
}
try {
const bestellung = await paypal.bestellungAnlegen({
zahlungId: z.id,
nummer: z.nummer,
betragCent: z.betrag_cent,
waehrung: z.waehrung || "EUR",
zweck: z.zweck_text,
rueckkehrUrl: `${BASIS()}/webdesign/portal.html?zahlung=${encodeURIComponent(z.id)}&zurueck=1`,
abbruchUrl: `${BASIS()}/webdesign/portal.html?zahlung=${encodeURIComponent(z.id)}&abbruch=1`,
sprache,
});
db.prepare(
`UPDATE wd_zahlungen SET status = 'freigegeben', paypal_order_id = ?,
freigegeben_am = ?, aktualisiert_am = ? WHERE id = ?`
).run(bestellung.orderId, jetzt(), jetzt(), z.id);
verlauf("zahlung", z.id, "paypal_gestartet", "kunde:" + kunde.id, { orderId: bestellung.orderId });
return json(res, { ok: true, weiterUrl: bestellung.approveUrl, orderId: bestellung.orderId });
} catch (fehler) {
db.prepare(`UPDATE wd_zahlungen SET fehler_text = ?, aktualisiert_am = ? WHERE id = ?`)
.run(String(fehler.message || fehler).slice(0, 500), jetzt(), z.id);
verlauf("zahlung", z.id, "paypal_fehler", "kunde:" + kunde.id, null);
return json(res, {
ok: false,
error: "Die Zahlung konnte nicht gestartet werden. Bitte versuch es gleich noch einmal "
+ "oder schreib kurz über das Postfach.",
}, 502);
}
}
/* ---------------------------------------------------------------------
ZAHLUNG ABSCHLIESSEN (Rückkehr aus PayPal)
POST /webdesign/portal/zahlung/:id/abschluss
Der schnellere von zwei Wegen. Der verlässliche ist der Webhook.
--------------------------------------------------------------------- */
export async function zahlungAbschliessen(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const z = db
.prepare(`SELECT * FROM wd_zahlungen WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!z) return json(res, { ok: false, error: "Zahlung nicht gefunden." }, 404);
/* Schon durch — etwa weil der Webhook schneller war. Das ist kein
Fehler, sondern der Normalfall bei guter Verbindung. */
if (z.status === "bezahlt") {
return json(res, { ok: true, schonBezahlt: true, status: "bezahlt" });
}
if (!z.paypal_order_id) {
return json(res, { ok: false, error: "Für diese Zahlung wurde noch nichts gestartet." }, 400);
}
try {
const einzug = await paypal.bestellungEinziehen(z.paypal_order_id);
if (einzug.status !== "COMPLETED") {
return json(res, { ok: false, error: "Die Zahlung wurde von PayPal noch nicht abgeschlossen." }, 409);
}
zahlungAlsBezahltVermerken(z.id, einzug, "kunde:" + kunde.id);
return json(res, { ok: true, status: "bezahlt" });
} catch (fehler) {
/* Wichtig: NICHT als Fehler des Kunden darstellen. Das Geld kann
trotzdem geflossen sein — der Webhook klärt es. */
verlauf("zahlung", z.id, "einzug_fehler", "kunde:" + kunde.id, null);
return json(res, {
ok: false,
error: "Wir konnten den Abschluss gerade nicht bestätigen. Falls die Zahlung bei PayPal "
+ "durchgegangen ist, wird sie hier gleich als bezahlt angezeigt — du musst nichts weiter tun.",
}, 502);
}
}
/* Gemeinsame Stelle für "bezahlt". Wird vom Rückkehrweg UND vom Webhook
benutzt, damit beide dieselben Felder setzen. Zwei getrennte
Fassungen würden über kurz oder lang auseinanderlaufen. */
function zahlungAlsBezahltVermerken(zahlungId, einzug, wer) {
const nun = jetzt();
db.prepare(
`UPDATE wd_zahlungen SET status = 'bezahlt', paypal_capture_id = ?,
netto_cent = COALESCE(?, netto_cent), gebuehr_cent = COALESCE(?, gebuehr_cent),
bezahlt_am = COALESCE(bezahlt_am, ?), aktualisiert_am = ? WHERE id = ?`
).run(
einzug.captureId || null,
einzug.nettoCent ?? null,
einzug.gebuehrCent ?? null,
nun, nun, zahlungId
);
verlauf("zahlung", zahlungId, "bezahlt", wer, { captureId: einzug.captureId || null });
/* AB HIER UEBERNIMMT DIE AUTOMATIK.
Vorher wurde hier nur ein Haekchen am Projekt gesetzt. Das reichte,
solange der Liefertermin mit der Annahme begann -- jetzt haengt an
einer eingehenden Anzahlung deutlich mehr: Die Uhr startet, der
Termin wird ab heute NEU gerechnet, der Status wechselt, und ich
bekomme eine Benachrichtigung.
Bewusst an DIESER Stelle und nicht im Webhook: Hier laufen beide
Wege zusammen -- PayPals Meldung und das Vermerken von Hand. Haenge
man es nur an den Webhook, verhielte sich die Seite verschieden, je
nachdem WIE das Geld ankam, und eine von Hand verbuchte Zahlung
starte die Uhr nie. */
try {
zahlungVerbucht(zahlungId, wer);
} catch (fehler) {
/* Die Zahlung selbst ist zu diesem Zeitpunkt schon sicher verbucht.
Scheitert die Automatik danach, darf das NICHT die Buchung
umwerfen -- lieber ein Projekt, dessen Uhr man von Hand startet,
als eine Zahlung, die verlorengeht. Der Fehler wird aber
festgehalten, damit er nicht unbemerkt bleibt. */
verlauf("zahlung", zahlungId, "automatik_fehlgeschlagen", "system",
String(fehler && fehler.message || fehler).slice(0, 300));
}
}
/* ---------------------------------------------------------------------
WEBHOOK — PayPal ruft hier an
POST /webdesign/paypal-webhook
Öffentlich erreichbar (PayPal hat keine Sitzung), aber durch die
Signaturprüfung geschützt. Ohne gültige Signatur wird nichts
verarbeitet — sonst könnte jeder eine Zahlung als bezahlt melden.
--------------------------------------------------------------------- */
export async function paypalWebhook(req, res) {
const roh = req.rawBody || JSON.stringify(req.body || {});
let ereignis = req.body || {};
let echt = false;
try {
echt = await paypal.webhookEchtheitPruefen(req.headers, roh);
} catch (e) {
echt = false;
}
if (!echt) {
verlauf("paypal", "webhook", "abgewiesen", "paypal", { typ: ereignis.event_type || null });
return json(res, { ok: false }, 400);
}
const id = paypal.ereignisId(ereignis);
/* Doppelte Zustellung ist bei PayPal normal — dieselbe Meldung kommt
bei Zweifeln erneut. Ohne diese Merkliste würde eine Zahlung
mehrfach verbucht. */
const schonDa = db.prepare(`SELECT 1 FROM wd_paypal_ereignisse WHERE event_id = ?`).get(id);
if (schonDa) return json(res, { ok: true, doppelt: true });
db.prepare(
`INSERT INTO wd_paypal_ereignisse (event_id, typ, zahlung_id, signatur_ok, verarbeitet, rohdaten, empfangen_am)
VALUES (?,?,?,1,0,?,?)`
).run(
id, ereignis.event_type || "?",
(ereignis.resource || {}).custom_id || null,
JSON.stringify(ereignis).slice(0, 20000), jetzt()
);
const typ = ereignis.event_type;
const kern = ereignis.resource || {};
if (typ === "PAYMENT.CAPTURE.COMPLETED") {
const zahlungId = kern.custom_id || null;
if (zahlungId) {
const aufschl = kern.seller_receivable_breakdown || {};
zahlungAlsBezahltVermerken(zahlungId, {
captureId: kern.id || null,
nettoCent: aufschl.net_amount ? paypal.paypalZuCent(aufschl.net_amount.value) : null,
gebuehrCent: aufschl.paypal_fee ? paypal.paypalZuCent(aufschl.paypal_fee.value) : null,
}, "paypal:webhook");
}
} else if (typ === "PAYMENT.CAPTURE.DENIED" || typ === "PAYMENT.CAPTURE.REFUNDED") {
const zahlungId = kern.custom_id || null;
if (zahlungId) {
db.prepare(`UPDATE wd_zahlungen SET status = ?, aktualisiert_am = ? WHERE id = ?`)
.run(typ.endsWith("REFUNDED") ? "erstattet" : "fehlge", jetzt(), zahlungId);
verlauf("zahlung", zahlungId, typ.toLowerCase(), "paypal:webhook", null);
}
}
/* Erst NACH der Auswertung als verarbeitet markieren. Bricht der
Server dazwischen ab, steht die Meldung als empfangen-aber-offen in
der Tabelle und faellt beim Nachsehen auf -- statt spurlos als
"schon behandelt" zu gelten. */
db.prepare(`UPDATE wd_paypal_ereignisse SET verarbeitet = 1 WHERE event_id = ?`).run(id);
return json(res, { ok: true });
}
/* ---------------------------------------------------------------------
VERWALTUNG: Zahlung anlegen
POST /webdesign/admin/zahlungen
--------------------------------------------------------------------- */
export async function zahlungAnlegen(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projektId = text(req.body?.projektId, 60);
const projekt = db
.prepare(`SELECT p.*, k.id AS kid FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?`)
.get(projektId);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const art = ["anzahlung", "rest", "aenderung", "sonstige"].includes(req.body?.art)
? req.body.art : "sonstige";
/* Betrag: bei der Anzahlung aus dem Projektpreis BERECHNEN, sonst
eingeben. Ein getippter Anzahlungsbetrag ist eine Fehlerquelle, die
erst beim Geldeingang auffällt. */
let betragCent = null;
if (art === "anzahlung" && projekt.anzahlung_cent) betragCent = projekt.anzahlung_cent;
else if (art === "rest" && projekt.preis_cent != null && projekt.anzahlung_cent != null) {
betragCent = projekt.preis_cent - projekt.anzahlung_cent;
} else {
const euro = Number(req.body?.betragEuro);
if (Number.isFinite(euro) && euro > 0) betragCent = Math.round(euro * 100);
}
if (!betragCent || betragCent <= 0) {
return json(res, { ok: false, error: "Bitte einen Betrag angeben." }, 400);
}
const id = neueId();
const nummer = naechsteNummer("wd_zahlungen", "Z");
db.prepare(
`INSERT INTO wd_zahlungen
(id, nummer, kunde_id, projekt_id, art, zweck_text, betrag_cent, waehrung,
status, kundenart, erstellt_am)
VALUES (?,?,?,?,?,?,?,?, 'offen', ?, ?)`
).run(
id, nummer, projekt.kunde_id, projekt.id, art,
text(req.body?.zweck, 200) || standardZweck(art, projekt.titel),
betragCent, projekt.waehrung || "EUR",
req.body?.kundenart === "unternehmen" ? "unternehmen" : "verbraucher",
jetzt()
);
verlauf("zahlung", id, "angelegt", "team:" + (p.sitzung?.actor || "?"), { nummer, betragCent });
return json(res, { ok: true, id, nummer, betragText: centZuText(betragCent, projekt.waehrung) });
}
function standardZweck(art, titel) {
const t = titel || "Projekt";
if (art === "anzahlung") return `Anzahlung 30 % — ${t}`;
if (art === "rest") return `Restbetrag — ${t}`;
if (art === "aenderung") return `Zusatzleistung — ${t}`;
return t;
}
/* GET /webdesign/admin/zahlungen — Übersicht samt Einrichtungsstand */
export async function zahlungenListe(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const zeilen = db
.prepare(
`SELECT z.*, k.name AS kunde_name, pr.titel AS projekt_titel
FROM wd_zahlungen z
LEFT JOIN wd_kunden k ON k.id = z.kunde_id
LEFT JOIN wd_projekte pr ON pr.id = z.projekt_id
ORDER BY z.erstellt_am DESC LIMIT 300`
)
.all()
.map((z) => ({ ...z, betragText: centZuText(z.betrag_cent, z.waehrung) }));
return json(res, {
ok: true,
zahlungen: zeilen,
/* Damit die Verwaltung sagen kann, WAS noch fehlt, statt nur
"nicht eingerichtet". */
einrichtung: {
einmal: paypal.istEingerichtet("einmal"),
abo: paypal.istEingerichtet("abo"),
fehlt: paypal.fehlendeEinstellungen("abo"),
live: paypal.istLive(),
},
});
}
/* =====================================================================
EINSTELLUNGEN — PayPal-Zugangsdaten über die Verwaltung eintragen
Damit Filipe dafür nicht in die Konsole muss. Die Werte werden
verschlüsselt gespeichert (siehe lib/webdesign-geheimnisse.js) und
wirken sofort — ohne Dienstneustart.
===================================================================== */
/* GET /webdesign/admin/einstellungen
Gibt NIE einen hinterlegten Wert zurück, auch nicht teilweise. Nur:
ist etwas da, woher stammt es, wie lang ist es, wann zuletzt geändert.
Ein Geheimnis, das man wieder auslesen kann, ist über kurz oder lang
auch dort, wo es nicht hingehört. */
export async function einstellungenLesen(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
return json(res, {
ok: true,
stand: geheim.stand(),
einrichtung: {
einmal: paypal.istEingerichtet("einmal"),
abo: paypal.istEingerichtet("abo"),
fehlt: paypal.fehlendeEinstellungen("abo"),
live: paypal.istLive(),
},
webhookUrl: `${(process.env.API_BASIS_URL || "https://postfach.dogfather-universe.com")}/webdesign/paypal-webhook`,
});
}
/* POST /webdesign/admin/einstellungen
Nimmt beliebig viele der erlaubten Schlüssel entgegen. Ein FEHLENDER
Schlüssel bleibt unverändert — nur ein ausdrücklich leerer Wert
löscht. Sonst würde ein Formular, das nur ein Feld ausfüllt, beim
Speichern alle anderen leeren. */
export async function einstellungenSchreiben(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const eingang = req.body?.werte;
if (!eingang || typeof eingang !== "object") {
return json(res, { ok: false, error: "Keine Werte übermittelt." }, 400);
}
const geaendert = [];
const abgelehnt = [];
for (const [schluessel, wert] of Object.entries(eingang)) {
if (!(schluessel in geheim.ERLAUBTE_SCHLUESSEL)) { abgelehnt.push(schluessel); continue; }
/* undefined = "nicht angefasst". Leerer String = "löschen". Das ist
ein echter Unterschied und darf nicht verwischt werden. */
if (wert === undefined || wert === null) continue;
try {
const r = await geheim.einstellungSetzen(schluessel, wert);
geaendert.push(schluessel + (r.entfernt ? " (entfernt)" : ""));
} catch (fehler) {
return json(res, { ok: false, error: fehler.message }, 400);
}
}
/* PayPal merkt sich ein Zugangstoken. Nach einem Wechsel der
Zugangsdaten wäre es das alte — und die nächste Zahlung liefe noch
über das vorherige Konto oder scheiterte mit "invalid client".
Deshalb hier ausdrücklich vergessen. */
paypal.tokenVergessen();
/* Im Protokoll steht, WAS geändert wurde, nie WOMIT. */
verlauf("einstellungen", "paypal", "geaendert",
"team:" + (p.sitzung?.actor || "?"), { felder: geaendert });
return json(res, {
ok: true,
geaendert,
abgelehnt,
stand: geheim.stand(),
einrichtung: {
einmal: paypal.istEingerichtet("einmal"),
abo: paypal.istEingerichtet("abo"),
fehlt: paypal.fehlendeEinstellungen("abo"),
live: paypal.istLive(),
},
});
}
/* POST /webdesign/admin/einstellungen/pruefen
Meldet sich testweise bei PayPal an. Erst das beweist, dass die Werte
stimmen — "gesetzt" heisst nur, dass etwas dasteht, nicht dass es
richtig ist. Es fliesst dabei kein Geld. */
export async function einstellungenPruefen(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
if (!paypal.istEingerichtet("einmal")) {
return json(res, {
ok: false,
error: "Es fehlen noch Werte: " + paypal.fehlendeEinstellungen("einmal").join(", "),
}, 400);
}
try {
paypal.tokenVergessen();
await paypal.zugangstokenPruefen();
return json(res, {
ok: true,
live: paypal.istLive(),
meldung: paypal.istLive()
? "Anmeldung bei PayPal erfolgreich — Echtbetrieb."
: "Anmeldung erfolgreich — aber im TESTMODUS. Es fliesst kein echtes Geld.",
});
} catch (fehler) {
return json(res, {
ok: false,
error: "PayPal hat die Anmeldung abgelehnt. Meist stimmen Client ID und Secret "
+ "nicht zusammen, oder sie stammen aus verschiedenen Apps bzw. aus dem Testbereich.",
technisch: String(fehler.message || fehler).slice(0, 200),
}, 400);
}
}
/* =====================================================================
ÜBERSICHT — das Cockpit
Die Verwaltung öffnete bisher mit einer Liste von Anfragen. Eine Liste
ist eine Ablage, keine Auskunft: Sie zeigt, WAS es gibt, nicht was zu
TUN ist. Wer morgens hereinkommt, muss durch fünf Reiter klicken, um
sich ein Bild zu machen — und übersieht dabei zuverlässig das eine,
das seit vier Tagen liegt.
Diese Antwort liefert alle Zahlen in EINER Abfrage. Fünf einzelne
Abrufe würden die Seite ruckelnd aufbauen und wären beim Wechsel
zwischen den Reitern jedes Mal fällig.
ZWEI GRUNDSÄTZE, DIE DIE ZAHLEN BRAUCHBAR MACHEN
1. GETRENNT NACH "wartet auf mich" und "wartet auf den Kunden".
Eine Gesamtzahl offener Vorgänge sagt nichts: Zwölf offene Punkte
sind entspannt, wenn elf davon beim Kunden liegen — und
alarmierend, wenn alle elf bei mir liegen.
2. DAS ALTER ZÄHLT, NICHT DIE MENGE. "Vier neue Anfragen" ist eine
Mengenangabe. "Die älteste wartet seit sechs Tagen" ist eine
Aussage über Zuverlässigkeit — und genau daran misst ein Kunde,
ob man sein Geld wert ist.
===================================================================== */
export async function uebersicht(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const eine = (sql, ...w) => db.prepare(sql).get(...w) || {};
const viele = (sql, ...w) => db.prepare(sql).all(...w);
/* ---- Was auf MICH wartet ---- */
const anfragenOffen = eine(
`SELECT COUNT(*) AS n, MIN(erstellt_am) AS aeltestes
FROM wd_anfragen WHERE archiviert = 0 AND status IN ('neu','rueckfrage')`
);
const nachrichtenOffen = eine(
`SELECT COUNT(*) AS n, MIN(erstellt_am) AS aeltestes
FROM wd_postfach WHERE gelesen_admin = 0 AND autor = 'kunde'`
);
const wuenscheOffen = eine(
`SELECT COUNT(*) AS n, MIN(erstellt_am) AS aeltestes
FROM wd_aenderungen WHERE status IN ('eingereicht','geprueft')`
);
const widerrufeOffen = eine(
`SELECT COUNT(*) AS n, MIN(eingegangen_am) AS aeltestes
FROM wd_widerrufe WHERE status = 'eingegangen'`
);
const projekteBeiMir = eine(
`SELECT COUNT(*) AS n FROM wd_projekte
WHERE archiviert = 0 AND wartet_auf = 'dogfather'
AND status NOT IN ('uebergeben','abgebrochen')`
);
/* ---- Was auf den KUNDEN wartet ---- */
const projekteBeiKunde = eine(
`SELECT COUNT(*) AS n FROM wd_projekte
WHERE archiviert = 0 AND wartet_auf = 'kunde'
AND status NOT IN ('uebergeben','abgebrochen')`
);
const aufgabenBeiKunde = eine(
`SELECT COUNT(*) AS n FROM wd_aufgaben
WHERE wer_dran = 'kunde' AND status NOT IN ('erledigt','entfaellt')
AND nicht_enthalten = 0`
);
const zahlungenOffen = eine(
`SELECT COUNT(*) AS n, COALESCE(SUM(betrag_cent),0) AS summe, MIN(erstellt_am) AS aeltestes
FROM wd_zahlungen WHERE status IN ('offen','freigegeben')`
);
/* ---- Geld ---- */
const monatsAnfang = new Date();
monatsAnfang.setDate(1);
monatsAnfang.setHours(0, 0, 0, 0);
const bezahltMonat = eine(
`SELECT COUNT(*) AS n, COALESCE(SUM(betrag_cent),0) AS brutto,
COALESCE(SUM(netto_cent),0) AS netto,
COALESCE(SUM(gebuehr_cent),0) AS gebuehr
FROM wd_zahlungen WHERE status = 'bezahlt' AND bezahlt_am >= ?`,
monatsAnfang.toISOString()
);
const bezahltGesamt = eine(
`SELECT COUNT(*) AS n, COALESCE(SUM(betrag_cent),0) AS brutto
FROM wd_zahlungen WHERE status = 'bezahlt'`
);
/* ---- Laufende Projekte mit ihrem Stand ---- */
const projekte = viele(
`SELECT p.id, p.nummer, p.titel, p.status, p.wartet_auf, p.richttermin,
k.name AS kunde_name,
(SELECT COUNT(*) FROM wd_aufgaben a
WHERE a.projekt_id = p.id AND a.status <> 'entfaellt') AS aufg_gesamt,
(SELECT COUNT(*) FROM wd_aufgaben a
WHERE a.projekt_id = p.id AND a.status = 'erledigt') AS aufg_fertig
FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id
WHERE p.archiviert = 0 AND p.status NOT IN ('uebergeben','abgebrochen')
ORDER BY p.erstellt_am DESC LIMIT 8`
);
/* ---- Was zuletzt geschah ---- */
const verlaufsListe = viele(
`SELECT bereich, aktion, wer, erstellt_am
FROM wd_verlauf ORDER BY erstellt_am DESC LIMIT 12`
);
/* ---- Bestand ---- */
const bestand = {
kunden: eine(`SELECT COUNT(*) AS n FROM wd_kunden WHERE archiviert = 0`).n,
projekte: eine(`SELECT COUNT(*) AS n FROM wd_projekte WHERE archiviert = 0`).n,
anfragenGesamt: eine(`SELECT COUNT(*) AS n FROM wd_anfragen WHERE archiviert = 0`).n,
};
return json(res, {
ok: true,
beiMir: {
anfragen: anfragenOffen,
nachrichten: nachrichtenOffen,
wuensche: wuenscheOffen,
widerrufe: widerrufeOffen,
projekte: projekteBeiMir.n,
},
beiKunde: {
projekte: projekteBeiKunde.n,
aufgaben: aufgabenBeiKunde.n,
zahlungen: zahlungenOffen,
zahlungenText: centZuText(zahlungenOffen.summe || 0),
},
geld: {
monat: { ...bezahltMonat, bruttoText: centZuText(bezahltMonat.brutto || 0),
nettoText: centZuText(bezahltMonat.netto || 0) },
gesamt: { ...bezahltGesamt, bruttoText: centZuText(bezahltGesamt.brutto || 0) },
/* Ehrlich beziffern, was PayPal einbehalten hat. Der Unterschied
zwischen Umsatz und dem, was ankommt, gehoert sichtbar gemacht --
sonst rechnet man am Jahresende mit der falschen Zahl. */
gebuehrenText: centZuText(bezahltMonat.gebuehr || 0),
},
projekte,
verlauf: verlaufsListe,
bestand,
paypal: {
eingerichtet: paypal.istEingerichtet("einmal"),
live: paypal.istLive(),
},
/* Benachrichtigungen. Sie stehen ganz oben im Cockpit, weil sie das
Einzige sind, was seit dem letzten Hinsehen NEU ist -- alles
andere sind Bestaende, die man auch morgen noch sieht. */
meldungen: meldungenLesen(true, 8),
meldungenOffen: meldungenOffen(),
/* Projekte, die seit einer Woche auf ihre Anzahlung warten. Ohne
diese Liste vergisst man sie: Sie stehen in keiner Zahl, weil
ihre Uhr nie zu laufen begann. */
wartetAufAnzahlung: wartetAufAnzahlung(7),
});
}
/* Eine Benachrichtigung wegklicken.
Nur als gelesen markieren, nicht löschen: Der Verlauf soll
nachvollziehbar bleiben, und ein versehentliches Wegklicken darf
nichts endgültig verschwinden lassen. */
export async function meldungGelesen(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const id = String(req.params.id || "");
if (id === "alle") {
const n = db.prepare(
`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE gelesen = 0`
).run(jetzt()).changes;
return json(res, { ok: true, anzahl: n });
}
db.prepare(`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE id = ?`)
.run(jetzt(), id);
return json(res, { ok: true, anzahl: 1 });
}