Wunsch: "ich will dass du es abcheckst" — nicht Stück für Stück (das war
schon geprüft), sondern EINMAL DURCHGÄNGIG als ein zusammenhängender
Ablauf, so wie ein echter Auftrag tatsächlich läuft: Anfrage → Angebot
→ Zusage im Portal → Anzahlung → laufende Uhr → Benachrichtigung →
Arbeit → Restzahlung → Gegenprobe mit Abbruch.
test-kette.mjs bildet genau das ab, gegen die echten Funktionen aus
server-internal/, mit einer Wegwerf-Datenbank. 40 Prüfungen, alle grün.
DABEI GEFUNDEN: Der Liefertermin-Countdown im Portal prüfte nicht, ob
die Uhr überhaupt schon läuft. Ein frisch zugesagtes Projekt hat schon
ein termin_am (aus der Zusage vorgerechnet), aber uhr_start_am steht
noch auf null, solange die Anzahlung nicht da ist. Der Kunde hätte also
direkt nach der Zusage einen tickenden Countdown gesehen — und wenn die
Anzahlung eintrifft, wird der Termin ab dem Zahlungstag NEU berechnet
und springt dann sichtbar nach hinten. Das sieht aus wie ein Fehler,
auch wenn die Zahl rechnerisch stimmt: Kaum zu erklären, warum "noch 8
Tage" plötzlich wieder "noch 10 Tage" werden.
Jetzt zwei klar getrennte Zustände: Vor der Anzahlung steht "Startet,
sobald deine Anzahlung da ist — voraussichtlicher Liefertermin danach:
ca. {datum}" (kein Countdown, aber der Termin bleibt sichtbar, kein
Verstecken). Danach der echte Countdown. Fünf Sprachen ergänzt.
Nebenbei einen irreführenden Kommentar korrigiert: Ein neuer Kunde wird
beim Angebot-Senden SOFORT freigeschaltet (nicht erst bei Zusage, wie
der alte Kommentar behauptete) — sonst könnte er sich gar nicht
einloggen, um sein eigenes Angebot anzusehen. Der Code war richtig,
nur die Erklärung falsch, und mein erster Testentwurf ist genau darauf
hereingefallen.
Bei der Gelegenheit auch die bekannte better-sqlite3-Aussetzer-Eigenart
(zufälliger Absturz beim Prozessende, dokumentiert seit früheren
Commits) noch einmal eingegrenzt: Derselbe Import in test-angebote.mjs
brach mal nach "GEHEIM", mal nach "VORLAGEN" ab — der Absturzpunkt
verschiebt sich zwischen identischen Läufen. Das ist der endgültige
Beweis, dass es reine GC-Zeitfensterflakiness in der nativen
Bibliothek ist, kein Fehler im eigenen Code.
Geprüft: 40 (Kette) + 55 + 56 + 70 + 28 + 49 serverseitig (alle
mehrfach unabhängig grün), 261 im Browser (Portal, Angebot, Übersicht,
Verwaltung, Abbruch, Design). Ein Screenshot bestätigt den neuen
Wartehinweis visuell.
464 lines
19 KiB
JavaScript
464 lines
19 KiB
JavaScript
/* =====================================================================
|
|
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
|
|
SOFORT freigeschaltet, nicht erst mit der Zusage -- ein Widerspruch
|
|
zur ersten Fassung dieses Kommentars, korrigiert am 24.08.2026 beim
|
|
Durchspielen der kompletten Kette.
|
|
|
|
freigeschaltet steuert das PORTAL-LOGIN selbst (siehe
|
|
webdesign-portal.js, portalAnmelden). Stuende hier 0, koennte ein
|
|
brandneuer Kunde sich gar nicht erst einloggen -- er bekaeme eine
|
|
Nachricht "dein Angebot liegt bereit" und liefe beim Versuch,
|
|
es sich anzusehen, gegen "Dein Zugang ist noch nicht
|
|
freigeschaltet". Das Angebot waere fuer ihn unerreichbar. */
|
|
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 });
|
|
}
|