diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index 695f71b..0852737 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -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 diff --git a/server/routes/unterkunftWebhook.js b/server/routes/unterkunftWebhook.js index 8c9f892..5797fa5 100644 --- a/server/routes/unterkunftWebhook.js +++ b/server/routes/unterkunftWebhook.js @@ -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 } /** diff --git a/server/services/shop/connect.js b/server/services/shop/connect.js index 6e1e4e1..a6c6271 100644 --- a/server/services/shop/connect.js +++ b/server/services/shop/connect.js @@ -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 } : {}) } ) diff --git a/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js index d73ed70..df153dd 100644 --- a/server/services/unterkunft/zahlung.js +++ b/server/services/unterkunft/zahlung.js @@ -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 }) } diff --git a/test/unterkunftAblauf.test.js b/test/unterkunftAblauf.test.js index fc9cc55..ca53a3d 100644 --- a/test/unterkunftAblauf.test.js +++ b/test/unterkunftAblauf.test.js @@ -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 }]), diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js new file mode 100644 index 0000000..d8cdd55 --- /dev/null +++ b/test/unterkunftBuchung.test.js @@ -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) +})