Buchungskalender: Storno und Erstattung getrennt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7650fe883
commit
1d2ca2791d
3 files changed
+110
-53
No files matched your search
@@ -204,12 +204,16 @@ export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse
|
||||
}
|
||||
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 rechnungEinmal(ctx, vorlaeufig, 'rechnung', { now })
|
||||
// Existiert die Rechnung schon (Wiederholung nach Teilfehler), gelten ihre Adresse und Zahlart weiter.
|
||||
const vorhanden = await findeRechnung(a.$id, 'rechnung')
|
||||
const endgAdresse = vorhanden?.inhalt?.empfaenger ?? adresse
|
||||
const endgZahlart = vorhanden?.inhalt?.zahlart || zahlart
|
||||
const vorlaeufig = { ...a, rechnungsadresse: endgAdresse, zahlart: endgZahlart }
|
||||
const rechnung = vorhanden ?? (await rechnungEinmal(ctx, vorlaeufig, 'rechnung', { now }))
|
||||
const anfrage = await aktualisiereAnfrage(a.$id, {
|
||||
status: 'gebucht',
|
||||
rechnungsadresse: adresse,
|
||||
zahlart,
|
||||
rechnungsadresse: endgAdresse,
|
||||
zahlart: endgZahlart,
|
||||
rechnungId: rechnung.$id,
|
||||
gebuchtAt: iso(ms(now)),
|
||||
})
|
||||
@@ -237,49 +241,59 @@ export async function markiereBezahlt(ctx, anfrageId, { via, paymentIntentId, no
|
||||
return r.anfrage
|
||||
}
|
||||
|
||||
function pruefeErstattung(a, betragCent) {
|
||||
const bisher = a.erstattetCent || 0
|
||||
const brutto = a.summen?.bruttoCent
|
||||
if (a.bezahltVia !== 'stripe') throw new UkFehler(409, 'status_ungueltig', { erstattungCent: 'Erstattung ist nur bei Online-Zahlung möglich.' })
|
||||
if (!Number.isInteger(betragCent) || betragCent <= 0 || !Number.isInteger(brutto) || betragCent > brutto - bisher) {
|
||||
throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Der Betrag ist ungültig oder zu hoch.' })
|
||||
}
|
||||
return bisher
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstattung einer stornierten, online bezahlten Buchung. erstattetCent wird ERST NACH dem Stripe-Erfolg geschrieben, ohne
|
||||
* Rollback. Ein Absturz zwischen Stripe-Erfolg und Schreiben heilt sich durch Wiederholung mit demselben idempotencyKey
|
||||
* (Stripe liefert dieselbe Erstattung) und durch den charge.refunded-Webhook (Task 7).
|
||||
*/
|
||||
export async function erstatteBetrag(ctx, anfrageId, { betragCent, now = new Date() } = {}) {
|
||||
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
|
||||
return withLocks(withLocksKey(pre.unterkunftId), async () => {
|
||||
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
|
||||
if (a.status !== 'storniert') throw new UkFehler(409, 'status_ungueltig')
|
||||
const bisher = pruefeErstattung(a, betragCent)
|
||||
const res = await zahlung.erstatte(ctx, a, betragCent, { now, idempotencyKey: `uk-erstattung-${anfrageId}-${bisher + betragCent}` })
|
||||
return aktualisiereAnfrage(a.$id, { erstattetCent: bisher + res.erstattetCent })
|
||||
})
|
||||
}
|
||||
|
||||
export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0, now = new Date() } = {}) {
|
||||
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Ungültiger Betrag.' })
|
||||
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
|
||||
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
|
||||
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
|
||||
if (a.status === 'storniert') return { anfrage: a, stornoRechnung: await findeRechnung(a.$id, 'storno'), neu: false }
|
||||
if (!darfUebergang(a.status, 'storniert')) throw new UkFehler(409, 'status_ungueltig')
|
||||
const brutto = a.summen?.bruttoCent
|
||||
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Ungültiger Betrag.' })
|
||||
const bisher = a.erstattetCent || 0
|
||||
if (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.' })
|
||||
}
|
||||
if (erstattungCent > 0) pruefeErstattung(a, erstattungCent)
|
||||
let stornoRechnung = null
|
||||
if (a.rechnungId) {
|
||||
const orig = await ladeRechnung(a.rechnungId)
|
||||
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 + 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 }
|
||||
const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) })
|
||||
return { anfrage, stornoRechnung, neu: true }
|
||||
})
|
||||
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 }
|
||||
if (r.neu) await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: erstattungCent, grund: text(grund, 500) }))
|
||||
let anfrage = r.anfrage
|
||||
if (erstattungCent > 0) {
|
||||
try {
|
||||
anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now })
|
||||
} catch (err) {
|
||||
if (err instanceof UkFehler && err.status < 500 && err.code !== 'online_aus') throw err
|
||||
throw new UkFehler(502, 'erstattung_fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
return { anfrage, stornoRechnung: r.stornoRechnung }
|
||||
}
|
||||
|
||||
/** Alle freigegebenen Anfragen mit abgelaufener Frist auf 'abgelaufen' setzen (Minutenjob, alle Unterkuenfte). */
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Stub: Task 7 ersetzt den Inhalt. Erstattung ueber Stripe Connect.
|
||||
import { UkFehler } from './fehler.js'
|
||||
|
||||
export const _aufrufe = []
|
||||
|
||||
export async function erstatte(ctx, anfrage, betragCent, { now, idempotencyKey } = {}) {
|
||||
_aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey })
|
||||
if (!ctx?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus')
|
||||
return { erstattetCent: betragCent }
|
||||
}
|
||||
Reference in new issue
Block a user