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
@@ -2,20 +2,21 @@ import { randomInt } from 'node:crypto'
|
||||
import express, { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { analyseDomains, analyseHosts } from '../services/analyse.js'
|
||||
import { legeAnfrageAn } from '../services/unterkunft/ablauf.js'
|
||||
import { bucheVerbindlich, legeAnfrageAn } from '../services/unterkunft/ablauf.js'
|
||||
import { belegteBereiche } from '../services/unterkunft/belegung.js'
|
||||
import { istTag } from '../services/unterkunft/datum.js'
|
||||
import { istTag, naechte } from '../services/unterkunft/datum.js'
|
||||
import { UkFehler } from '../services/unterkunft/fehler.js'
|
||||
import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js'
|
||||
import { ID_RE, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
|
||||
import { formToken, ipHashVon, pruefeFormToken } from '../services/unterkunft/token.js'
|
||||
import { ID_RE, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
|
||||
import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js'
|
||||
import { formToken, ipHashVon, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js'
|
||||
import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js'
|
||||
import { heute } from '../services/termine/zeit.js'
|
||||
|
||||
/**
|
||||
* Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md).
|
||||
* Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js).
|
||||
* Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage. Task 7 ergaenzt die Buchungsseite.
|
||||
* Konfiguration, Belegung, Anfrage (Task 6); Buchungsseite GET|POST /:siteKey/buchung/:id, POST …/zahlung, GET …/rechnung.pdf (Task 7).
|
||||
*
|
||||
* - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals
|
||||
* (config.publicBaseUrl) oder gleicher Herkunft; sonst 403.
|
||||
@@ -70,7 +71,7 @@ function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
|
||||
res.status(status)
|
||||
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
|
||||
if (status === 204) return res.end()
|
||||
const inhalt = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
const inhalt = Buffer.isBuffer(body) || typeof body === 'string' ? body : JSON.stringify(body)
|
||||
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
|
||||
return res.end(inhalt)
|
||||
}
|
||||
@@ -189,8 +190,21 @@ router.options(
|
||||
}),
|
||||
)
|
||||
|
||||
router.options(
|
||||
['/:siteKey/buchung/:id', '/:siteKey/buchung/:id/zahlung'],
|
||||
route(async (req, now, cors) => {
|
||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
||||
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
|
||||
if (!ctx) return vorabantwort('*')
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
return vorabantwort(cors.erlaubt)
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Konfiguration
|
||||
|
||||
const akzentfarbe = (e) => (AKZENT_RE.test(e?.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT)
|
||||
|
||||
const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' })
|
||||
|
||||
function datenschutzUrl(project) {
|
||||
@@ -213,7 +227,7 @@ router.get(
|
||||
body: {
|
||||
aktiv: true,
|
||||
anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) },
|
||||
akzentfarbe: AKZENT_RE.test(e.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT,
|
||||
akzentfarbe: akzentfarbe(e),
|
||||
formToken: formToken(req.params.siteKey, now),
|
||||
unterkuenfte: unterkuenfte.map((u) => ({
|
||||
id: u.$id,
|
||||
@@ -328,6 +342,156 @@ router.use((err, req, res, next) => {
|
||||
return next(err)
|
||||
})
|
||||
|
||||
// Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf
|
||||
// ---------------------------------------------------------------- Buchungsseite (Task 7)
|
||||
|
||||
const BUCHUNG = { mindestdauer: true }
|
||||
const ART_RE = /^(rechnung|storno)$/
|
||||
|
||||
/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion und falsches Token dieselbe 404-Antwort. */
|
||||
async function ladeMitToken(ctx, id, token) {
|
||||
if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden()
|
||||
const a = await ladeAnfrage(id)
|
||||
if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token)) throw nichtGefunden()
|
||||
return a
|
||||
}
|
||||
|
||||
/** Kontext der Buchungsseite: Lesen je IP begrenzt; Schreiben nur von erlaubter Herkunft und je IP als Buchungsversuch gezaehlt. */
|
||||
async function buchungsKontext(req, now, cors, { schreiben }) {
|
||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
||||
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
|
||||
if (!ctx) throw nichtGefunden()
|
||||
if (schreiben) {
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
|
||||
} else if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const onlineMoeglich = (ctx) => Boolean(ctx.einstellungen?.onlineZahlung && ctx.einstellungen?.stripeAccountId)
|
||||
const wort = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '')
|
||||
|
||||
async function buchungAntwort(ctx, a) {
|
||||
const [u, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null])
|
||||
const e = ctx.einstellungen ?? {}
|
||||
const adr = a.rechnungsadresse
|
||||
return {
|
||||
status: a.status,
|
||||
nummer: a.nummer,
|
||||
unterkunft: { name: u?.name ?? '', checkIn: u?.checkIn ?? '', checkOut: u?.checkOut ?? '' },
|
||||
von: a.von,
|
||||
bis: a.bis,
|
||||
naechte: naechte(a.von, a.bis),
|
||||
personen: a.personen,
|
||||
posten: Array.isArray(a.posten) ? a.posten : [],
|
||||
summen: a.summen ?? null,
|
||||
steuerart: a.summen?.steuerart ?? '',
|
||||
fristAt: a.fristAt ?? '',
|
||||
stornobedingungen: wort(a.stornobedingungenText, 5000),
|
||||
anbieter: {
|
||||
firma: wort(e.firma),
|
||||
inhaber: wort(e.inhaber),
|
||||
strasse: wort(e.strasse),
|
||||
plz: wort(e.plz),
|
||||
ort: wort(e.ort),
|
||||
email: wort(e.email),
|
||||
telefon: wort(e.telefon),
|
||||
datenschutzUrl: datenschutzUrl(ctx.project),
|
||||
},
|
||||
akzentfarbe: akzentfarbe(e),
|
||||
gast: { name: wort(a.gast?.name), firma: wort(a.gast?.firma) },
|
||||
...(adr && typeof adr === 'object'
|
||||
? { rechnungsadresse: { name: wort(adr.name), firma: wort(adr.firma), strasse: wort(adr.strasse), plz: wort(adr.plz), ort: wort(adr.ort), land: wort(adr.land), ustId: wort(adr.ustId, 20) } }
|
||||
: {}),
|
||||
zahlarten: onlineMoeglich(ctx) ? ['vorort', 'online'] : ['vorort'],
|
||||
...(a.zahlart ? { zahlart: a.zahlart } : {}),
|
||||
bezahlt: a.status === 'bezahlt',
|
||||
...(rechnung?.nummer ? { rechnungNummer: rechnung.nummer } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:siteKey/buchung/:id',
|
||||
route(
|
||||
async (req, now, cors) => {
|
||||
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 })
|
||||
return { body: await buchungAntwort(ctx, a) }
|
||||
},
|
||||
BUCHUNG,
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:siteKey/buchung/:id',
|
||||
route(
|
||||
async (req, now, cors) => {
|
||||
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
|
||||
const a = await ladeMitToken(ctx, req.params.id, req.query.t)
|
||||
const b = req.body
|
||||
if (!istObjekt(b)) throw ungueltig()
|
||||
const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, {
|
||||
rechnungsadresse: istObjekt(b.rechnungsadresse) ? b.rechnungsadresse : {},
|
||||
zahlart: text(b.zahlart),
|
||||
now,
|
||||
})
|
||||
const body = { status: anfrage.status, rechnungNummer: rechnung?.nummer ?? '' }
|
||||
if (anfrage.status === 'gebucht' && anfrage.zahlart === 'online') {
|
||||
try {
|
||||
body.zahlungUrl = (await checkoutFuer(ctx, anfrage, { now, token: req.query.t })).url
|
||||
} catch (err) {
|
||||
// Gebucht ist gebucht: die Seite holt die Zahlung ueber POST .../zahlung nach
|
||||
console.warn(`[publicUnterkunft] Zahlungssitzung nicht angelegt: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
return { status: 200, body }
|
||||
},
|
||||
BUCHUNG,
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:siteKey/buchung/:id/zahlung',
|
||||
route(
|
||||
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 })
|
||||
if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig')
|
||||
try {
|
||||
return { body: await checkoutFuer(ctx, a, { now, token: req.query.t }) }
|
||||
} catch (err) {
|
||||
if (err instanceof UkFehler) throw err
|
||||
throw new UkFehler(502, 'zahlung_fehlgeschlagen')
|
||||
}
|
||||
},
|
||||
BUCHUNG,
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/:siteKey/buchung/:id/rechnung.pdf',
|
||||
route(
|
||||
async (req, now, cors) => {
|
||||
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
|
||||
const a = await ladeMitToken(ctx, req.params.id, req.query.t)
|
||||
const art = typeof req.query.art === 'string' ? req.query.art : 'rechnung'
|
||||
if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' })
|
||||
const dok = await findeRechnung(a.$id, art)
|
||||
if (!dok) throw nichtGefunden()
|
||||
let modul
|
||||
try {
|
||||
modul = await import('../services/unterkunft/rechnungPdf.js')
|
||||
} catch {
|
||||
return { status: 501, body: { fehler: 'nicht_verfuegbar' } }
|
||||
}
|
||||
const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum })
|
||||
const dateiname = `${art === 'storno' ? 'Stornorechnung' : 'Rechnung'}-${String(dok.nummer).replace(/[^A-Za-z0-9_-]/g, '')}.pdf`
|
||||
return { body: pdf, typ: 'application/pdf', headers: { 'Content-Disposition': `inline; filename="${dateiname}"` } }
|
||||
},
|
||||
BUCHUNG,
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -3,7 +3,11 @@ 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'
|
||||
|
||||
/**
|
||||
* Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto)
|
||||
@@ -13,9 +17,69 @@ import { unterkunftModus } from '../services/unterkunft/schalter.js'
|
||||
|
||||
const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/
|
||||
|
||||
const text = (v) => (typeof v === 'string' ? v : '')
|
||||
const ID_OK = /^[A-Za-z0-9_-]{1,36}$/
|
||||
|
||||
/** Kontext fuer ablauf.js: voll, solange die Funktion existiert; sonst der Mindestkontext (eine eingegangene Zahlung geht nie verloren). */
|
||||
async function kontextFuer(anfrage) {
|
||||
const voll = await ladeFuerProjekt(anfrage.customerId, anfrage.projectId).catch(() => null)
|
||||
if (voll && voll.featureId === anfrage.featureId) return voll
|
||||
return {
|
||||
featureId: anfrage.featureId,
|
||||
customerId: anfrage.customerId ?? '',
|
||||
projectId: anfrage.projectId ?? '',
|
||||
siteKey: '',
|
||||
besitzerEmail: '',
|
||||
einstellungen: await ladeEinstellungen(anfrage.featureId),
|
||||
project: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Anfrage zum Ereignis: nur wenn das Konto des Ereignisses das Konto der Funktion ist (sonst Fremdereignis, ohne Wirkung). */
|
||||
async function anfrageZu(event, anfrageId) {
|
||||
if (!ID_OK.test(text(anfrageId))) return null
|
||||
const anfrage = await ladeAnfrage(anfrageId)
|
||||
if (!anfrage) return null
|
||||
const ctx = await kontextFuer(anfrage)
|
||||
if (!ctx.einstellungen?.stripeAccountId || ctx.einstellungen.stripeAccountId !== event.account) return null
|
||||
return { anfrage, ctx }
|
||||
}
|
||||
|
||||
async function zahlungEingegangen(event, session, now) {
|
||||
const treffer = await anfrageZu(event, session.metadata?.anfrageId)
|
||||
if (!treffer) return
|
||||
const { anfrage, ctx } = treffer
|
||||
const brutto = anfrage.summen?.bruttoCent
|
||||
if (Number.isInteger(session.amount_total) && Number.isInteger(brutto) && session.amount_total !== brutto) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function erstattungGebucht(event, charge) {
|
||||
const treffer = await anfrageZu(event, charge.metadata?.anfrageId)
|
||||
if (!treffer) return
|
||||
const { anfrage } = treffer
|
||||
if (anfrage.stripePaymentIntentId && typeof charge.payment_intent === 'string' && charge.payment_intent !== anfrage.stripePaymentIntentId) return
|
||||
const stand = charge.amount_refunded
|
||||
if (!Number.isInteger(stand) || stand <= 0) return
|
||||
await withLocks(['unterkunft:' + anfrage.unterkunftId], async () => {
|
||||
const frisch = await ladeAnfrage(anfrage.$id)
|
||||
if (frisch && stand > (frisch.erstattetCent || 0)) await aktualisiereAnfrage(frisch.$id, { erstattetCent: stand })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten. Geruest (Task 5): Task 8/9 fuellen es (Zahlung eingegangen,
|
||||
* Sitzung abgelaufen, Erstattung gebucht). Bis dahin ohne Wirkung.
|
||||
* Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten (idempotent; Fehler => 500, Stripe wiederholt).
|
||||
* checkout.session.completed (bezahlt) und async_payment_succeeded: Buchung als bezahlt markieren. charge.refunded: erstattetCent auf
|
||||
* mindestens den Stand der Zahlung heben (heilt einen Absturz zwischen Stripe-Erfolg und Schreiben).
|
||||
* @param {object} event Stripe-Ereignis (signiert und geprueft)
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
@@ -23,10 +87,27 @@ export async function verarbeiteUkEreignis(event, now = new Date()) {
|
||||
return ereignisHandler(event, now)
|
||||
}
|
||||
|
||||
// Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen); Task 8/9 ersetzen den Standard durch die echte Verarbeitung
|
||||
let ereignisHandler = async () => {}
|
||||
async function verarbeite(event, now) {
|
||||
const objekt = event?.data?.object
|
||||
if (!objekt || typeof objekt !== 'object') return
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
if (objekt.metadata?.wkUk === '1' && objekt.payment_status === 'paid') await zahlungEingegangen(event, objekt, now)
|
||||
break
|
||||
case 'checkout.session.async_payment_succeeded':
|
||||
if (objekt.metadata?.wkUk === '1') await zahlungEingegangen(event, objekt, now)
|
||||
break
|
||||
case 'charge.refunded':
|
||||
await erstattungGebucht(event, objekt)
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
let ereignisHandler = verarbeite
|
||||
// Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen)
|
||||
export function setzeUkEreignisHandlerFuerTests(fn) {
|
||||
ereignisHandler = fn ?? (async () => {})
|
||||
ereignisHandler = fn ?? verarbeite
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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