Buchungskalender: Buchungsseite, Stripe-Zahlung und Webhook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40a366b8ae
commit
ca0f762272
6 files changed
+679
-18
No files matched your search
@@ -139,7 +139,7 @@ export async function sessionAblaufen(accountId, id) {
|
||||
}
|
||||
|
||||
/** Erstattung auf dem Haendlerkonto; ohne betragCent wird voll erstattet. */
|
||||
export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey } = {}) {
|
||||
export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey, webklar = 'shop' } = {}) {
|
||||
pruefeKonto(accountId)
|
||||
if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.')
|
||||
if (betragCent !== undefined && (!Number.isInteger(betragCent) || betragCent <= 0)) throw fehler(400, 'Ungültiger Erstattungsbetrag.')
|
||||
@@ -149,7 +149,7 @@ export async function erstatten(accountId, { paymentIntent, betragCent, grund, i
|
||||
payment_intent: paymentIntent,
|
||||
...(betragCent !== undefined ? { amount: betragCent } : {}),
|
||||
reason: 'requested_by_customer',
|
||||
metadata: { webklar: 'shop', ...(text ? { grund: text } : {}) },
|
||||
metadata: { webklar, ...(text ? { grund: text } : {}) },
|
||||
},
|
||||
{ stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}) }
|
||||
)
|
||||
|
||||
@@ -1,10 +1,108 @@
|
||||
// Stub: Task 7 ersetzt den Inhalt. Erstattung ueber Stripe Connect.
|
||||
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 })
|
||||
if (!ctx?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus')
|
||||
return { erstattetCent: betragCent }
|
||||
return erstatter(ctx, anfrage, betragCent, { now, idempotencyKey })
|
||||
}
|
||||
Reference in new issue
Block a user