import { config } from '../../config.js' import { withLocks } from '../keyedLock.js' import { checkoutAnlegen, erstatten, erstattungenZurZahlung, sessionAblaufen, sessionHolen } from '../shop/connect.js' import { formatTag } from './datum.js' import { UkFehler } from './fehler.js' import { aktualisiereAnfrage, ladeAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js' import { markiereBezahlt } from './ablauf.js' /** * Stripe-Zahlung und Erstattung des Buchungskalenders (Direct Charges auf dem Konto des Vermieters, wie der Webshop). * Die Sitzung traegt metadata { wkUk:'1', anfrageId }; dieselben Metadaten (plus webklar:'unterkunft') stehen an der Zahlung, * damit charge.refunded dem Buchungskalender zugeordnet werden kann (routes/shopWebhook.js). */ export const _aufrufe = [] /** Aufrufprotokoll nur im Test (UK_TESTPROTOKOLL, setzt useTestEnv); in Produktion waechst nichts. */ const testProtokoll = () => process.env.NODE_ENV === 'test' || process.env.UK_TESTPROTOKOLL === '1' const SITZUNG_MINUTEN = 31 // Stripe verlangt mindestens 30 Minuten Restlaufzeit beim Anlegen const KOERPER_MAX = 250 const sekunden = (now) => Math.floor((now instanceof Date ? now.getTime() : Number(now)) / 1000) /** 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. 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) * @returns {Promise<{ url: string }>} */ 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 (typeof token !== 'string' || token === '') throw new UkFehler(400, 'ungueltig') 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'}`) } } /** Stimmt eine bezahlte Sitzung mit der Buchung ueberein: Betrag = Brutto der Anfrage (Cent), Waehrung eur? Sonst gilt sie nicht als Bezahlung. */ export function zahlungPasst(anfrage, session) { const brutto = anfrage?.summen?.bruttoCent return Number.isInteger(brutto) && session?.amount_total === brutto && String(session?.currency ?? '').toLowerCase() === 'eur' } /** 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 { const bezahlt = await markiereBezahlt(ctx, anfrageId, { via: 'stripe', paymentIntentId: pi, now }) // gleichzeitige Zweitzahlung: zwischen dem Lesen oben und der Sperre hat eine andere Zahlung gebucht (markiereBezahlt gibt dann // die bezahlte Anfrage ohne Fehler zurueck) -> die Zahlung, die nicht gebucht wurde, voll erstatten if (pi && bezahlt?.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi) return bezahlt } 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. erzwingen: ohne Pause (Rueckkehr von Stripe). */ 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 if (!zahlungPasst(anfrage, session)) { console.warn(`[unterkunft] Abgleich: Betrag oder Währung weicht ab (Buchung ${anfrage.nummer}), Zahlung wird erstattet`) await erstatteUeberzaehligeZahlung(ctx, pi) return anfrage } 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)}`) } return anfrage } async function erstatteEcht(ctx, anfrage, betragCent, { idempotencyKey } = {}) { const accountId = ctx?.einstellungen?.stripeAccountId if (!accountId) throw new UkFehler(409, 'online_aus') if (!anfrage?.stripePaymentIntentId) throw new UkFehler(409, 'status_ungueltig') const refund = await erstatten(accountId, { paymentIntent: anfrage.stripePaymentIntentId, betragCent, grund: `Stornierung Buchung ${anfrage.nummer ?? ''}`.trim(), idempotencyKey, webklar: 'unterkunft', }) return { erstattetCent: Number.isInteger(refund?.amount) ? refund.amount : betragCent } } let erstatter = erstatteEcht /** Nur fuer Tests: ersetzt die Erstattung (null stellt die echte wieder her). */ export function setzeErstattungFuerTests(fn) { erstatter = typeof fn === 'function' ? fn : erstatteEcht } export async function erstatte(ctx, anfrage, betragCent, { now, idempotencyKey } = {}) { if (testProtokoll()) _aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey }) return erstatter(ctx, anfrage, betragCent, { now, idempotencyKey }) } async function liveErstattetEcht(ctx, anfrage) { const accountId = ctx?.einstellungen?.stripeAccountId if (!accountId || !anfrage?.stripePaymentIntentId) return 0 const liste = await erstattungenZurZahlung(accountId, anfrage.stripePaymentIntentId) return liste.filter((r) => r?.status !== 'failed' && r?.status !== 'canceled').reduce((s, r) => s + (Number.isInteger(r?.amount) ? r.amount : 0), 0) } let liveLeser = liveErstattetEcht /** Nur fuer Tests: ersetzt die Abfrage des bei Stripe erstatteten Betrags (null stellt die echte wieder her). */ export function setzeLiveErstattetFuerTests(fn) { liveLeser = typeof fn === 'function' ? fn : liveErstattetEcht } /** Summe der bei Stripe tatsaechlich erstatteten Betraege dieser Zahlung (0 ohne Konto oder Zahlung); Stripe-Fehler gehen nach oben. */ export async function liveErstattet(ctx, anfrage) { return liveLeser(ctx, anfrage) }