Aufträge L5: Auftrag widerrufen (nur Privatpersonen innerhalb der Frist, unter Sperre)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:14:22 +00:00
1 parent 335ac60e54
commit 576a17bfa4
2 files changed
+107 -2

No files matched your search

+22 -1
View File
@@ -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() }))
})
}
+85 -1
View File
@@ -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')
})