Files
dogfather-universe/server-internal/routes/webdesign-zahlungen.js
T
DogFatherGitandClaude Opus 5 c1cb51884f Verwaltung: Übersicht als Startansicht, Reiter unter den Titel
Die Reiter standen neben dem Titel. Das las sich wie eine einzige lange
Zeile, in der der Titel nur der erste von sechs Knöpfen zu sein schien --
man sah nicht auf einen Blick, in welchem Bereich man war. Jetzt zwei
Zeilen: oben WO man ist, darunter WOHIN man kann.

Die Verwaltung öffnete bisher mit der Anfragenliste. Eine Liste ist eine
Ablage: Sie zeigt, WAS es gibt, nicht was zu TUN ist. Neu ist ein
Cockpit, das in drei Stufen antwortet -- was auf mich wartet, was beim
Kunden liegt, wie es ums Geld steht -- und dann laufende Projekte mit
Fortschritt sowie den Verlauf.

Zwei Grundsätze machen die Zahlen brauchbar: Getrennt nach 'wartet auf
mich' und 'wartet auf den Kunden' (zwölf offene Punkte sind entspannt,
wenn elf beim Kunden liegen). Und das ALTER färbt, nicht die Menge --
vier neue Anfragen sind kein Problem, eine seit sechs Tagen liegende
schon. Ein offener Widerruf ist immer rot, weil eine gesetzliche Frist
läuft.

Jede Kachel ist ein echter <button> und führt in den passenden Reiter.
Farbe ist nie der einzige Träger: Neben jedem farbigen Zustand steht der
Text ('älteste seit 8 Tagen').

Drei Fehler dabei gefunden und behoben:
- Der Titel wurde nur beim Klicken gesetzt. Frisch geladen zeigte die
  Seite das Cockpit, während darüber noch 'Projektanfragen' stand. Die
  Zuordnung Reiter->Titel liegt jetzt ausserhalb des Klick-Zuhörers.
- '.wd h2' überstimmte '.vw-ub-h': 44px Überschrift über 33px Zahl, die
  Seite las sich wie ein Plakat. Dieselbe Spezifitätsfalle wie früher
  bei '.wd a'.
- Eine Antwort mit ok:true aber ohne Inhalt riss die ganze Verwaltung
  mit. Wird jetzt abgefangen -- ein halber Server ist ein realistischer
  Fall.

Geprüft: 22 Serverprüfungen gegen eine echte Datenbank (inkl. vier
Fällen, die belegen, dass die Rechteprüfung wirklich greift), 56 im
Browser auf Computer und Handy, 69 im bestehenden Verwaltungstest,
0 Fundstellen im Design-/WCAG-Test, 5 im Farbsystemtest.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 19:21:12 +02:00

727 lines
29 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 {
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
);
/* Anzahlung/Rest am Projekt mitführen, damit die Verwaltung den Stand
sieht, ohne die Zahlungen einzeln durchzugehen. */
const z = db.prepare(`SELECT art, projekt_id FROM wd_zahlungen WHERE id = ?`).get(zahlungId);
if (z?.projekt_id && (z.art === "anzahlung" || z.art === "rest")) {
const feld = z.art === "anzahlung" ? "anzahlung_bezahlt" : "rest_bezahlt";
db.prepare(`UPDATE wd_projekte SET ${feld} = 1, aktualisiert_am = ? WHERE id = ?`)
.run(nun, z.projekt_id);
}
verlauf("zahlung", zahlungId, "bezahlt", wer, { captureId: einzug.captureId || null });
}
/* ---------------------------------------------------------------------
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(),
},
});
}