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]>
727 lines
29 KiB
JavaScript
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(),
|
|
},
|
|
});
|
|
}
|