diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index b434860..38223f1 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -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). */ diff --git a/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js index 26f9bb1..d73ed70 100644 --- a/server/services/unterkunft/zahlung.js +++ b/server/services/unterkunft/zahlung.js @@ -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 } } diff --git a/test/unterkunftAblauf.test.js b/test/unterkunftAblauf.test.js index 25441bf..fc9cc55 100644 --- a/test/unterkunftAblauf.test.js +++ b/test/unterkunftAblauf.test.js @@ -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()