Buchungskalender: Buchungsseite, Stripe-Zahlung und Webhook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:16:17 +00:00
1 parent 40a366b8ae
commit ca0f762272
6 files changed
+679 -18

No files matched your search

+172 -8
View File
@@ -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
+86 -5
View File
@@ -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
}
/**
+2 -2
View File
@@ -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 } : {}) }
)
+101 -3
View File
@@ -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 })
}
+5
View File
@@ -29,6 +29,11 @@ beforeEach(async () => {
for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id)
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
// Erstattung ohne Stripe: wie der fruehere Stub (die echte Erstattung testet unterkunftBuchung.test.js)
zahlung.setzeErstattungFuerTests(async (c, a, betragCent) => {
if (!c?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus')
return { erstattetCent: betragCent }
})
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung', maxPersonen: 4, mindestNaechte: 2, aktiv: true })
aw.seed('ukPreise', {
$id: 'p1', unterkunftId: 'u1', featureId: 'f1', staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]),
+313
View File
@@ -0,0 +1,313 @@
import { test, after, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Buchungskalender Task 7: Buchungsseite, Stripe-Zahlung, Webhook (Stripe ist der Nachbau)
const NAMEN = ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen']
const aw = await startAppwriteMock({
schema: Object.fromEntries(NAMEN.filter((n) => !['websiteProjects', 'customers'].includes(n)).map((n) => [n, schemaKeys(n)])),
datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const K = await import('../server/services/unterkunft/kontext.js')
const mails = await import('../server/services/unterkunft/mails.js')
const ab = await import('../server/services/unterkunft/ablauf.js')
const sp = await import('../server/services/unterkunft/speicher.js')
const zahlung = await import('../server/services/unterkunft/zahlung.js')
const W = await import('../server/routes/unterkunftWebhook.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js')
const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes })
after(async () => {
await Promise.all([app.close(), aw.close(), st.close()])
})
const SITE = 'abcdefghijklmnop'
const FEATURE = 'f_uk'
const ACCOUNT = 'acct_haendler1'
const NOW = new Date('2026-11-01T12:00:00.000Z')
const BASE = `/api/public/unterkunft/v1/${SITE}`
const ORIGIN = 'https://donau-home.example'
const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' }
const vorher = { ...config.unterkunft }
const einstellungen = (extra = {}) => ({
$id: FEATURE, featureId: FEATURE, firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Str 1', plz: '78576', ort: 'Stetten', email: 'x@y.de',
steuernummer: '12/345', iban: 'DE00', akzentfarbe: '#112233', ...extra,
})
async function seedBasis(einstExtra = {}) {
for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true })
aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' })
aw.seed('ukEinstellungen', einstellungen(einstExtra))
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', maxPersonen: 4, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true })
aw.seed('ukPreise', {
$id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]),
reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'regel',
ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vorher.',
})
K.leereCache()
}
beforeEach(async () => {
Object.assign(config.unterkunft, vorher, { mode: 'on', minAntwortMs: 0 })
resetTermineLimitsForTests()
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
zahlung.setzeErstattungFuerTests(null)
W.setzeUkEreignisHandlerFuerTests(null)
st.calls.length = 0
setzeUhrFuerTests(() => NOW)
await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT })
})
afterEach(() => Object.assign(config.unterkunft, vorher))
/** Freigegebene Anfrage mit Token (wie der Vermieter sie freigibt). */
async function freigegeben(von = '2026-11-10', bis = '2026-11-12') {
const ctx = await K.unterkunftPerSiteKey(SITE, { now: NOW })
const a = await ab.legeAnfrageAn(ctx, { unterkunftId: 'u1', von, bis, personen: 2, gast: { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: '' } }, { now: NOW, ipHash: 'x' })
const { token } = await ab.gibFrei(ctx, a.$id, { now: NOW })
mails._aufrufe.length = 0
return { id: a.$id, token, ctx }
}
const url = (id, t, rest = '') => `${BASE}/buchung/${id}${rest}?t=${encodeURIComponent(t)}`
const post = (pfad, body, headers = { origin: ORIGIN }) => send('POST', pfad, { body, headers })
const arten = () => mails._aufrufe.map((m) => m.art || m.template)
test('GET buchung: falsches Token und unbekannte ID sind identisch 404', async () => {
const { id, token } = await freigegeben()
const falsch = await send('GET', url(id, 'falsch'))
const fremd = await send('GET', url('gibtsnicht', token))
const kaputt = await send('GET', url('a.b', token))
const ohne = await send('GET', `${BASE}/buchung/${id}`)
for (const r of [falsch, fremd, kaputt, ohne]) {
assert.equal(r.status, 404)
assert.deepEqual(r.body, { fehler: 'nicht_gefunden' })
}
})
test('GET buchung: Vertrag, ohne interne Felder, zahlarten ohne online wenn Stripe fehlt', async () => {
await seedBasis({})
const { id, token } = await freigegeben()
const r = await send('GET', url(id, token))
assert.equal(r.status, 200)
assert.equal(r.body.status, 'freigegeben')
assert.equal(r.body.unterkunft.name, 'Erste')
assert.equal(r.body.naechte, 2)
assert.equal(r.body.summen.bruttoCent, 20000)
assert.equal(r.body.akzentfarbe, '#112233')
assert.equal(r.body.anbieter.datenschutzUrl, 'https://donau-home.example/datenschutz')
assert.equal(r.body.anbieter.firma, 'Donau Home')
assert.deepEqual(r.body.zahlarten, ['vorort'])
assert.equal(r.body.bezahlt, false)
assert.equal(r.body.rechnungNummer, undefined)
assert.equal(r.body.stornobedingungen, 'Kostenlos bis 7 Tage vorher.')
const roh = JSON.stringify(r.body)
for (const verboten of ['tokenHash', 'ipHash', 'steuernummer', 'iban', 'featureId', 'customerId', 'eva@example.com']) assert.ok(!roh.includes(verboten), verboten)
const mitOnline = await (async () => {
await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT })
const f = await freigegeben()
return send('GET', url(f.id, f.token))
})()
assert.deepEqual(mitOnline.body.zahlarten, ['vorort', 'online'])
})
test('POST buchung vor Ort: gebucht, Rechnung; zweites POST gleiche Antwort ohne zweite Rechnung', async () => {
const { id, token } = await freigegeben()
const r1 = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
assert.equal(r1.status, 200)
assert.deepEqual(r1.body, { status: 'gebucht', rechnungNummer: 'RE-2026-0001' })
assert.equal(r1.headers.get('access-control-allow-origin'), ORIGIN)
const r2 = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
assert.deepEqual(r2.body, r1.body)
assert.equal(aw.docs('ukRechnungen').size, 1)
assert.deepEqual(arten(), ['G19', 'K32'])
const g = await send('GET', url(id, token))
assert.equal(g.body.status, 'gebucht')
assert.equal(g.body.rechnungNummer, 'RE-2026-0001')
assert.equal(g.body.zahlart, 'vorort')
assert.equal(g.body.rechnungsadresse.ort, 'Berlin')
})
test('POST buchung: falsches Token 404, fremde Herkunft 403, Adresse fehlt 400, Frist abgelaufen 410', async () => {
const { id, token } = await freigegeben()
const falsch = await post(url(id, 'falsch'), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
assert.equal(falsch.status, 404)
assert.deepEqual(falsch.body, { fehler: 'nicht_gefunden' })
assert.equal((await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }, { origin: 'https://boese.example' })).status, 403)
const luecke = await post(url(id, token), { rechnungsadresse: { name: 'x' }, zahlart: 'vorort' })
assert.equal(luecke.status, 400)
assert.equal(luecke.body.fehler, 'ungueltig')
setzeUhrFuerTests(() => new Date(NOW.getTime() + 4 * 86400000))
const spaet = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
assert.equal(spaet.status, 410)
assert.deepEqual(spaet.body, { fehler: 'frist_abgelaufen' })
assert.equal(aw.docs('ukRechnungen').size, 0)
})
test('POST buchung online: zahlungUrl vom Stripe-Nachbau, Sitzung auf dem Konto des Vermieters', async () => {
const { id, token } = await freigegeben()
const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
assert.equal(r.status, 200)
assert.equal(r.body.status, 'gebucht')
assert.match(r.body.zahlungUrl, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/)
const [call] = st.calls.filter((c) => c.path === '/v1/checkout/sessions')
assert.equal(call.account, ACCOUNT)
assert.equal(call.body.mode, 'payment')
assert.equal(call.body['metadata[wkUk]'], '1')
assert.equal(call.body['metadata[anfrageId]'], id)
assert.equal(call.body['payment_intent_data[metadata][anfrageId]'], id)
assert.equal(call.body['payment_intent_data[metadata][webklar]'], 'unterkunft')
assert.equal(call.body['line_items[0][price_data][unit_amount]'], '20000')
assert.match(call.body['line_items[0][price_data][product_data][name]'], /^Aufenthalt Erste 10\.11\.2026–12\.11\.2026, Rechnung RE-2026-0001$/)
assert.equal(call.body['payment_method_types[0]'], 'card')
assert.equal(call.body['payment_method_types[1]'], undefined)
assert.equal(call.body.locale, 'de')
assert.match(call.body.success_url, new RegExp(`/unterkunft/${SITE}/buchung/${id}\\?t=.*&wk_zahlung=ok$`))
assert.match(call.body.cancel_url, /&wk_zahlung=abbruch$/)
assert.equal(Number(call.body.expires_at), Math.floor(NOW.getTime() / 1000) + 31 * 60)
assert.match(call.idempotencyKey, new RegExp(`^uk-zahlung-${id}-\\d+$`))
assert.ok((await sp.ladeAnfrage(id)).stripeSessionId.startsWith('cs_'))
})
test('SEPA nur bei gemeldetem Status', async () => {
await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: true, sepa_debit: true }) })
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const call = st.calls.find((c) => c.path === '/v1/checkout/sessions')
assert.equal(call.body['payment_method_types[1]'], 'sepa_debit')
})
test('Webhook checkout.session.completed: bezahlt, G20, zweimal nur ein Uebergang', async () => {
const { id, token } = await freigegeben()
const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const sid = (await sp.ladeAnfrage(id)).stripeSessionId
mails._aufrufe.length = 0
const session = st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_1' })
const event = { type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } }
await W.verarbeiteUkEreignis(event, NOW)
await W.verarbeiteUkEreignis(event, NOW)
const a = await sp.ladeAnfrage(id)
assert.equal(a.status, 'bezahlt')
assert.equal(a.bezahltVia, 'stripe')
assert.equal(a.stripePaymentIntentId, 'pi_1')
assert.deepEqual(arten(), ['G20'])
assert.ok(r.body.zahlungUrl)
})
test('Webhook: unbezahlt, fremdes Konto, unbekannte Anfrage ohne Wirkung; async_payment_succeeded bucht', async () => {
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const sid = (await sp.ladeAnfrage(id)).stripeSessionId
const unbezahlt = { ...st.completeSession(ACCOUNT, sid, { payment_status: 'unpaid', payment_intent: 'pi_2' }) }
await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: unbezahlt } }, NOW)
assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht')
const bezahlt = { ...unbezahlt, payment_status: 'paid' }
await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: 'acct_fremd', data: { object: bezahlt } }, NOW)
assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht')
await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: { ...bezahlt, metadata: { wkUk: '1', anfrageId: 'gibtsnicht' } } } }, NOW)
await W.verarbeiteUkEreignis({ type: 'checkout.session.async_payment_succeeded', account: ACCOUNT, data: { object: bezahlt } }, NOW)
assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt')
})
test('Rueckkehr mit wk_zahlung=ok schliesst ohne Webhook selbst ab', async () => {
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const sid = (await sp.ladeAnfrage(id)).stripeSessionId
const offen = await send('GET', url(id, token, '') + '&wk_zahlung=ok')
assert.equal(offen.body.status, 'gebucht')
mails._aufrufe.length = 0
st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_3' })
const r = await send('GET', url(id, token) + '&wk_zahlung=ok')
assert.equal(r.body.status, 'bezahlt')
assert.equal(r.body.bezahlt, true)
assert.deepEqual(arten(), ['G20'])
assert.equal((await sp.ladeAnfrage(id)).stripePaymentIntentId, 'pi_3')
})
test('POST zahlung: neue Sitzung fuer gebuchte Online-Zahlung; vor Ort oder bezahlt 409', async () => {
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const r = await post(url(id, token, '/zahlung'), {})
assert.equal(r.status, 200)
assert.match(r.body.url, /^https:\/\/checkout\.stripe\.test\//)
assert.equal((await post(url(id, 'falsch', '/zahlung'), {})).status, 404)
const sid = (await sp.ladeAnfrage(id)).stripeSessionId
st.completeSession(ACCOUNT, sid, { payment_status: 'paid', payment_intent: 'pi_4' })
const nach = await post(url(id, token, '/zahlung'), {})
assert.equal(nach.status, 409)
assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt')
const g = await freigegeben('2026-12-10', '2026-12-12')
await post(url(g.id, g.token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
assert.equal((await post(url(g.id, g.token, '/zahlung'), {})).status, 409)
})
async function bezahlt(pi = 'pi_5') {
const { id, token, ctx } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const sid = (await sp.ladeAnfrage(id)).stripeSessionId
const session = st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: pi })
await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } }, NOW)
return { id, token, ctx }
}
test('Erstattung echt: Stripe-Aufruf mit Metadaten unterkunft und Idempotency-Key, erstattetCent aus Stripe', async () => {
const { id, ctx } = await bezahlt('pi_6')
st.setPaymentIntent(ACCOUNT, 'pi_6', { amount: 20000 })
st.calls.length = 0
const r = await ab.storniere(ctx, id, { grund: 'Krank', erstattungCent: 12000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 12000)
const [call] = st.calls.filter((c) => c.path === '/v1/refunds')
assert.equal(call.account, ACCOUNT)
assert.equal(call.body.payment_intent, 'pi_6')
assert.equal(call.body.amount, '12000')
assert.equal(call.body['metadata[webklar]'], 'unterkunft')
assert.equal(call.idempotencyKey, `uk-erstattung-${id}-12000`)
assert.equal(zahlung._aufrufe.length, 1)
})
test('Erstattung: Stripe scheitert -> 502, Storno bleibt, erstattetCent unveraendert', async () => {
const { id, ctx } = await bezahlt('pi_7')
st.setPaymentIntent(ACCOUNT, 'pi_7', { amount: 20000 })
st.failNext({ status: 400, path: '/v1/refunds' })
await assert.rejects(ab.storniere(ctx, id, { erstattungCent: 5000, now: NOW }), (e) => e.status === 502 && e.code === 'erstattung_fehlgeschlagen')
const a = await sp.ladeAnfrage(id)
assert.equal(a.status, 'storniert')
assert.equal(a.erstattetCent ?? 0, 0)
})
test('Webhook charge.refunded: erstattetCent = Maximum, nie zurueck', async () => {
const { id, ctx } = await bezahlt('pi_8')
await ab.storniere(ctx, id, { now: NOW })
const charge = (betrag) => ({ type: 'charge.refunded', account: ACCOUNT, data: { object: { id: 'ch_1', payment_intent: 'pi_8', amount_refunded: betrag, metadata: { wkUk: '1', webklar: 'unterkunft', anfrageId: id } } } })
await W.verarbeiteUkEreignis(charge(7000), NOW)
assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000)
await W.verarbeiteUkEreignis(charge(3000), NOW)
assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000)
await W.verarbeiteUkEreignis(charge(7000), NOW)
assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000)
await W.verarbeiteUkEreignis({ ...charge(9000), account: 'acct_fremd' }, NOW)
assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000)
})
test('rechnung.pdf: 200 mit PDF oder 501 ohne Erzeuger; falsches Token 404', async () => {
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
const r = await send('GET', url(id, token, '/rechnung.pdf'))
assert.ok([200, 501].includes(r.status), String(r.status))
if (r.status === 200) assert.equal(r.headers.get('content-type'), 'application/pdf')
assert.equal((await send('GET', url(id, 'falsch', '/rechnung.pdf'))).status, 404)
assert.equal((await send('GET', url(id, token, '/rechnung.pdf') + '&art=storno')).status, 404)
assert.equal((await send('GET', url(id, token, '/rechnung.pdf') + '&art=x')).status, 400)
})