diff --git a/cloudflare-worker/migrations/0017_webdesign_angebote.sql b/cloudflare-worker/migrations/0017_webdesign_angebote.sql new file mode 100644 index 00000000..097c534e --- /dev/null +++ b/cloudflare-worker/migrations/0017_webdesign_angebote.sql @@ -0,0 +1,68 @@ +-- ===================================================================== +-- Angebote mit Zusage im Portal +-- +-- Ein Angebot, das der Kunde online verbindlich annimmt, ist ein +-- Vertragsschluss über eine elektronische Benutzeroberfläche. Daraus +-- folgen zwei Dinge, die diese Tabelle tragen muss: +-- +-- 1. DER WORTLAUT WIRD FESTGEHALTEN, NICHT NUR DER PREIS. +-- Bei einem Streit zählt, WAS dem Kunden gezeigt wurde, als er +-- zusagte. Würde man nur preis_cent und paket speichern und den Text +-- bei jeder Ansicht neu erzeugen, stünde nach der nächsten Änderung +-- an den Vorlagen etwas anderes da als damals — und niemand könnte +-- mehr belegen, worauf man sich geeinigt hat. Deshalb wird der +-- vollständige Leistungstext beim ABSENDEN eingefroren. +-- +-- 2. DIE ZUSAGE WIRD MIT ZEITPUNKT UND HERKUNFT BELEGT. +-- Wer zugesagt hat, wann, und von welcher Verbindung aus. Die +-- Beweislast liegt beim Unternehmer. +-- +-- Ein Angebot hängt an einer ANFRAGE, nicht an einem Projekt: Es geht +-- der Annahme voraus. Erst die Zusage des Kunden macht daraus ein +-- Projekt. +-- ===================================================================== +CREATE TABLE IF NOT EXISTS wd_angebote ( + id TEXT PRIMARY KEY, + nummer TEXT NOT NULL UNIQUE, + anfrage_id TEXT, + kunde_id TEXT, + projekt_id TEXT, -- entsteht erst mit der Zusage + + paket TEXT NOT NULL, + titel TEXT NOT NULL, + preis_cent INTEGER NOT NULL, + anzahlung_cent INTEGER NOT NULL, + waehrung TEXT NOT NULL DEFAULT 'EUR', + laufzeit_werktage INTEGER, + + -- Der eingefrorene Wortlaut. Siehe Kopfkommentar: Er wird NICHT bei + -- jeder Ansicht neu erzeugt. + leistungen_text TEXT NOT NULL, + hinweis_text TEXT, + sprache TEXT NOT NULL DEFAULT 'de', + + -- entwurf | gesendet | angenommen | abgelehnt | abgelaufen | zurueckgezogen + status TEXT NOT NULL DEFAULT 'entwurf', + -- Ein Angebot ohne Ablauf bindet unbefristet. Vier Wochen sind lang + -- genug zum Überlegen und kurz genug, dass ein Preis von damals nicht + -- ein Jahr später noch gilt. + gueltig_bis TEXT, + + gesendet_am TEXT, + entschieden_am TEXT, + zusage_ip_hash TEXT, + ablehnung_grund TEXT, + + erstellt_am TEXT NOT NULL, + aktualisiert_am TEXT, + + -- Bewusst ON DELETE SET NULL statt CASCADE: Wird eine Anfrage + -- gelöscht, darf der Beleg über ein angenommenes Angebot nicht + -- mitverschwinden. + FOREIGN KEY (anfrage_id) REFERENCES wd_anfragen(id) ON DELETE SET NULL, + FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_wd_angebote_anfrage ON wd_angebote (anfrage_id); +CREATE INDEX IF NOT EXISTS idx_wd_angebote_kunde ON wd_angebote (kunde_id, status); +CREATE INDEX IF NOT EXISTS idx_wd_angebote_status ON wd_angebote (status, gueltig_bis); diff --git a/server-internal/db.js b/server-internal/db.js index f859988c..129981ff 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -60,6 +60,8 @@ const MIGRATION_FILES = [ // Die Uhr laeuft erst bei Anzahlung; Abbruch mit Erstattung; // Benachrichtigungen fuer die Verwaltung. "0016_webdesign_automatik.sql", + // Angebote mit Zusage im Portal. + "0017_webdesign_angebote.sql", ]; function alreadyApplied(name) { diff --git a/server-internal/index.js b/server-internal/index.js index e1801786..0ed6969e 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -92,7 +92,7 @@ import { jede Abfrage filtert auf die kunde_id aus der Sitzung. */ import { portalAnmelden, portalAbmelden, portalEinladungPruefen, portalPasswortSetzen, - portalUebersicht, portalProjekt, portalAenderungEinreichen, + portalUebersicht, portalAngebotZusagen, portalAngebotAblehnen, portalProjekt, portalAenderungEinreichen, portalAenderungEntscheiden, portalNachricht, portalProfilSpeichern, } from "./routes/webdesign-portal.js"; /* Kunden und Projekte verwalten (Masterplan S.13). Hier entstehen @@ -131,6 +131,8 @@ import { abbruchVorschau, projektAbbrechen, } from "./routes/webdesign-annahme.js"; import { suche } from "./routes/webdesign-suche.js"; +import { angebotVorschau, angebotSenden, angebotZurueckziehen } + from "./routes/webdesign-angebote.js"; /* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen, damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH. Sie muessen beim Start EINMAL entschluesselt werden, weil die @@ -314,6 +316,11 @@ app.post("/webdesign/admin/anfragen/:id/annehmen", wrap(anfrageAnnehmen)); app.get("/webdesign/admin/anfragen/:id/absage-vorschau", wrap(absageVorschau)); app.post("/webdesign/admin/anfragen/:id/ablehnen", wrap(anfrageAblehnen)); +/* Angebot: Vorschau (liest nur), Absenden, Zurueckziehen. */ +app.get("/webdesign/admin/anfragen/:id/angebot-vorschau", wrap(angebotVorschau)); +app.post("/webdesign/admin/anfragen/:id/angebot", wrap(angebotSenden)); +app.post("/webdesign/admin/angebote/:id/zurueckziehen", wrap(angebotZurueckziehen)); + /* ---------- Kundenportal ---------- Oeffentlich sind nur Anmeldung und das Einloesen eines Einladungslinks. Alles Weitere prueft die Kundensitzung im Handler selbst. */ @@ -323,6 +330,8 @@ app.get("/webdesign/portal/einladung", wrap(portalEinladungPruefen)); app.post("/webdesign/portal/passwort", wrap(portalPasswortSetzen)); app.get("/webdesign/portal/uebersicht", wrap(portalUebersicht)); app.get("/webdesign/portal/projekt/:id", wrap(portalProjekt)); +app.post("/webdesign/portal/angebote/:id/zusagen", wrap(portalAngebotZusagen)); +app.post("/webdesign/portal/angebote/:id/ablehnen", wrap(portalAngebotAblehnen)); app.post("/webdesign/portal/aenderung", wrap(portalAenderungEinreichen)); app.post("/webdesign/portal/aenderung/:id/entscheiden", wrap(portalAenderungEntscheiden)); app.post("/webdesign/portal/nachricht", wrap(portalNachricht)); diff --git a/server-internal/lib/webdesign-helfer.js b/server-internal/lib/webdesign-helfer.js index fb9dd71b..76af6af4 100644 --- a/server-internal/lib/webdesign-helfer.js +++ b/server-internal/lib/webdesign-helfer.js @@ -189,7 +189,32 @@ export function spracheOderDe(wert) { /* Betrag in Cent -> lesbarer Text. Nie mit Gleitkomma rechnen. */ export function centZuText(cent, waehrung = "EUR") { if (cent == null) return null; - const ganz = Math.trunc(cent / 100); - const rest = String(Math.abs(cent % 100)).padStart(2, "0"); - return `${ganz},${rest} ${waehrung === "EUR" ? "€" : waehrung}`; + + /* ZWEI FEHLER, DIE HIER LANGE STECKTEN (gefunden 24.08.2026) + + 1. KEIN TAUSENDERPUNKT. Ein Angebot ueber 1490 Euro erschien als + "1490,00 €". Bei einem Betrag, den jemand bezahlen soll, ist das + nicht nur unschoen -- man verliest sich. Aufgefallen erst, als + das erste Angebot ueber tausend Euro entstand; alle frueheren + Testbetraege lagen darunter. + + 2. DAS MINUSZEICHEN GING VERLOREN. Math.trunc(-0.5) ergibt -0, und + -0 schreibt sich als "0". Eine Erstattung von 50 Cent erschien + damit als "0,50 €" -- also wie eine Forderung. Deshalb wird das + Vorzeichen jetzt getrennt vom Betrag behandelt, statt sich auf + die Ganzzahl zu verlassen. + + Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe soll auf + dem Server und im Browser Zeichen fuer Zeichen gleich sein. Intl + haengt an der eingestellten Sprache -- und ein Betrag, der in der + Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl. */ + const negativ = cent < 0; + const absolut = Math.abs(cent); + const ganz = Math.floor(absolut / 100); + const rest = String(absolut % 100).padStart(2, "0"); + + /* Dreiergruppen von rechts. */ + const gruppiert = String(ganz).replace(/\B(?=(\d{3})+(?!\d))/g, "."); + + return `${negativ ? "-" : ""}${gruppiert},${rest} ${waehrung === "EUR" ? "€" : waehrung}`; } diff --git a/server-internal/routes/webdesign-angebote.js b/server-internal/routes/webdesign-angebote.js new file mode 100644 index 00000000..7f56e9b5 --- /dev/null +++ b/server-internal/routes/webdesign-angebote.js @@ -0,0 +1,455 @@ +/* ===================================================================== + webdesign-angebote.js — ein Angebot schicken, das der Kunde anklickt + + DER LEITGEDANKE: SO WENIG TIPPEN WIE MÖGLICH + + Wunsch vom 24.08.2026: "das zum angebot rausschicken für mich soll + auch perfektionniert werden so dass es für mich am einfachsten ist + und ich so wenig wie möglich schreiben muss". + + Deshalb schreibt hier NIEMAND den Leistungsumfang. Er entsteht aus + der Aufgabenvorlage des Pakets — denselben 92 Punkten, nach denen + später auch gearbeitet wird. Das hat einen Nebeneffekt, der wichtiger + ist als die gesparte Tipparbeit: Was im Angebot steht, IST die + Arbeitsliste. Ein von Hand geschriebenes Angebot und eine getrennt + gepflegte Aufgabenliste laufen unweigerlich auseinander — und dann + steht im Angebot etwas, das niemand abarbeitet. + + Zu tippen bleibt: das Paket wählen und den Preis bestätigen. Beides + ist vorbelegt. + + DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN + + Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zählt, WAS + dem Kunden gezeigt wurde, als er zusagte. Würde der Text bei jeder + Ansicht neu aus den Vorlagen erzeugt, stünde nach der nächsten + Änderung an den Vorlagen etwas anderes da als damals. + ===================================================================== */ +import { db } from "../db.js"; +import { json } from "../lib/http.js"; +import { darfWebdesign } from "../lib/webdesign-ausweis.js"; +import { requireSession } from "../lib/auth.js"; +import { hasPermission } from "../lib/permissions.js"; +import { + jetzt, neueId, naechsteNummer, text, verlauf, centZuText, PAKETE, +} from "../lib/webdesign-helfer.js"; +import { laufzeitFuer, werktageAddieren, terminVorschlag } from "../lib/webdesign-termine.js"; +import { vorlageAnwenden } from "./webdesign-aufgaben.js"; +import { meldungAnlegen } from "../lib/webdesign-automatik.js"; + +async function darf(req, recht) { + return darfWebdesign(req, recht, async () => { + 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 }; + }); +} +function werIst(p) { + if (p.actor) return p.actor; + if (p.sitzung && p.sitzung.actor) return "team:" + p.sitzung.actor; + return "unbekannt"; +} + +/* Preisvorschlag je Paket, in Euro. + + Wie die Laufzeiten im Code und nicht in der Datenbank: Es ist eine + Geschäftsentscheidung, kein Einstellwert. Er ist ein VORSCHLAG und + steht vor dem Absenden zum Ändern da -- kein Angebot geht ungesehen + hinaus. */ +export const PREIS_VORSCHLAG = { + onepager: 490, + website: 1490, + shop: 2990, + verwaltung: 990, + betreuung: 39, // je Monat + unklar: 1490, +}; + +const PAKETNAME = { + onepager: "Onepager", website: "Website", shop: "Onlineshop", + verwaltung: "Verwaltungsbereich", betreuung: "Laufende Betreuung", + unklar: "Projekt", +}; + +/* Der Leistungstext aus der Aufgabenvorlage. + + Gruppiert nach Kategorie, damit daraus kein Zettel mit 25 Spiegel- + strichen wird. Nur Punkte, die auch WIRKLICH von mir kommen: Was + der Kunde beisteuert (Texte, Bilder, Zugänge), gehört nicht in eine + Leistungsbeschreibung -- es steht getrennt darunter als das, was + gebraucht wird. */ +function leistungenBauen(paket) { + const punkte = db.prepare( + `SELECT titel, kategorie, wer_dran FROM wd_aufgaben_vorlagen + WHERE paket = ? AND aktiv = 1 ORDER BY reihenfolge` + ).all(paket); + + const meine = punkte.filter((p) => p.wer_dran !== "kunde"); + const seine = punkte.filter((p) => p.wer_dran === "kunde"); + + const gruppen = new Map(); + for (const p of meine) { + const k = p.kategorie || "Sonstiges"; + if (!gruppen.has(k)) gruppen.set(k, []); + gruppen.get(k).push(p.titel); + } + + let out = ""; + for (const [gruppe, liste] of gruppen) { + out += "**" + gruppe + "**\n"; + for (const t of liste) out += "· " + t + "\n"; + out += "\n"; + } + if (seine.length) { + out += "**Was ich von dir brauche**\n"; + for (const t of seine) out += "· " + t + "\n"; + } + return out.trim(); +} + +/* --------------------------------------------------------------------- + VORSCHAU — alles vorbelegt, nichts zu tippen + --------------------------------------------------------------------- */ +export async function angebotVorschau(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`) + .get(String(req.params.id || "")); + if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404); + + /* Das Paket aus der Anfrage, aber überschreibbar: Beim Lesen stellt + sich oft heraus, dass etwas anderes passt als angekreuzt wurde. */ + const paket = PAKETE.includes(text(req.query?.paket, 30)) + ? text(req.query?.paket, 30) + : (PAKETE.includes(anfrage.paket) ? anfrage.paket : "unklar"); + + const preisEuro = PREIS_VORSCHLAG[paket] ?? PREIS_VORSCHLAG.unklar; + const werktage = laufzeitFuer(paket); + + const offen = db.prepare( + `SELECT id, nummer, status FROM wd_angebote + WHERE anfrage_id = ? AND status IN ('gesendet','angenommen')` + ).get(anfrage.id); + + return json(res, { + ok: true, + paket, + titel: PAKETNAME[paket] + (anfrage.name ? " für " + anfrage.name : ""), + preisEuro, + anzahlungEuro: Math.round(preisEuro * 0.3 * 100) / 100, + werktage, + /* Vier Wochen: lang genug zum Überlegen, kurz genug, dass ein Preis + von heute nicht in einem Jahr noch gilt. */ + gueltigBis: werktageAddieren(new Date(), 20), + leistungen: leistungenBauen(paket), + sprache: anfrage.sprache || "de", + kunde: { name: anfrage.name, email: anfrage.email }, + schonUnterwegs: offen || null, + }); +} + +/* --------------------------------------------------------------------- + ABSENDEN + --------------------------------------------------------------------- */ +export async function angebotSenden(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`) + .get(String(req.params.id || "")); + if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404); + if (!anfrage.email) { + return json(res, { ok: false, + error: "Diese Anfrage hat keine E-Mail-Adresse — ohne sie gibt es kein Portal." }, 400); + } + + /* Ein zweites offenes Angebot zur selben Anfrage wäre für den Kunden + nicht auflösbar: Er sähe zwei Preise und wüsste nicht, welcher + gilt. Wer neu anbieten will, zieht das alte zurück. */ + const offen = db.prepare( + `SELECT nummer FROM wd_angebote WHERE anfrage_id = ? AND status IN ('gesendet','angenommen')` + ).get(anfrage.id); + if (offen) { + return json(res, { ok: false, + error: `Zu dieser Anfrage ist bereits Angebot ${offen.nummer} unterwegs. Zieh es zurück, bevor du ein neues schickst.` }, 409); + } + + const paket = PAKETE.includes(text(req.body?.paket, 30)) ? text(req.body?.paket, 30) : "unklar"; + const preisEuro = Number(req.body?.preisEuro); + if (!Number.isFinite(preisEuro) || preisEuro <= 0) { + return json(res, { ok: false, error: "Bitte einen Preis angeben." }, 400); + } + const preisCent = Math.round(preisEuro * 100); + const anzahlungCent = Math.round(preisCent * 0.3); + + const leistungen = text(req.body?.leistungen, 20000) || leistungenBauen(paket); + const gueltigBis = /^\d{4}-\d{2}-\d{2}$/.test(text(req.body?.gueltigBis, 10) || "") + ? text(req.body?.gueltigBis, 10) + : werktageAddieren(new Date(), 20); + + /* Der Kunde braucht einen Zugang, um zusagen zu können. Er wird hier + angelegt, aber NICHT freigeschaltet -- freigeschaltet wird er mit + der Zusage. Vorher gibt es nichts zu sehen ausser dem Angebot. */ + let kunde = db.prepare( + `SELECT id FROM wd_kunden WHERE lower(email) = lower(?) AND archiviert = 0` + ).get(anfrage.email); + + const id = neueId(); + const nummer = naechsteNummer("wd_angebote", "AG"); + let einladung = null; + + db.transaction(() => { + if (!kunde) { + const kid = neueId(); + db.prepare( + `INSERT INTO wd_kunden (id, email, name, telefon, sprache, + email_bestaetigt, freigeschaltet, gesperrt, erstellt_am) + VALUES (?,?,?,?,?,0,1,0,?)` + ).run(kid, anfrage.email, anfrage.name || anfrage.email, + anfrage.telefon || null, anfrage.sprache || "de", jetzt()); + kunde = { id: kid }; + einladung = "neu"; + } + + db.prepare( + `INSERT INTO wd_angebote + (id, nummer, anfrage_id, kunde_id, paket, titel, preis_cent, anzahlung_cent, + waehrung, laufzeit_werktage, leistungen_text, hinweis_text, sprache, + status, gueltig_bis, gesendet_am, erstellt_am) + VALUES (?,?,?,?,?,?,?,?,'EUR',?,?,?,?,'gesendet',?,?,?)` + ).run( + id, nummer, anfrage.id, kunde.id, paket, + text(req.body?.titel, 160) || (PAKETNAME[paket] + " für " + (anfrage.name || "")), + preisCent, anzahlungCent, laufzeitFuer(paket), + leistungen, text(req.body?.hinweis, 4000) || null, + anfrage.sprache || "de", gueltigBis, jetzt(), jetzt() + ); + + /* Der Status der Anfrage folgt der Handlung, ohne dass jemand daran + denken muss -- genau das war der Wunsch: "wenn ich ein angebot + rausschicke soll der automatisch das erkennen". */ + db.prepare( + `UPDATE wd_anfragen SET status = 'angebot', kunde_id = ?, aktualisiert_am = ? WHERE id = ?` + ).run(kunde.id, jetzt(), anfrage.id); + + /* Das Angebot landet zusätzlich im Postfach. Das Portal ist der Ort + der Entscheidung, aber niemand schaut dort ohne Anlass hinein. */ + db.prepare( + `INSERT INTO wd_postfach (id, kunde_id, autor, text, gelesen_kunde, gelesen_admin, intern, erstellt_am) + VALUES (?,?, 'dogfather', ?, 0, 1, 0, ?)` + ).run(neueId(), kunde.id, + `Dein Angebot ${nummer} liegt bereit: ${PAKETNAME[paket]} für ${centZuText(preisCent)}. ` + + `Du findest es in deinem Portal und kannst dort mit einem Klick zusagen. ` + + `Gültig bis ${gueltigBis}.`, + jetzt()); + })(); + + verlauf("anfrage", anfrage.id, "angebot_gesendet", werIst(p), + `${nummer} · ${paket} · ${centZuText(preisCent)}`); + + return json(res, { + ok: true, id, nummer, + preisText: centZuText(preisCent), + anzahlungText: centZuText(anzahlungCent), + gueltigBis, + kundeNeu: einladung === "neu", + hinweis: "Das Angebot liegt im Portal des Kunden und als Nachricht in seinem Postfach.", + }); +} + +/* Zurückziehen — damit man ein neues schicken kann. */ +export async function angebotZurueckziehen(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const a = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(String(req.params.id || "")); + if (!a) return json(res, { ok: false, error: "Angebot nicht gefunden." }, 404); + /* Ein angenommenes Angebot ist ein Vertrag. Man zieht es nicht + zurück -- man bricht das Projekt ab. */ + if (a.status === "angenommen") { + return json(res, { ok: false, + error: "Dieses Angebot wurde bereits angenommen. Brich stattdessen das Projekt ab." }, 409); + } + db.prepare(`UPDATE wd_angebote SET status = 'zurueckgezogen', aktualisiert_am = ? WHERE id = ?`) + .run(jetzt(), a.id); + verlauf("angebot", a.id, "zurueckgezogen", werIst(p), a.nummer); + return json(res, { ok: true }); +} + +/* ===================================================================== + PORTAL — was der Kunde sieht und anklickt + ===================================================================== */ + +/* Gilt das Angebot noch? + + Getrennte Funktion, weil die Prüfung an zwei Stellen gebraucht wird + (Anzeigen und Zusagen) und in beiden Fällen dasselbe bedeuten muss. + Ein Angebot, das man noch sieht, aber nicht mehr annehmen kann, wäre + eine Falle. */ +function nochGueltig(a) { + if (a.status !== "gesendet") return false; + if (!a.gueltig_bis) return true; + return a.gueltig_bis >= new Date().toISOString().slice(0, 10); +} + +export function portalAngebote(kundeId) { + const liste = db.prepare( + `SELECT id, nummer, paket, titel, preis_cent, anzahlung_cent, waehrung, + laufzeit_werktage, leistungen_text, hinweis_text, status, + gueltig_bis, gesendet_am, entschieden_am + FROM wd_angebote + WHERE kunde_id = ? AND status IN ('gesendet','angenommen') + ORDER BY gesendet_am DESC` + ).all(kundeId); + + for (const a of liste) { + a.preis_text = centZuText(a.preis_cent, a.waehrung); + a.anzahlung_text = centZuText(a.anzahlung_cent, a.waehrung); + a.annehmbar = nochGueltig(a); + a.abgelaufen = a.status === "gesendet" && !a.annehmbar; + } + return liste; +} + +/* --------------------------------------------------------------------- + ZUSAGE DURCH DEN KUNDEN + + Der folgenreichste Klick auf der ganzen Seite: Hier entsteht ein + Vertrag. Entsprechend gilt alles oder nichts, und entsprechend wird + belegt, WAS zugesagt wurde. + + Was in einem Zug passiert: + 1. Angebot als angenommen festschreiben, mit Zeitpunkt und Herkunft + 2. Projekt anlegen, Aufgabenliste aus der Vorlage + 3. Anzahlung als offene Rechnung erzeugen + 4. Anfrage auf "angenommen" + 5. Benachrichtigung an mich + + Die UHR startet hier NICHT. Sie startet erst mit dem Zahlungseingang + -- das ist die Regel aus dem vorigen Schritt, und sie gilt auch dann, + wenn der Kunde selbst zugesagt hat. + --------------------------------------------------------------------- */ +export async function portalAngebotZusagen(req, res, kunde, ipHash) { + const a = db.prepare(`SELECT * FROM wd_angebote WHERE id = ? AND kunde_id = ?`) + .get(String(req.params.id || ""), kunde.id); + if (!a) return json(res, { ok: false, error: "Angebot nicht gefunden." }, 404); + + if (a.status === "angenommen") { + /* Ein zweiter Klick -- versehentlich oder weil die Antwort lange + brauchte -- darf kein zweites Projekt anlegen. */ + return json(res, { ok: true, bereitsAngenommen: true, projektId: a.projekt_id, + hinweis: "Dieses Angebot hast du bereits angenommen." }); + } + if (!nochGueltig(a)) { + return json(res, { ok: false, + error: "Dieses Angebot ist nicht mehr gültig. Schreib mir kurz, dann mache ich dir ein neues." }, 409); + } + + let projektId = null; + let projektNummer = null; + let zahlungId = null; + + try { + db.transaction(() => { + projektId = neueId(); + projektNummer = naechsteNummer("wd_projekte", "P"); + const v = terminVorschlag(a.paket, new Date()); + + db.prepare( + `INSERT INTO wd_projekte + (id, nummer, kunde_id, anfrage_id, titel, paket, beschreibung, + status, naechster_schritt, wartet_auf, preis_cent, anzahlung_cent, + waehrung, termin_am, termin_geplant_am, angenommen_am, termin_werktage, erstellt_am) + VALUES (?,?,?,?,?,?,?, 'briefing', ?, 'kunde', ?,?,?,?,?,?,?,?)` + ).run( + projektId, projektNummer, kunde.id, a.anfrage_id, a.titel, a.paket, + a.leistungen_text, + /* Der erste Zug liegt beim Kunden: Ohne Anzahlung geht es nicht + los. Das steht so auch im Portal, damit niemand auf eine + Arbeit wartet, die noch gar nicht beginnen kann. */ + "Anzahlung überweisen — danach geht es los", + a.preis_cent, a.anzahlung_cent, a.waehrung, + v.termin, v.termin, jetzt(), a.laufzeit_werktage, jetzt() + ); + + vorlageAnwenden(projektId, kunde.id, a.paket); + + /* Die Anzahlung als offene Rechnung. Sie entsteht HIER und nicht + erst, wenn jemand daran denkt -- sonst hinge der Start des + Projekts an einem Handgriff, den man vergessen kann. */ + zahlungId = neueId(); + db.prepare( + `INSERT INTO wd_zahlungen + (id, nummer, kunde_id, projekt_id, art, zweck_text, betrag_cent, waehrung, + status, kundenart, erstellt_am) + VALUES (?,?,?,?, 'anzahlung', ?, ?, ?, 'offen', 'verbraucher', ?)` + ).run(zahlungId, naechsteNummer("wd_zahlungen", "Z"), kunde.id, projektId, + "Anzahlung (30 %) — " + a.titel, a.anzahlung_cent, a.waehrung, jetzt()); + + db.prepare( + `UPDATE wd_angebote + SET status = 'angenommen', entschieden_am = ?, zusage_ip_hash = ?, + projekt_id = ?, aktualisiert_am = ? + WHERE id = ?` + ).run(jetzt(), ipHash || null, projektId, jetzt(), a.id); + + if (a.anfrage_id) { + db.prepare(`UPDATE wd_anfragen SET status = 'angenommen', aktualisiert_am = ? WHERE id = ?`) + .run(jetzt(), a.anfrage_id); + } + db.prepare(`UPDATE wd_kunden SET freigeschaltet = 1, aktualisiert_am = ? WHERE id = ? AND gesperrt = 0`) + .run(jetzt(), kunde.id); + })(); + } catch (fehler) { + return json(res, { ok: false, + error: "Beim Annehmen ist etwas schiefgegangen — es wurde nichts angelegt. Bitte versuch es noch einmal.", + einzelheit: String((fehler && fehler.message) || fehler).slice(0, 300) }, 500); + } + + verlauf("angebot", a.id, "angenommen", "kunde:" + kunde.id, + `${a.nummer} → ${projektNummer}`); + + meldungAnlegen({ + art: "zusage", + titel: "Zusage! " + a.nummer + " angenommen", + text: (kunde.name || "Der Kunde") + " hat zugesagt: " + centZuText(a.preis_cent, a.waehrung) + + ". " + projektNummer + " ist angelegt, die Anzahlung von " + + centZuText(a.anzahlung_cent, a.waehrung) + " steht offen. Die Uhr läuft, sobald sie da ist.", + ziel: "projekte", zielId: projektId, + dringend: 1, + }); + + return json(res, { + ok: true, + projektId, projektNummer, zahlungId, + anzahlungText: centZuText(a.anzahlung_cent, a.waehrung), + hinweis: "Danke! Dein Projekt ist angelegt. Sobald die Anzahlung da ist, geht es los.", + }); +} + +export async function portalAngebotAblehnen(req, res, kunde) { + const a = db.prepare(`SELECT * FROM wd_angebote WHERE id = ? AND kunde_id = ?`) + .get(String(req.params.id || ""), kunde.id); + if (!a) return json(res, { ok: false, error: "Angebot nicht gefunden." }, 404); + if (a.status === "angenommen") { + return json(res, { ok: false, error: "Dieses Angebot hast du bereits angenommen." }, 409); + } + + db.prepare( + `UPDATE wd_angebote SET status = 'abgelehnt', entschieden_am = ?, + ablehnung_grund = ?, aktualisiert_am = ? WHERE id = ?` + ).run(jetzt(), text(req.body?.grund, 500) || null, jetzt(), a.id); + + verlauf("angebot", a.id, "abgelehnt", "kunde:" + kunde.id, a.nummer); + meldungAnlegen({ + art: "absage", + titel: a.nummer + " wurde abgelehnt", + text: (kunde.name || "Der Kunde") + " hat das Angebot nicht angenommen." + + (req.body?.grund ? " Grund: " + String(req.body.grund).slice(0, 200) : ""), + ziel: "anfragen", zielId: a.anfrage_id, + }); + return json(res, { ok: true }); +} diff --git a/server-internal/routes/webdesign-portal.js b/server-internal/routes/webdesign-portal.js index 5d316376..89a21e50 100644 --- a/server-internal/routes/webdesign-portal.js +++ b/server-internal/routes/webdesign-portal.js @@ -33,8 +33,11 @@ import { jetzt, neueId, text, istEmail, emailNormal, tokenHash, besucherIp, zuVieleVersuche, versuchZaehlen, versucheZuruecksetzen, verlauf, spracheOderDe, centZuText, + ipHash, } from "../lib/webdesign-helfer.js"; import { werktageBis } from "../lib/webdesign-termine.js"; +import { portalAngebote, portalAngebotZusagen as angebotZusagenKern, + portalAngebotAblehnen as angebotAblehnenKern } from "./webdesign-angebote.js"; const scrypt = promisify(scryptRoh); @@ -303,6 +306,13 @@ export function portalUebersicht(req, res) { : null; } + /* Offene Angebote GANZ nach vorn. + + Ein Angebot ist das Einzige im Portal, das eine Entscheidung + verlangt -- alles andere ist Auskunft. Stünde es unter den + laufenden Projekten, sähe man es erst nach dem Scrollen. */ + const angebote = portalAngebote(kunde.id); + const abos = db .prepare(`SELECT id, paket, preis_cent, status, start_am, naechste_zahlung, laeuft_bis FROM wd_betreuung_abos WHERE kunde_id = ? AND status != 'abgelaufen'`) @@ -320,6 +330,7 @@ export function portalUebersicht(req, res) { return json(res, { ok: true, + angebote, kunde: { name: kunde.name, firma: kunde.firma, email: kunde.email, sprache: kunde.sprache, betreuung_aktiv: !!kunde.betreuung_aktiv, @@ -518,3 +529,27 @@ export function portalProfilSpeichern(req, res) { nachgebaute Fassung würde diesen Teil früher oder später verlieren — und dann hätte eine Sperre in einem Teil des Portals keine Wirkung. */ export { passwortHashen, passwortPruefen, PASSWORT_MIN, sitzungLesen, brauchtKunde }; + +/* --------------------------------------------------------------------- + ANGEBOTE — Hüllen um die Kernfunktionen + + Die Logik steht in webdesign-angebote.js, weil sie dort mit dem + Erzeugen zusammengehört. Hier kommt nur dazu, was das Portal + beisteuern muss: WER gerade angemeldet ist und von welcher Verbindung + aus zugesagt wurde. + + Der IP-Abdruck wird gehasht, nicht im Klartext gespeichert: Er dient + dem Nachweis, dass die Zusage von diesem Zugang kam -- dafür genügt + ein Vergleichswert, die Adresse selbst braucht niemand aufzubewahren. + --------------------------------------------------------------------- */ +export async function portalAngebotZusagen(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + return angebotZusagenKern(req, res, kunde, ipHash(besucherIp(req))); +} + +export async function portalAngebotAblehnen(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + return angebotAblehnenKern(req, res, kunde); +} diff --git a/server-internal/test-angebote.mjs b/server-internal/test-angebote.mjs new file mode 100644 index 00000000..659c0c91 --- /dev/null +++ b/server-internal/test-angebote.mjs @@ -0,0 +1,207 @@ +/* ===================================================================== + test-angebote.mjs — Angebot schicken, Kunde sagt zu + + Der folgenreichste Klick der ganzen Seite: Mit der Zusage entsteht ein + Vertrag. Entsprechend wird hier geprüft, dass + - alles oder nichts passiert, + - ein zweiter Klick kein zweites Projekt anlegt, + - der WORTLAUT eingefroren wird (bei Streit zählt, was gezeigt wurde), + - und die Uhr trotz Zusage NOCH NICHT läuft. + + Der letzte Punkt ist der, den man beim Bauen am ehesten falsch macht: + Es fühlt sich richtig an, mit der Zusage loszulegen. Die Regel ist + aber, dass die Zeit erst mit der Anzahlung läuft — und sie muss auch + dann gelten, wenn der Kunde selbst zugesagt hat. + ===================================================================== */ +import { mkdtempSync, rmSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes, createHmac } from "node:crypto"; + +const L = (...a) => writeSync(1, a.join(" ") + "\n"); +process.on("uncaughtException", (e) => { + writeSync(2, "\nABGEBROCHEN: " + ((e && e.stack) || e) + "\n"); process.exit(1); +}); +process.on("unhandledRejection", (e) => { + writeSync(2, "\nABGELEHNT: " + ((e && e.stack) || e) + "\n"); process.exit(1); +}); + +const tmp = mkdtempSync(join(tmpdir(), "wd-ang-")); +process.env.DB_PATH = join(tmp, "t.db"); +process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64"); +process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex"); + +const { initDb, db } = await import("./db.js"); +initDb(); +const geheim = await import("./lib/webdesign-geheimnisse.js"); +await geheim.geheimnisseLaden(); +const { vorlagenSicherstellen } = await import("./lib/webdesign-vorlagen.js"); +vorlagenSicherstellen(); +const G = await import("./routes/webdesign-angebote.js"); +const Auto = await import("./lib/webdesign-automatik.js"); + +let ok = 0, fehler = 0; +const pruefe = (n, b, z) => b + ? (ok++, L(" ok " + n)) + : (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : ""))); + +function ausweis(g = 60000) { + const n = Buffer.from(JSON.stringify({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + g })) + .toString("base64url"); + return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url"); +} +async function ruf(fn, extra, ...rest) { + let out = null; + await fn({ headers: { authorization: "Bearer " + ausweis() }, cookies: {}, + params: {}, body: {}, query: {}, ...extra }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }, + ...rest); + return out; +} + +const nun = new Date().toISOString(); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a1','A-1','Anna Muster','anna@example.com','website','neu','de',?)`).run(nun); + +L("\n1. VORSCHAU — alles vorbelegt, nichts zu tippen"); +let v = await ruf(G.angebotVorschau, { params: { id: "a1" } }); +pruefe("die Vorschau antwortet", v && v.ok === true, JSON.stringify(v).slice(0, 100)); +pruefe("das Paket kommt aus der Anfrage", v.paket === "website", v.paket); +pruefe("ein Preis ist vorgeschlagen", v.preisEuro > 0, v.preisEuro); +pruefe("die Anzahlung sind 30 Prozent davon", + Math.abs(v.anzahlungEuro - v.preisEuro * 0.3) < 0.01, v.anzahlungEuro); +pruefe("ein Ablaufdatum ist gesetzt", /^\d{4}-\d{2}-\d{2}$/.test(v.gueltigBis || ""), v.gueltigBis); +/* DER KERN: Der Leistungstext entsteht aus der Aufgabenvorlage. */ +pruefe("der Leistungstext ist nicht leer", (v.leistungen || "").length > 100, (v.leistungen || "").length); +pruefe("er ist nach Kategorien gegliedert", /\*\*/.test(v.leistungen)); +pruefe("und trennt, was der KUNDE beisteuern muss", + /Was ich von dir brauche/.test(v.leistungen), v.leistungen.slice(-120)); +pruefe("die Vorschau legt NICHTS an", + db.prepare(`SELECT COUNT(*) AS n FROM wd_angebote`).get().n === 0); + +L("\n2. EIN ANDERES PAKET WAEHLEN"); +const v2 = await ruf(G.angebotVorschau, { params: { id: "a1" }, query: { paket: "onepager" } }); +pruefe("das Paket laesst sich ueberschreiben", v2.paket === "onepager", v2.paket); +pruefe("und der Preis passt sich an", v2.preisEuro !== v.preisEuro, v2.preisEuro); +pruefe("auch der Leistungstext ist ein anderer", v2.leistungen !== v.leistungen); + +L("\n3. ABSENDEN"); +const g = await ruf(G.angebotSenden, { params: { id: "a1" }, + body: { paket: "website", preisEuro: 1490, leistungen: v.leistungen } }); +pruefe("das Absenden gelingt", g && g.ok === true, JSON.stringify(g).slice(0, 140)); +pruefe("es gibt eine Angebotsnummer", /^AG-/.test(g.nummer || ""), g.nummer); +const ang = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(g.id); +pruefe("der Status steht auf gesendet", ang.status === "gesendet"); +/* Eingefroren, nicht bei jeder Ansicht neu erzeugt -- sonst staende + nach der naechsten Vorlagenaenderung etwas anderes da als damals. */ +pruefe("der Wortlaut ist eingefroren", ang.leistungen_text === v.leistungen); +pruefe("die Anzahlung ist berechnet, nicht getippt", + ang.anzahlung_cent === Math.round(149000 * 0.3), ang.anzahlung_cent); + +const anf = db.prepare(`SELECT status, kunde_id FROM wd_anfragen WHERE id = 'a1'`).get(); +/* Der Wunsch: "wenn ich ein angebot rausschicke soll der automatisch + das erkennen". */ +pruefe("die Anfrage steht jetzt automatisch auf 'angebot'", anf.status === "angebot", anf.status); +pruefe("und ist mit dem Kunden verknuepft", !!anf.kunde_id); +pruefe("das Angebot liegt auch im Postfach", + db.prepare(`SELECT COUNT(*) AS n FROM wd_postfach WHERE kunde_id = ?`).get(anf.kunde_id).n === 1); + +L("\n4. KEIN ZWEITES ANGEBOT ZUR SELBEN ANFRAGE"); +const doppelt = await ruf(G.angebotSenden, { params: { id: "a1" }, + body: { paket: "website", preisEuro: 999 } }); +pruefe("ein zweites wird abgewiesen", doppelt.ok === false, JSON.stringify(doppelt).slice(0, 130)); +pruefe("mit Hinweis auf das offene", /AG-/.test(doppelt.error || ""), doppelt.error); + +L("\n5. WAS DER KUNDE SIEHT"); +const kunde = { id: anf.kunde_id, name: "Anna Muster" }; +let sicht = G.portalAngebote(kunde.id); +pruefe("das Angebot erscheint im Portal", sicht.length === 1); +pruefe("mit lesbarem Preis", /1.490/.test(sicht[0].preis_text), sicht[0].preis_text); +pruefe("und lesbarer Anzahlung", /447/.test(sicht[0].anzahlung_text), sicht[0].anzahlung_text); +pruefe("es ist annehmbar", sicht[0].annehmbar === true); + +L("\n6. ZUSAGE — hier entsteht der Vertrag"); +const z = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, kunde, "abc123"); +pruefe("die Zusage gelingt", z && z.ok === true, JSON.stringify(z).slice(0, 160)); +pruefe("es gibt eine Projektnummer", /^P-/.test(z.projektNummer || ""), z.projektNummer); + +const ang2 = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(g.id); +pruefe("das Angebot steht auf angenommen", ang2.status === "angenommen"); +pruefe("mit Zeitpunkt", !!ang2.entschieden_am); +/* Die Beweislast liegt beim Unternehmer: Wer, wann, von wo. */ +pruefe("und mit Herkunft der Zusage", ang2.zusage_ip_hash === "abc123", ang2.zusage_ip_hash); +pruefe("das Projekt haengt am Angebot", ang2.projekt_id === z.projektId); + +const pr = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(z.projektId); +pruefe("das Projekt uebernimmt den Preis", pr.preis_cent === 149000, pr.preis_cent); +pruefe("die Aufgabenliste steht", + db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(z.projektId).n > 5); +/* DER PUNKT, DEN MAN AM EHESTEN FALSCH MACHT. */ +pruefe("die UHR laeuft trotz Zusage NOCH NICHT", pr.uhr_start_am === null, String(pr.uhr_start_am)); +pruefe("der erste Zug liegt beim Kunden", pr.wartet_auf === "kunde", pr.wartet_auf); +pruefe("und der naechste Schritt nennt die Anzahlung", + /Anzahlung/.test(pr.naechster_schritt || ""), pr.naechster_schritt); + +const zg = db.prepare(`SELECT * FROM wd_zahlungen WHERE projekt_id = ?`).get(z.projektId); +pruefe("die Anzahlung wurde als offene Rechnung angelegt", !!zg && zg.status === "offen"); +pruefe("in der richtigen Hoehe", zg.betrag_cent === Math.round(149000 * 0.3), zg.betrag_cent); + +pruefe("die Anfrage steht jetzt auf angenommen", + db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a1'`).get().status === "angenommen"); + +L("\n7. BENACHRICHTIGUNG AN MICH"); +const m = Auto.meldungenLesen(true, 10).find((x) => x.art === "zusage"); +pruefe("die Zusage wird gemeldet", !!m, JSON.stringify(Auto.meldungenLesen(true, 5).map((x) => x.art))); +pruefe("sie ist dringend", m.dringend === 1); +pruefe("sie nennt den Betrag", /1.490/.test(m.text), m.text); +pruefe("und sagt, dass die Uhr erst mit der Anzahlung laeuft", + /Uhr läuft, sobald/.test(m.text), m.text); + +L("\n8. EIN ZWEITER KLICK LEGT NICHTS DOPPELT AN"); +const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n; +const z2 = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, kunde, "abc123"); +pruefe("er meldet keinen Fehler", z2.ok === true && z2.bereitsAngenommen === true); +pruefe("und es entstand kein zweites Projekt", + db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === vorher); + +L("\n9. EIN ANGENOMMENES ANGEBOT LAESST SICH NICHT ZURUECKZIEHEN"); +const zz = await ruf(G.angebotZurueckziehen, { params: { id: g.id } }); +pruefe("das wird verweigert", zz.ok === false, JSON.stringify(zz).slice(0, 130)); +pruefe("mit Hinweis auf den Projektabbruch", /Projekt ab/.test(zz.error || ""), zz.error); + +L("\n10. ABGELAUFENE ANGEBOTE"); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a2','A-2','Bert','bert@example.com','onepager','neu','de',?)`).run(nun); +const g2 = await ruf(G.angebotSenden, { params: { id: "a2" }, body: { paket: "onepager", preisEuro: 490 } }); +db.prepare(`UPDATE wd_angebote SET gueltig_bis = '2020-01-01' WHERE id = ?`).run(g2.id); +const k2 = db.prepare(`SELECT id, name FROM wd_kunden WHERE lower(email) = 'bert@example.com'`).get(); +const sicht2 = G.portalAngebote(k2.id); +pruefe("ein abgelaufenes Angebot ist NICHT mehr annehmbar", sicht2[0].annehmbar === false); +pruefe("und wird als abgelaufen gekennzeichnet", sicht2[0].abgelaufen === true); +/* Ein Angebot, das man noch sieht, aber nicht mehr annehmen kann, waere + eine Falle -- deshalb muss auch der Versuch scheitern. */ +const zAlt = await ruf(G.portalAngebotZusagen, { params: { id: g2.id } }, k2, "x"); +pruefe("die Zusage auf ein abgelaufenes Angebot scheitert", zAlt.ok === false, zAlt.error); +pruefe("mit einem freundlichen Hinweis statt einer Fehlermeldung", + /neues/.test(zAlt.error || ""), zAlt.error); + +L("\n11. FREMDE ANGEBOTE SIND UNSICHTBAR"); +const fremd = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, k2, "x"); +pruefe("ein anderer Kunde kann nicht zusagen", fremd.ok === false, fremd.error); +pruefe("und erfaehrt nicht einmal, dass es existiert", + /nicht gefunden/.test(fremd.error || ""), fremd.error); + +L("\n12. DIE RECHTEPRUEFUNG GREIFT"); +for (const [name, kopf] of [ + ["ohne Ausweis keine Vorschau", {}], + ["mit abgelaufenem Ausweis keine Vorschau", { authorization: "Bearer " + ausweis(-1000) }], +]) { + let out = null; + await G.angebotVorschau({ headers: kopf, cookies: {}, params: { id: "a1" }, query: {} }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }); + pruefe(name, out && out.ok === false, JSON.stringify(out).slice(0, 70)); +} + +L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); +process.exitCode = fehler ? 1 : 0; +rmSync(tmp, { recursive: true, force: true }); diff --git a/server-internal/test-automatik.mjs b/server-internal/test-automatik.mjs index 9c2b3e4c..c77fa971 100644 --- a/server-internal/test-automatik.mjs +++ b/server-internal/test-automatik.mjs @@ -221,6 +221,24 @@ pruefe("danach sind es null", Auto.meldungenOffen() === 0); pruefe("aber geloescht wurde nichts", db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen`).get().n >= offenVorher); +L("\n12. GELDBETRAEGE — zwei Fehler, die man nur bei bestimmten Zahlen sieht"); +const { centZuText } = await import("./lib/webdesign-helfer.js"); +/* Beide fielen erst auf, als das erste Angebot über tausend Euro + entstand — alle früheren Testbeträge lagen darunter, und da sieht man + weder den fehlenden Tausenderpunkt noch das verlorene Minuszeichen. */ +pruefe("Tausender bekommen einen Punkt", centZuText(149000) === "1.490,00 €", centZuText(149000)); +pruefe("Millionen auch", centZuText(100000000) === "1.000.000,00 €", centZuText(100000000)); +pruefe("darunter bleibt es unverändert", centZuText(99900) === "999,00 €", centZuText(99900)); +/* Math.trunc(-0.5) ergibt -0, und -0 schreibt sich als "0": Eine + Erstattung von 50 Cent erschien damit als Forderung. */ +pruefe("kleine NEGATIVE Beträge behalten ihr Minus", centZuText(-50) === "-0,50 €", centZuText(-50)); +pruefe("größere negative auch", centZuText(-149000) === "-1.490,00 €", centZuText(-149000)); +pruefe("null bleibt null", centZuText(0) === "0,00 €", centZuText(0)); +pruefe("nichts bleibt nichts", centZuText(null) === null); +pruefe("die Nachkommastellen stimmen", centZuText(1490) === "14,90 €", centZuText(1490)); +pruefe("eine andere Währung wird angehängt", + centZuText(1000, "CHF") === "10,00 CHF", centZuText(1000, "CHF")); + L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); process.exitCode = fehler ? 1 : 0; rmSync(tmp, { recursive: true, force: true });