Aufträge L5: Widerruf eines Projektauftrags auf der Widerrufsseite (nur Privatpersonen in der Frist, Nummer optional)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:16:45 +00:00
1 parent c72b252e63
commit 3beba876d6
2 files changed
+246 -13

No files matched your search

+61 -13
View File
@@ -5,6 +5,8 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js' import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js' import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
import { getLedger } from '../services/hourLedger.js' import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js' import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
@@ -727,7 +729,8 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher'] const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten'] const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten', 'projekt']
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist) // Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' } const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
@@ -735,7 +738,7 @@ const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspe
/** /**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten' | 'projekt'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/ */
function validDeclaration(body) { function validDeclaration(body) {
@@ -764,6 +767,7 @@ function validDeclaration(body) {
contract, contract,
order, order,
packages, packages,
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
} }
} }
@@ -804,7 +808,8 @@ function contractLabel(contract, sub) {
} }
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */ /** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
function orderLabel(order, sub, { customer = false } = {}) { function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
@@ -1147,6 +1152,37 @@ async function withdrawFeature(d, receivedAt, featureKey) {
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline } return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
} }
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
async function withdrawProjekt(d, receivedAt) {
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
}
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
const ergebnisText = {
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
nach_frist: () => 'nichts geändert',
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
}[ergebnis]
return {
ereignisse: ergebnisText
? ergebnisText()
: AB_NUMMER.test(abNummer)
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
}
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */ /** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
function describeWithdrawn(events) { function describeWithdrawn(events) {
return events return events
@@ -1168,10 +1204,14 @@ router.post('/public/withdraw', async (req, res) => {
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) // Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null const feature = featureKey !== null
const projekt = d.order === 'projekt'
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
const funktion = feature ? featureConfig(featureKey) : null const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false } let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try { try {
if (feature) { if (projekt) {
pr = await withdrawProjekt(d, receivedAt)
} else if (feature) {
fn = await withdrawFeature(d, receivedAt, featureKey) fn = await withdrawFeature(d, receivedAt, featureKey)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) } result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else { } else {
@@ -1183,8 +1223,11 @@ router.post('/public/withdraw', async (req, res) => {
} }
} catch (err) { } catch (err) {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message) console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
} }
const { inTime } = result const { inTime } = result
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
// Fuer T09: warum sich die Frist nicht pruefen liess // Fuer T09: warum sich die Frist nicht pruefen liess
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : '' const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
@@ -1192,8 +1235,8 @@ router.post('/public/withdraw', async (req, res) => {
template: 'K04', template: 'K04',
values: { values: {
name: d.name, name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }), bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),
produkt: feature ? funktion.label.name : productLabelOf(sub), produkt: produktText,
eingang_zeitpunkt: fmtDateTime(receivedAt), eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email, email: d.email,
kundennummer: d.customerNumber, kundennummer: d.customerNumber,
@@ -1204,7 +1247,9 @@ router.post('/public/withdraw', async (req, res) => {
wechsel: inTime && d.order === 'wechsel', wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher', zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature, funktion: feature,
pruefen: !inTime && !feature, pruefen: !inTime && !feature && !projekt,
projekt,
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
}, },
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) }).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
@@ -1215,13 +1260,15 @@ router.post('/public/withdraw', async (req, res) => {
email: d.email, email: d.email,
name: d.name, name: d.name,
kundennummer: d.customerNumber, kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub), bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),
produkt: feature ? funktion.label.name : productLabelOf(sub), produkt: produktText,
preis: feature ? funktion.priceLabel : '', preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '', stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt), abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: feature rechtzeitig: projekt
? projektAngaben.rechtzeitig
: feature
? !fn.customer ? !fn.customer
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)' ? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
: !fn.orders.length : !fn.orders.length
@@ -1242,14 +1289,14 @@ router.post('/public/withdraw', async (req, res) => {
: result.deadline : result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)' ? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)', : 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn), ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),
pruefhinweis, pruefhinweis,
}, },
blocks: { blocks: {
kundennummer: Boolean(d.customerNumber), kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub), abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: result.canceled, auf_kuendigung_gesetzt: result.canceled,
kein_abo_gefunden: !sub && !feature, kein_abo_gefunden: !sub && !feature && !projekt,
kuendigung_fehlgeschlagen: result.cancelFailed, kuendigung_fehlgeschlagen: result.cancelFailed,
abschluss: inTime && d.order === 'abschluss', abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel', wechsel: inTime && d.order === 'wechsel',
@@ -1261,6 +1308,7 @@ router.post('/public/withdraw', async (req, res) => {
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn, abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis), nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn, bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
projekt,
}, },
}) })
+185
View File
@@ -0,0 +1,185 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, send } = await import('./support/testApp.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js')
const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString()
const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
let protokoll
beforeEach(() => {
resetPublicRateLimit()
resetAuftragRoutesForTests()
sink.sent.length = 0
config.auftraege.mode = 'on'
protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body })
const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs'))
const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen'))
const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang))
const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)'
const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)'
const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'
const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen'
const pruefeT09 = (ergebnis, frist, eingang) => {
assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`)
assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`)
assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`)
}
test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => {
const gesendetAm = vor(3)
seed({ gesendetAm })
const stripeVorher = st.calls.length
const res = await widerruf({ customerNumber: 'KD-9' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'])
assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf')
const ab = await getAuftrag('ab1')
assert.equal(ab.status, 'widerrufen')
assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime())
// K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid'])
assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text)
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`)
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'))
assert.ok(k04().text.includes('Kundennummer: KD-9'))
assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/)
// T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang
assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag')
pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG')
assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/)
})
test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => {
seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true })
assert.equal((await widerruf()).status, 200)
const ab = await getAuftrag('ab1')
assert.equal(ab.status, 'widerrufen')
assert.notEqual(ab.bestaetigtAm, null)
assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)')
})
test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => {
seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') })
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'versendet')
assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text)
pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft')
})
test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => {
const gesendetAm = vor(20)
seed({ gesendetAm })
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'versendet')
pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen')
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS))
})
test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => {
const faelle = [
[{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
[{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
[{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER],
[{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER],
[{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER],
[{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER],
]
seed({ gesendetAm: vor(3) })
for (const [body, label, ergebnis] of faelle) {
sink.sent.length = 0
resetPublicRateLimit()
const res = await widerruf(body)
const was = JSON.stringify(body)
assert.equal(res.status, 200, was)
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was)
assert.equal((await getAuftrag('ab1')).status, 'versendet', was)
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was)
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was)
pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft')
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was)
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was)
}
})
test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => {
seed({ gesendetAm: vor(3) })
assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
})
test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => {
const faelle = [
['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'],
['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'],
['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'],
['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'],
]
for (const [status, ergebnis, eingang] of faelle) {
const gesendetAm = vor(3)
seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null })
sink.sent.length = 0
resetPublicRateLimit()
assert.equal((await widerruf()).status, 200, status)
pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang)
assert.equal((await getAuftrag('ab1')).status, status, status)
}
})
test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => {
seed({ gesendetAm: vor(3) })
assert.equal((await widerruf()).status, 200)
const lesen = await send('GET', `/api/auftrag/${TOKEN}`)
assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }])
assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410)
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
})
test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => {
seed({ gesendetAm: vor(3) })
config.auftraege.mode = 'off'
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404)
})
test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => {
seed({ gesendetAm: vor(3) })
aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 })
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'versendet')
pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft')
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
assert.ok(protokoll.mock.callCount() > 0)
})