Ein Token-Tausch durch die Erinnerung zwischen Pruefung und Sperre ergibt 404 wie bei einem ungueltigen Link; das Portal ist nicht betroffen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
86 lines
4.8 KiB
JavaScript
86 lines
4.8 KiB
JavaScript
/**
|
|
* 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'
|
|
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.',
|
|
}
|
|
export const UNGUELTIGER_LINK = 'Dieser Link ist ungültig oder abgelaufen.'
|
|
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 (ueber === 'link') {
|
|
// Die Route hat Token und Ablauf auf einem Stand ausserhalb der Sperre geprueft; eine Erinnerung (Token-Tausch) kann
|
|
// dazwischen gelaufen sein: dann gilt der alte Link nicht mehr, wie bei jedem ungueltigen Link (404)
|
|
const bis = frisch.tokenGueltigBis ? Date.parse(frisch.tokenGueltigBis) : Number.NaN
|
|
if (!frisch.tokenHash || frisch.tokenHash !== auftrag.tokenHash || !Number.isFinite(bis) || jetzt.getTime() > bis) {
|
|
throw new AuftragError(404, UNGUELTIGER_LINK)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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() }))
|
|
})
|
|
}
|