diff --git a/server/services/auftraege/bestaetigung.js b/server/services/auftraege/bestaetigung.js new file mode 100644 index 0000000..5d76e75 --- /dev/null +++ b/server/services/auftraege/bestaetigung.js @@ -0,0 +1,55 @@ +/** + * Bestätigen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`, + * Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen + * nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts. + */ +import { withLocks } from '../keyedLock.js' +import { getAuftrag, updateAuftrag } from './auftragStore.js' +import { AuftragError } from './fehler.js' +import { sperreAuftrag } from './konstanten.js' +import { sendeBestaetigtMails } from './mails.js' +import { FRUEHBEGINN_FASSUNG } from './texte.js' + +export const GESPERRT_MELDUNG = { + ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', + storniert: 'Diese Auftragsbestätigung wurde storniert.', + widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.', +} +const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.' +const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.' +const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen'] + +/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */ +export function bestaetigungsLage(auftrag) { + if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt' + return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen' +} + +function pruefeZeitpunkt(jetzt) { + if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein') +} + +export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) { + // synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst + if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein') + pruefeZeitpunkt(jetzt) + const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => { + const frisch = await getAuftrag(auftrag.id) + if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN) + const lage = bestaetigungsLage(frisch) + if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR) + if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false } + const privat = frisch.kunde?.kundenart === 'privat' + const patch = { + bestaetigtAm: jetzt.toISOString(), + bestaetigtUeber: ueber, + bestaetigtPdfSha256: frisch.pdfSha256, + fruehbeginn: privat && fruehbeginn === true, + fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '', + } + if (frisch.status === 'versendet') patch.status = 'bestaetigt' + return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true } + }) + if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell) + return aktuell +} diff --git a/test/auftraegeBestaetigung.test.js b/test/auftraegeBestaetigung.test.js new file mode 100644 index 0000000..35adff5 --- /dev/null +++ b/test/auftraegeBestaetigung.test.js @@ -0,0 +1,175 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') +const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') +const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { bestaetigeAuftrag, bestaetigungsLage } = await import('../server/services/auftraege/bestaetigung.js') +after(() => { + setMailTransportForTests(null) + return aw.close() +}) + +const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ) +const ms = (iso) => new Date(iso).getTime() +let sink +let protokoll +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + protokoll = mock.method(console, 'error', () => {}) +}) +afterEach(() => mock.restoreAll()) +const seed = (over = {}) => { + const ab = abDokument(over) + aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) }) + return ab +} +const betreffe = () => sink.sent.map((mail) => mail.subject) +const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen' +const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' +const norm = (text) => text.replace(/\s+/g, ' ').trim() + +test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => { + const lage = (over) => bestaetigungsLage(abDokument(over)) + assert.equal(lage({}), 'offen') + assert.equal(lage({ status: 'abgerechnet' }), 'offen') + assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') + assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') + for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status) +}) + +test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => { + const ab = seed() + const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) + assert.equal(neu.status, 'bestaetigt') + assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) + assert.equal(neu.bestaetigtUeber, 'link') + assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256) + assert.equal(neu.fruehbeginn, true) + assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()]) + assert.deepEqual(betreffe(), [K32, T22]) + assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder') + assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`)) +}) + +test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => { + const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) + assert.equal(neu.bestaetigtUeber, 'portal') + assert.equal(neu.fruehbeginn, false) + assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten') + assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.')) + assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026')) + assert.ok(sink.sent[1].text.includes('Weg: Kundenportal')) +}) + +test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => { + const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) + assert.equal(neu.fruehbeginn, false) + assert.equal(neu.fruehbeginnFassung, '') + for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/) + assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH') +}) + +test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => { + const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) + assert.equal(neu.status, 'abgerechnet') + assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) + assert.equal((await getAuftrag('ab1')).status, 'abgerechnet') + assert.equal(sink.sent.length, 2) +}) + +test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => { + const ab = seed() + const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) + const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) }) + assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm)) + assert.equal(zweite.bestaetigtUeber, 'link') + assert.equal(zweite.fruehbeginn, true) + assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung') +}) + +test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => { + const ab = seed() + const [a, b] = await Promise.all([ + bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), + bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }), + ]) + assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm)) + assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt') + assert.equal(b.bestaetigtUeber, 'link') + assert.equal(sink.sent.length, 2) +}) + +test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => { + const ab = seed() + let freigeben + const gehalten = new Promise((resolve) => { + freigeben = resolve + }) + const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) + let fertig = false + const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => { + fertig = true + return neu + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') + assert.equal((await getAuftrag('ab1')).status, 'versendet') + freigeben() + await halter + assert.equal((await bestaetigt).status, 'bestaetigt') + // Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück) + seed() + sink.sent.length = 0 + const frei = [] + setMailTransportForTests({ + async sendMail(mail) { + frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))])) + return sink.sendMail(mail) + }, + }) + await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) + assert.deepEqual(frei, [true, true]) + assert.equal(sink.sent.length, 2) +}) + +test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => { + const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']] + for (const [status, meldung] of faelle) { + const ab = seed({ status }) + await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status) + } + assert.equal(sink.sent.length, 0) +}) + +test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => { + setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } }) + const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) + assert.equal(neu.status, 'bestaetigt') + assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') + assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll') +}) + +test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => { + const ab = seed() + await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError) + await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError) + await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404) + assert.equal(sink.sent.length, 0) +})