From 576a17bfa42c2cee8633ee5d7c6c8c5e241290f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:14:22 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge=20L5:=20Auftrag=20widerrufen=20(n?= =?UTF-8?q?ur=20Privatpersonen=20innerhalb=20der=20Frist,=20unter=20Sperre?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/bestaetigung.js | 23 +++++- test/auftraegeBestaetigung.test.js | 86 ++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/server/services/auftraege/bestaetigung.js b/server/services/auftraege/bestaetigung.js index 5d76e75..c33eedf 100644 --- a/server/services/auftraege/bestaetigung.js +++ b/server/services/auftraege/bestaetigung.js @@ -1,8 +1,9 @@ /** - * Bestätigen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`, + * Bestätigen und Widerrufen 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 { withdrawalDeadline } from '../deadlines.js' import { withLocks } from '../keyedLock.js' import { getAuftrag, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' @@ -53,3 +54,23 @@ export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = n if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell) return aktuell } + +/** + * Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre. + * Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre). + * Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'. + */ +export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) { + pruefeZeitpunkt(jetzt) + return withLocks(sperreAuftrag(auftrag.id), async () => { + const frisch = await getAuftrag(auftrag.id) + if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN) + const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null + const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde }) + if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen') + if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht') + if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar') + if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist') + return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() })) + }) +} diff --git a/test/auftraegeBestaetigung.test.js b/test/auftraegeBestaetigung.test.js index 35adff5..778753f 100644 --- a/test/auftraegeBestaetigung.test.js +++ b/test/auftraegeBestaetigung.test.js @@ -15,7 +15,7 @@ const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/servic 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') +const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js') after(() => { setMailTransportForTests(null) return aw.close() @@ -173,3 +173,87 @@ test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekan 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) }) + +// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC) +const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z') + +test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => { + const ab = seed() + const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE }) + assert.equal(ok.ergebnis, 'widerrufen') + assert.equal(ok.auftrag.status, 'widerrufen') + assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime()) + assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime()) + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') + seed() + const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) }) + assert.equal(spaet.ergebnis, 'nach_frist') + assert.equal(spaet.auftrag.status, 'versendet') + assert.equal((await getAuftrag('ab1')).widerrufenAm, null) +}) + +test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => { + const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true }) + const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT }) + assert.equal(r.ergebnis, 'widerrufen') + assert.equal(r.auftrag.fruehbeginn, true) + assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime()) + const firma = seed({ kunde: kunde('unternehmen') }) + const k = await widerrufeAuftrag(firma, { jetzt: JETZT }) + assert.equal(k.ergebnis, 'kein_widerrufsrecht') + assert.equal((await getAuftrag('ab1')).status, 'versendet') +}) + +test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => { + const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']] + for (const [status, ergebnis] of faelle) { + const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null }) + const r = await widerrufeAuftrag(ab, { jetzt: JETZT }) + assert.equal(r.ergebnis, ergebnis, status) + const doc = await getAuftrag('ab1') + assert.equal(doc.status, status, status) + assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status) + } +}) + +test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => { + const veraltet = seed() + let freigeben + const gehalten = new Promise((resolve) => { + freigeben = resolve + }) + const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) + let fertig = false + const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => { + fertig = true + return r + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') + seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3) + freigeben() + await halter + const r = await widerruf + assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre') + assert.equal((await getAuftrag('ab1')).status, 'storniert') + assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails') +}) + +test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => { + const ab = seed() + const [bestaetigt, widerrufen] = await Promise.all([ + bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), + widerrufeAuftrag(ab, { jetzt: JETZT }), + ]) + assert.equal(bestaetigt.status, 'bestaetigt') + assert.equal(widerrufen.ergebnis, 'widerrufen') + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') + seed() + const umgekehrt = await Promise.allSettled([ + widerrufeAuftrag(ab, { jetzt: JETZT }), + bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), + ]) + assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen') + assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409) + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') +})