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]>
775 lines
32 KiB
JavaScript
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 });
|
|
}
|