import { config } from '../../config.js' import { checkoutAnlegen, erstatten, sessionHolen } from '../shop/connect.js' import { formatTag } from './datum.js' import { UkFehler } from './fehler.js' import { aktualisiereAnfrage, 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 = [] 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) /** 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}` } /** * Checkout-Sitzung fuer eine gebuchte Online-Zahlung anlegen. * @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 (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 } : {}), } 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 } } /** * 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. */ export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date() } = {}) { const accountId = ctx?.einstellungen?.stripeAccountId if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online' || !anfrage.stripeSessionId || !accountId) return anfrage try { const session = await sessionHolen(accountId, anfrage.stripeSessionId) 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 }) } } 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 } = {}) { _aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey }) return erstatter(ctx, anfrage, betragCent, { now, idempotencyKey }) }