Files
DogFatherGitandClaude Opus 5 cea265eb8c Angebote: der Kunde sagt mit einem Klick zu
DER LEITGEDANKE: SO WENIG TIPPEN WIE MOEGLICH
Niemand schreibt hier den Leistungsumfang. Er entsteht aus der
Aufgabenvorlage des Pakets -- denselben 92 Punkten, nach denen spaeter
gearbeitet wird. Der Nebeneffekt ist wichtiger 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 tun bleibt: Paket waehlen, Preis bestaetigen. Beides ist vorbelegt,
Laufzeit und Ablaufdatum werden gerechnet.

DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN
Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zaehlt, WAS
dem Kunden gezeigt wurde, als er zusagte. Wuerde der Text bei jeder
Ansicht neu aus den Vorlagen erzeugt, staende nach der naechsten
Vorlagenaenderung etwas anderes da als damals. Die Zusage wird mit
Zeitpunkt und (gehashter) Herkunft belegt -- die Beweislast liegt beim
Unternehmer.

WAS AUTOMATISCH PASSIERT
Angebot raus -> Anfrage steht auf 'angebot' (der Wunsch: 'wenn ich ein
angebot rausschicke soll der automatisch das erkennen'). Zusage ->
Projekt angelegt, Aufgabenliste eingesetzt, Anzahlung als offene
Rechnung erzeugt, Anfrage auf 'angenommen', Benachrichtigung an mich.

Die UHR startet dabei 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. Genau dieser Punkt wird ausdruecklich
geprueft: Es fuehlt sich richtig an, mit der Zusage loszulegen.

ZWEI FEHLER IN DER GELDANZEIGE GEFUNDEN
Beide fielen erst auf, als das erste Angebot ueber tausend Euro entstand
-- alle frueheren Testbetraege lagen darunter:
- Kein Tausenderpunkt: 1490 Euro erschienen als '1490,00 €'.
- 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. Ausgerechnet beim Abbruch mit
  Rueckzahlung waere das der falsche Ort fuer einen Anzeigefehler.
Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe muss auf dem
Server und im Browser zeichengleich sein -- ein Betrag, der in der
Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl.

Geprueft: 56 zu den Angeboten, 53 zur Automatik (inkl. der neun
Geldpruefungen), 70 zur Annahme, 28 zur Suche -- alle gegen eine echte
Datenbank. Darunter: zweiter Klick legt nichts doppelt an, abgelaufene
Angebote lassen sich nicht mehr annehmen (ein Angebot, das man sieht
aber nicht annehmen kann, waere eine Falle), ein fremder Kunde erfaehrt
nicht einmal, dass ein Angebot existiert.

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

208 lines
11 KiB
JavaScript

/* =====================================================================
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','[email protected]','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','[email protected]','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) = '[email protected]'`).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 });