Buchungskalender: Zahlung – Sitzung wiederverwenden, Zahlung nach Storno erstatten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:23:06 +00:00
1 parent ca0f762272
commit a94b50fbc1
5 files changed
+233 -52

No files matched your search

+4 -3
View File
@@ -416,7 +416,7 @@ router.get(
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
let a = await ladeMitToken(ctx, req.params.id, req.query.t)
// Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen
a = await schliesseAbWennBezahlt(ctx, a, { now })
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' })
return { body: await buchungAntwort(ctx, a) }
},
BUCHUNG,
@@ -457,7 +457,7 @@ router.post(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
let a = await ladeMitToken(ctx, req.params.id, req.query.t)
a = await schliesseAbWennBezahlt(ctx, a, { now })
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true })
if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig')
try {
return { body: await checkoutFuer(ctx, a, { now, token: req.query.t }) }
@@ -483,7 +483,8 @@ router.get(
let modul
try {
modul = await import('../services/unterkunft/rechnungPdf.js')
} catch {
} catch (err) {
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err
return { status: 501, body: { fehler: 'nicht_verfuegbar' } }
}
const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum })
+2 -9
View File
@@ -3,11 +3,10 @@ import { config } from '../config.js'
import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
import { withLocks } from '../services/keyedLock.js'
import { statusAusKonto } from '../services/shop/connect.js'
import { markiereBezahlt } from '../services/unterkunft/ablauf.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { ladeFuerProjekt } from '../services/unterkunft/kontext.js'
import { unterkunftModus } from '../services/unterkunft/schalter.js'
import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen } from '../services/unterkunft/speicher.js'
import { verbucheZahlung } from '../services/unterkunft/zahlung.js'
/**
* Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto)
@@ -54,13 +53,7 @@ async function zahlungEingegangen(event, session, now) {
console.warn(`[unterkunft] Webhook: Betrag weicht ab (Buchung ${anfrage.nummer})`)
}
const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id
try {
await markiereBezahlt(ctx, anfrage.$id, { via: 'stripe', paymentIntentId: pi || undefined, now })
} catch (err) {
// Buchung inzwischen storniert o. Ae.: Stripe nicht endlos wiederholen lassen, der Vermieter erstattet von Hand
if (err instanceof UkFehler && err.status === 409) console.warn(`[unterkunft] Webhook: Zahlung fuer Buchung ${anfrage.nummer} im Status ${anfrage.status} eingegangen`)
else throw err
}
await verbucheZahlung(ctx, anfrage.$id, pi, { now })
}
async function erstattungGebucht(event, charge) {
+1
View File
@@ -283,6 +283,7 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0
const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) })
return { anfrage, stornoRechnung, neu: true }
})
if (r.neu) await sende(() => zahlung.beendeOffeneZahlung(ctx, r.anfrage))
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) {
+104 -31
View File
@@ -1,8 +1,9 @@
import { config } from '../../config.js'
import { checkoutAnlegen, erstatten, sessionHolen } from '../shop/connect.js'
import { withLocks } from '../keyedLock.js'
import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from '../shop/connect.js'
import { formatTag } from './datum.js'
import { UkFehler } from './fehler.js'
import { aktualisiereAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js'
import { aktualisiereAnfrage, ladeAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js'
import { markiereBezahlt } from './ablauf.js'
/**
@@ -18,16 +19,17 @@ const KOERPER_MAX = 250
const sekunden = (now) => Math.floor((now instanceof Date ? now.getTime() : Number(now)) / 1000)
/** SEPA nur, wenn der gespeicherte Kontostatus es ausdruecklich meldet. */
const sepaAktiv = (status) => status?.sepa_debit === true || status?.sepa_debit_payments === 'active'
/** Buchungsseite des Gastes (Rueckkehr von Stripe); das Token liegt nur beim Gast, nicht in der Datenbank. */
export function buchungsUrl(ctx, anfrageId, token, zusatz = '') {
return `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrageId}?t=${encodeURIComponent(token)}${zusatz}`
}
const lockKey = (a) => ['unterkunft:' + a.unterkunftId]
/**
* Checkout-Sitzung fuer eine gebuchte Online-Zahlung anlegen.
* Checkout-Sitzung fuer eine gebuchte Online-Zahlung. Unter der Sperre der Unterkunft wird die Anfrage frisch gelesen (nach einem Storno
* entsteht keine Sitzung mehr). Eine noch offene, nicht abgelaufene Sitzung der Anfrage wird wiederverwendet (Doppelklick); sonst entsteht
* eine neue mit auf die volle Minute gerundetem Ablauf, sodass Wiederholungen derselben Minute mit dem Idempotency-Key identisch sind.
* @param {object} ctx Kontext mit einstellungen.stripeAccountId und siteKey
* @param {object} anfrage Anfrage im Status gebucht (zahlart online)
* @param {{ now?: Date, token: string }} opt token: das Buchungs-Token des Gastes (fuer die Rueckkehr-Links)
@@ -37,44 +39,115 @@ export async function checkoutFuer(ctx, anfrage, { now = new Date(), token } = {
const einst = ctx?.einstellungen ?? {}
const accountId = einst.stripeAccountId
if (!einst.onlineZahlung || !accountId) throw new UkFehler(409, 'online_aus')
if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online') throw new UkFehler(409, 'status_ungueltig')
if (typeof token !== 'string' || token === '') throw new UkFehler(400, 'ungueltig')
const brutto = anfrage.summen?.bruttoCent
if (!Number.isInteger(brutto) || brutto <= 0) throw new UkFehler(409, 'status_ungueltig')
const [unterkunft, rechnung] = await Promise.all([ladeUnterkunft(anfrage.unterkunftId), anfrage.rechnungId ? ladeRechnung(anfrage.rechnungId) : null])
const name = `Aufenthalt ${unterkunft?.name ?? ''} ${formatTag(anfrage.von)}–${formatTag(anfrage.bis)}${rechnung?.nummer ? `, Rechnung ${rechnung.nummer}` : ''}`.replace(/\s+/g, ' ').slice(0, KOERPER_MAX)
const metadata = { wkUk: '1', anfrageId: anfrage.$id }
const params = {
mode: 'payment',
payment_method_types: sepaAktiv(einst.stripeStatus) ? ['card', 'sepa_debit'] : ['card'],
locale: 'de',
line_items: [{ quantity: 1, price_data: { currency: 'eur', unit_amount: brutto, product_data: { name } } }],
expires_at: sekunden(now) + SITZUNG_MINUTEN * 60,
metadata,
payment_intent_data: { metadata: { ...metadata, webklar: 'unterkunft' } },
success_url: buchungsUrl(ctx, anfrage.$id, token, '&wk_zahlung=ok'),
cancel_url: buchungsUrl(ctx, anfrage.$id, token, '&wk_zahlung=abbruch'),
...(typeof anfrage.gast?.email === 'string' && anfrage.gast.email ? { customer_email: anfrage.gast.email } : {}),
if (!anfrage?.$id) throw new UkFehler(409, 'status_ungueltig')
const nowMs = now instanceof Date ? now.getTime() : Number(now)
return withLocks(lockKey(anfrage), async () => {
const a = await ladeAnfrage(anfrage.$id)
if (!a || a.featureId !== ctx.featureId || a.status !== 'gebucht' || a.zahlart !== 'online') throw new UkFehler(409, 'status_ungueltig')
const brutto = a.summen?.bruttoCent
if (!Number.isInteger(brutto) || brutto <= 0) throw new UkFehler(409, 'status_ungueltig')
if (a.stripeSessionId) {
try {
const alt = await sessionHolen(accountId, a.stripeSessionId)
if (alt?.status === 'open' && alt.url && !(Number(alt.expires_at) * 1000 <= nowMs)) return { url: alt.url }
} catch (err) {
console.warn(`[unterkunft] Alte Zahlungssitzung nicht lesbar: ${err?.name ?? 'Fehler'}`)
}
}
const [unterkunft, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null])
const name = `Aufenthalt ${unterkunft?.name ?? ''} ${formatTag(a.von)}–${formatTag(a.bis)}${rechnung?.nummer ? `, Rechnung ${rechnung.nummer}` : ''}`.replace(/\s+/g, ' ').slice(0, KOERPER_MAX)
const metadata = { wkUk: '1', anfrageId: a.$id }
const minute = Math.floor(nowMs / 60000)
const params = {
mode: 'payment',
payment_method_types: ['card'],
locale: 'de',
line_items: [{ quantity: 1, price_data: { currency: 'eur', unit_amount: brutto, product_data: { name } } }],
expires_at: minute * 60 + SITZUNG_MINUTEN * 60,
metadata,
payment_intent_data: { metadata: { ...metadata, webklar: 'unterkunft' } },
success_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=ok'),
cancel_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=abbruch'),
...(typeof a.gast?.email === 'string' && a.gast.email ? { customer_email: a.gast.email } : {}),
}
const session = await checkoutAnlegen(accountId, params, { idempotencyKey: `uk-zahlung-${a.$id}-${minute}` })
if (!session.url) throw new UkFehler(502, 'zahlung_fehlgeschlagen')
await aktualisiereAnfrage(a.$id, { stripeSessionId: session.id })
return { url: session.url }
})
}
/** Nach dem Storno: eine noch offene, unbezahlte Checkout-Sitzung beenden (Fehler nur loggen: sie laeuft ohnehin in 31 Minuten ab). */
export async function beendeOffeneZahlung(ctx, anfrage) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (!anfrage?.stripeSessionId || !accountId || anfrage.bezahltAt) return
try {
await sessionAblaufen(accountId, anfrage.stripeSessionId)
} catch (err) {
console.warn(`[unterkunft] Offene Zahlungssitzung nicht beendet: ${err?.name ?? 'Fehler'}`)
}
const minute = Math.floor((now instanceof Date ? now.getTime() : Number(now)) / 60000)
const session = await checkoutAnlegen(accountId, params, { idempotencyKey: `uk-zahlung-${anfrage.$id}-${minute}` })
if (!session.url) throw new UkFehler(502, 'zahlung_fehlgeschlagen')
await aktualisiereAnfrage(anfrage.$id, { stripeSessionId: session.id })
return { url: session.url }
}
/** Volle Rueckerstattung einer Zahlung, die zu keiner offenen Buchung mehr passt (nach Storno, Ablauf oder doppelt gezahlt). */
export async function erstatteUeberzaehligeZahlung(ctx, paymentIntentId) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (!accountId || typeof paymentIntentId !== 'string' || paymentIntentId === '') return
await erstatten(accountId, { paymentIntent: paymentIntentId, grund: 'Zahlung ohne offene Buchung', idempotencyKey: `uk-rueck-${paymentIntentId}`, webklar: 'unterkunft' })
console.warn('[unterkunft] Zahlung ohne offene Buchung automatisch erstattet')
}
/**
* Eingegangene Zahlung buchen. Passt sie nicht (Buchung storniert/abgelaufen oder schon mit anderer Zahlung bezahlt), wird sie voll erstattet;
* der Buchungsstatus bleibt dann unveraendert. Idempotent. Gibt die aktuelle Anfrage zurueck.
*/
export async function verbucheZahlung(ctx, anfrageId, paymentIntentId, { now = new Date() } = {}) {
const pi = typeof paymentIntentId === 'string' && paymentIntentId !== '' ? paymentIntentId : undefined
const vorher = await ladeAnfrage(anfrageId)
if (vorher?.status === 'bezahlt') {
if (pi && vorher.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi)
return vorher
}
try {
return await markiereBezahlt(ctx, anfrageId, { via: 'stripe', paymentIntentId: pi, now })
} catch (err) {
if (!(err instanceof UkFehler) || err.status !== 409) throw err
const jetzt = await ladeAnfrage(anfrageId)
// dieselbe Zahlung, die schon gebucht wurde (Wiederholung des Ereignisses nach spaeterem Storno): nichts erstatten
if (pi && jetzt?.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi)
return jetzt ?? vorher
}
}
// Abgleich mit Stripe hoechstens alle 30 Sekunden je Sitzung; abgelaufene Sitzungen nie wieder
const ABGLEICH_MS = 30 * 1000
const ABGLEICH_MAX = 5000
const abgleich = new Map()
export const leereAbgleichFuerTests = () => abgleich.clear()
function merkeAbgleich(schluessel, eintrag) {
abgleich.delete(schluessel)
if (abgleich.size >= ABGLEICH_MAX) abgleich.delete(abgleich.keys().next().value)
abgleich.set(schluessel, eintrag)
}
/**
* Hat der Gast bezahlt, ohne dass der Webhook schon da war? Sitzung bei Stripe nachsehen und bei complete+paid als bezahlt buchen.
* Gibt die (ggf. neue) Anfrage zurueck; Stripe-Fehler werden geloggt und lassen die Anfrage unveraendert.
* Gibt die (ggf. neue) Anfrage zurueck; Stripe-Fehler werden geloggt und lassen die Anfrage unveraendert. erzwingen: ohne Pause (Rueckkehr von Stripe).
*/
export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date() } = {}) {
export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date(), erzwingen = false } = {}) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online' || !anfrage.stripeSessionId || !accountId) return anfrage
const schluessel = `${anfrage.$id}:${anfrage.stripeSessionId}`
const nowMs = now instanceof Date ? now.getTime() : Number(now)
const bekannt = abgleich.get(schluessel)
if (bekannt?.abgelaufen) return anfrage
if (!erzwingen && bekannt && Math.abs(nowMs - bekannt.at) < ABGLEICH_MS) return anfrage
try {
const session = await sessionHolen(accountId, anfrage.stripeSessionId)
merkeAbgleich(schluessel, { at: nowMs, abgelaufen: session?.status === 'expired' })
if (session?.status === 'complete' && session.payment_status === 'paid') {
const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id
return await markiereBezahlt(ctx, anfrage.$id, { via: 'stripe', paymentIntentId: pi || undefined, now })
return await verbucheZahlung(ctx, anfrage.$id, pi, { now })
}
} catch (err) {
console.warn(`[unterkunft] Zahlungsabgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)