Buchungskalender: Storno und Erstattung getrennt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 13:58:17 +00:00
1 parent f7650fe883
commit 1d2ca2791d
3 files changed
+110 -53

No files matched your search

+57 -17
View File
@@ -14,6 +14,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const ab = await import('../server/services/unterkunft/ablauf.js')
const mails = await import('../server/services/unterkunft/mails.js')
const zahlung = await import('../server/services/unterkunft/zahlung.js')
const { UkFehler } = await import('../server/services/unterkunft/fehler.js')
const sp = await import('../server/services/unterkunft/speicher.js')
const { istFrei } = await import('../server/services/unterkunft/belegung.js')
@@ -27,6 +28,7 @@ const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin
beforeEach(async () => {
for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id)
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung', maxPersonen: 4, mindestNaechte: 2, aktiv: true })
aw.seed('ukPreise', {
$id: 'p1', unterkunftId: 'u1', featureId: 'f1', staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]),
@@ -177,7 +179,7 @@ test('Storno: Stornorechnung mit eigener Nummer, G22; Erstattung > brutto = 400'
const { id, token } = await freigegeben()
const { rechnung } = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
mails._aufrufe.length = 0
assert.equal(await fehlerCode(ab.storniere(ctx, id, { grund: 'x', erstattungCent: 100, now: NOW })), '400 ungueltig')
assert.equal(await fehlerCode(ab.storniere(ctx, id, { grund: 'x', erstattungCent: 100, now: NOW })), '409 status_ungueltig')
assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht')
const r = await ab.storniere(ctx, id, { grund: 'Wunsch', now: NOW })
assert.equal(r.anfrage.status, 'storniert')
@@ -232,39 +234,77 @@ test('Buchung: unvollstaendige Einstellungen -> 409, nichts geschrieben', async
assert.equal((await sp.ladeAnfrage(id)).status, 'freigegeben')
})
test('Storno: Erstattung scheitert -> Rollback, Wiederholung erstattet genau einmal', async () => {
async function bezahltOnline() {
const sctx = { ...ctx, einstellungen: { ...ctx.einstellungen, onlineZahlung: true, stripeAccountId: 'acct_1' } }
const ohne = { ...ctx }
const { id, token } = await freigegeben()
await ab.bucheVerbindlich(sctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })
await ab.markiereBezahlt(sctx, id, { via: 'stripe', now: NOW })
assert.equal(await fehlerCode(ab.storniere(ohne, id, { erstattungCent: 20000, now: NOW })), '409 online_aus')
const a = await sp.ladeAnfrage(id)
assert.equal(a.status, 'bezahlt')
mails._aufrufe.length = 0
return { sctx, id }
}
test('Storno: Erstattung scheitert -> storniert, eine Stornorechnung, 502; Wiederholung erstattet einmal, kein zweites G22', async () => {
const { sctx, id } = await bezahltOnline()
await assert.rejects(ab.storniere({ ...ctx }, id, { grund: ' g ', erstattungCent: 20000, now: NOW }), (e) => e.status === 502 && e.code === 'erstattung_fehlgeschlagen')
let a = await sp.ladeAnfrage(id)
assert.equal(a.status, 'storniert')
assert.equal(a.erstattetCent, 0)
assert.equal(aw.docs('ukRechnungen').size, 2)
const r = await ab.storniere(sctx, id, { grund: ' g ', erstattungCent: 20000, now: NOW })
assert.deepEqual(arten(), ['G22'])
assert.equal(mails._aufrufe[0].extra.grund, 'g')
const r = await ab.storniere(sctx, id, { grund: 'g', erstattungCent: 20000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 20000)
assert.equal(aw.docs('ukRechnungen').size, 2)
assert.equal(await fehlerCode(ab.storniere(sctx, id, { erstattungCent: 20000, now: NOW })), '409 status_ungueltig')
assert.equal(mails._aufrufe.at(-1).extra.grund, 'g')
assert.equal(r.stornoRechnung.nummer, 'RE-2026-0002')
assert.deepEqual(arten(), ['G22'])
assert.equal(zahlung._aufrufe.at(-1).idempotencyKey, `uk-erstattung-${id}-20000`)
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id, { betragCent: 1, now: NOW })), '400 ungueltig')
})
test('Storno: Schreibfehler vor der Erstattung -> keine Erstattung, Status unveraendert', async () => {
const sctx = { ...ctx, einstellungen: { ...ctx.einstellungen, onlineZahlung: true, stripeAccountId: 'acct_1' } }
const { id, token } = await freigegeben()
await ab.bucheVerbindlich(sctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })
await ab.markiereBezahlt(sctx, id, { via: 'stripe', now: NOW })
test('erstatteBetrag: Teilbetraege, Obergrenze, nur storniert, nur Stripe', async () => {
const { sctx, id } = await bezahltOnline()
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id, { betragCent: 100, now: NOW })), '409 status_ungueltig')
await ab.storniere(sctx, id, { grund: 'g', now: NOW })
assert.equal(zahlung._aufrufe.length, 0)
await ab.erstatteBetrag(sctx, id, { betragCent: 5000, now: NOW })
const a = await ab.erstatteBetrag(sctx, id, { betragCent: 15000, now: NOW })
assert.equal(a.erstattetCent, 20000)
assert.deepEqual(zahlung._aufrufe.map((x) => x.idempotencyKey), [`uk-erstattung-${id}-5000`, `uk-erstattung-${id}-20000`])
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id, { betragCent: 1, now: NOW })), '400 ungueltig')
const { id: id2, token } = await (async () => {
const x = await anlegen('2026-12-10', '2026-12-12')
const f = await ab.gibFrei(ctx, x.$id, { now: NOW })
await ab.bucheVerbindlich(ctx, x.$id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
return { id: x.$id, token: f.token }
})()
void token
await ab.storniere(ctx, id2, { grund: 'g', now: NOW })
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id2, { betragCent: 100, now: NOW })), '409 status_ungueltig')
})
test('Storno: Schreibfehler -> weder Erstattung noch Statuswechsel; Wiederholung klappt', async () => {
const { sctx, id } = await bezahltOnline()
aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 })
await assert.rejects(ab.storniere(sctx, id, { erstattungCent: 20000, now: NOW }))
const a = await sp.ladeAnfrage(id)
assert.equal(a.status, 'bezahlt')
assert.equal(a.erstattetCent, 0)
assert.equal(zahlung._aufrufe.length, 0)
assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt')
const r = await ab.storniere(sctx, id, { erstattungCent: 20000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 20000)
assert.equal(aw.docs('ukRechnungen').size, 2)
})
test('Buchung: Wiederholung behaelt Adresse und Zahlart der ersten Rechnung', async () => {
const { id, token } = await freigegeben()
aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 })
await assert.rejects(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }))
const r = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: { ...ADRESSE, ort: 'Hamburg' }, zahlart: 'online', now: NOW }).catch((e) => e)
// online ohne Stripe-Konto wird weiterhin abgelehnt; mit vorort und anderer Adresse gilt die erste
assert.equal(r.code, 'online_aus')
const r2 = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: { ...ADRESSE, ort: 'Hamburg' }, zahlart: 'vorort', now: NOW })
assert.equal(r2.anfrage.rechnungsadresse.ort, 'Berlin')
assert.equal(aw.docs('ukRechnungen').size, 1)
})
test('Rechnung traegt die gespeicherte Steuerart (Kleinunternehmer; Regel mit 0 %)', async () => {
aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: 'f1', steuerart: 'kleinunternehmer' })
const a = await anlegen()