Buchungskalender: Storno und Buchung idempotent bei Teilfehlern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
947e9da2e3
commit
f7650fe883
5 files changed
+106
-11
No files matched your search
@@ -8,7 +8,7 @@ import { UkFehler } from './fehler.js'
|
||||
import * as mails from './mails.js'
|
||||
import { berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js'
|
||||
import { stelleAus } from './rechnungen.js'
|
||||
import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js'
|
||||
import { aktualisiereAnfrage, ladeAnfrage, findeRechnung, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js'
|
||||
import { neuesToken, tokenPasst } from './token.js'
|
||||
import * as zahlung from './zahlung.js'
|
||||
|
||||
@@ -39,6 +39,11 @@ async function sende(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rechnung zu Anfrage und Art ausstellen, aber nur einmal: eine vorhandene wird wiederverwendet (Wiederholung nach Teilfehler). */
|
||||
async function rechnungEinmal(ctx, a, art, opts) {
|
||||
return (await findeRechnung(a.$id, art)) ?? stelleAus(ctx, a, { art, ...opts })
|
||||
}
|
||||
|
||||
function fuerFeature(ctx, a) {
|
||||
if (!a || a.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden')
|
||||
return a
|
||||
@@ -124,7 +129,7 @@ export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, no
|
||||
sauber = berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }).posten
|
||||
}
|
||||
const steuerart = preise?.steuerart ?? 'regel'
|
||||
const summen = summiere(sauber, steuerart)
|
||||
const summen = { ...summiere(sauber, steuerart), steuerart }
|
||||
if (!(summen.bruttoCent > 0)) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe muss größer als 0 sein.' })
|
||||
if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt')
|
||||
const tage = Number.isInteger(fristTage) && fristTage >= 1 ? fristTage : preise?.fristTage || 3
|
||||
@@ -194,10 +199,13 @@ export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse
|
||||
const einst = ctx.einstellungen || {}
|
||||
if (zahlart === 'online' && !(einst.onlineZahlung && einst.stripeAccountId)) throw new UkFehler(409, 'online_aus')
|
||||
const adresse = pruefeAdresse(rechnungsadresse)
|
||||
if (!einst.firma || !einst.strasse || !einst.plz || !einst.ort || !(einst.steuernummer || einst.ustId)) {
|
||||
throw new UkFehler(409, 'einstellungen_unvollstaendig')
|
||||
}
|
||||
if (!Array.isArray(a.posten) || !a.summen) throw new UkFehler(409, 'status_ungueltig')
|
||||
if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt')
|
||||
const vorlaeufig = { ...a, rechnungsadresse: adresse, zahlart }
|
||||
const rechnung = await stelleAus(ctx, vorlaeufig, { art: 'rechnung', now })
|
||||
const rechnung = await rechnungEinmal(ctx, vorlaeufig, 'rechnung', { now })
|
||||
const anfrage = await aktualisiereAnfrage(a.$id, {
|
||||
status: 'gebucht',
|
||||
rechnungsadresse: adresse,
|
||||
@@ -241,22 +249,36 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0
|
||||
if (a.bezahltVia !== 'stripe') throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Erstattung ist nur bei Online-Zahlung möglich.' })
|
||||
if (!Number.isInteger(brutto) || erstattungCent > brutto - bisher) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Der Betrag ist zu hoch.' })
|
||||
}
|
||||
let erstattet = 0
|
||||
if (erstattungCent > 0) erstattet = (await zahlung.erstatte(ctx, a, erstattungCent, { now })).erstattetCent
|
||||
let stornoRechnung = null
|
||||
if (a.rechnungId) {
|
||||
const orig = await ladeRechnung(a.rechnungId)
|
||||
if (orig) stornoRechnung = await stelleAus(ctx, a, { art: 'storno', bezugNummer: orig.nummer, now })
|
||||
if (orig) stornoRechnung = await rechnungEinmal(ctx, a, 'storno', { bezugNummer: orig.nummer, now })
|
||||
}
|
||||
// Erst Status und Erstattungsbetrag festschreiben, dann Stripe: scheitert danach etwas, wird nie ein zweites Mal erstattet.
|
||||
const anfrage = await aktualisiereAnfrage(a.$id, {
|
||||
status: 'storniert',
|
||||
stornoGrund: text(grund, 500),
|
||||
storniertAt: iso(ms(now)),
|
||||
erstattetCent: bisher + erstattet,
|
||||
erstattetCent: bisher + erstattungCent,
|
||||
})
|
||||
let erstattet = erstattungCent
|
||||
if (erstattungCent > 0) {
|
||||
try {
|
||||
erstattet = (
|
||||
await zahlung.erstatte(ctx, a, erstattungCent, { now, idempotencyKey: `uk-erstattung-${a.$id}-${bisher + erstattungCent}` })
|
||||
).erstattetCent
|
||||
} catch (err) {
|
||||
await aktualisiereAnfrage(a.$id, { status: a.status, stornoGrund: a.stornoGrund ?? '', storniertAt: null, erstattetCent: bisher })
|
||||
throw err
|
||||
}
|
||||
if (erstattet !== erstattungCent) {
|
||||
const korrigiert = await aktualisiereAnfrage(a.$id, { erstattetCent: bisher + erstattet })
|
||||
return { anfrage: korrigiert, stornoRechnung, erstattet }
|
||||
}
|
||||
}
|
||||
return { anfrage, stornoRechnung, erstattet }
|
||||
})
|
||||
await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: r.erstattet, grund }))
|
||||
await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: r.erstattet, grund: text(grund, 500) }))
|
||||
return { anfrage: r.anfrage, stornoRechnung: r.stornoRechnung }
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function stelleAus(ctx, anfrage, { art = 'rechnung', bezugNummer, n
|
||||
personen: anfrage.personen,
|
||||
posten,
|
||||
summen,
|
||||
steuerart: (anfrage.summen.ust || []).length === 0 ? 'kleinunternehmer' : 'regel',
|
||||
steuerart: anfrage.summen.steuerart ?? ((anfrage.summen.ust || []).length === 0 ? 'kleinunternehmer' : 'regel'),
|
||||
zahlart: anfrage.zahlart || '',
|
||||
bezugNummer: bezugNummer ?? '',
|
||||
anfrageNummer: anfrage.nummer,
|
||||
|
||||
@@ -144,3 +144,9 @@ export async function listeRechnungen(featureId) {
|
||||
|
||||
/** Alle Anfragen (aller Kunden) mit einem Status; fuer den Fristen-Job. */
|
||||
export const listeAnfragenAlle = (status) => liste('ukAnfragen', [Query.equal('status', status)])
|
||||
|
||||
/** Vorhandene Rechnung einer Anfrage zu einer Art ('rechnung' | 'storno'); fuer idempotente Wiederholung nach Teilfehlern. */
|
||||
export async function findeRechnung(anfrageId, art) {
|
||||
const alle = await liste('ukRechnungen', [Query.equal('anfrageId', anfrageId)])
|
||||
return alle.filter((r) => r.art === art).sort((a, b) => String(a.nummer).localeCompare(String(b.nummer)))[0] ?? null
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Stub: Task 7 ersetzt den Inhalt. Erstattung ueber Stripe Connect.
|
||||
import { UkFehler } from './fehler.js'
|
||||
|
||||
export async function erstatte(ctx, anfrage, betragCent, { now } = {}) {
|
||||
export async function erstatte(ctx, anfrage, betragCent, { now, idempotencyKey } = {}) {
|
||||
if (!ctx?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus')
|
||||
return { erstattetCent: betragCent }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const { istFrei } = await import('../server/services/unterkunft/belegung.js')
|
||||
|
||||
after(() => aw.close())
|
||||
const NOW = new Date('2026-11-01T12:00:00Z')
|
||||
const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', onlineZahlung: false } }
|
||||
const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', strasse: 'Str 1', plz: '10115', ort: 'Berlin', steuernummer: '12/345', onlineZahlung: false } }
|
||||
const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo' }
|
||||
const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' }
|
||||
|
||||
@@ -210,3 +210,70 @@ test('Uebergaenge', () => {
|
||||
assert.equal(ab.darfUebergang('bezahlt', 'storniert'), true)
|
||||
assert.equal(ab.darfUebergang('abgelehnt', 'neu'), false)
|
||||
})
|
||||
|
||||
test('Buchung: Wiederholung nach Fehler beim Anfrage-Update gibt genau eine Rechnung, gleiche Nummer', async () => {
|
||||
const { id, token } = await freigegeben()
|
||||
const opt = { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }
|
||||
aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 })
|
||||
await assert.rejects(ab.bucheVerbindlich(ctx, id, token, opt))
|
||||
assert.equal(aw.docs('ukRechnungen').size, 1)
|
||||
const nr = [...aw.docs('ukRechnungen').values()][0].nummer
|
||||
const r = await ab.bucheVerbindlich(ctx, id, token, opt)
|
||||
assert.equal(aw.docs('ukRechnungen').size, 1)
|
||||
assert.equal(r.rechnung.nummer, nr)
|
||||
assert.equal(r.anfrage.status, 'gebucht')
|
||||
})
|
||||
|
||||
test('Buchung: unvollstaendige Einstellungen -> 409, nichts geschrieben', async () => {
|
||||
const { id, token } = await freigegeben()
|
||||
const bad = { ...ctx, einstellungen: { ...ctx.einstellungen, steuernummer: '', ustId: '' } }
|
||||
assert.equal(await fehlerCode(ab.bucheVerbindlich(bad, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })), '409 einstellungen_unvollstaendig')
|
||||
assert.equal(aw.docs('ukRechnungen').size, 0)
|
||||
assert.equal((await sp.ladeAnfrage(id)).status, 'freigegeben')
|
||||
})
|
||||
|
||||
test('Storno: Erstattung scheitert -> Rollback, Wiederholung erstattet genau einmal', async () => {
|
||||
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')
|
||||
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.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')
|
||||
})
|
||||
|
||||
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 })
|
||||
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)
|
||||
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('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()
|
||||
const { token } = await ab.gibFrei(ctx, a.$id, { posten: [{ text: 'X', menge: 1, einzelCent: 5000, ustProzent: 0 }], now: NOW })
|
||||
const r = await ab.bucheVerbindlich(ctx, a.$id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
|
||||
assert.equal(r.rechnung.inhalt.steuerart, 'kleinunternehmer')
|
||||
const b = await anlegen('2026-12-10', '2026-12-12')
|
||||
aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: 'f1', steuerart: 'regel' })
|
||||
const f = await ab.gibFrei(ctx, b.$id, { posten: [{ text: 'X', menge: 1, einzelCent: 5000, ustProzent: 0 }], now: NOW })
|
||||
const r2 = await ab.bucheVerbindlich(ctx, b.$id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
|
||||
assert.equal(r2.rechnung.inhalt.steuerart, 'regel')
|
||||
})
|
||||
Reference in new issue
Block a user