Files
Webklar-Kundenbereich/server/services/unterkunft/zahlung.js
T
2026-10-07 14:16:17 +00:00

109 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
}