Files

217 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../../config.js'
import { withLocks } from '../keyedLock.js'
import { checkoutAnlegen, erstatten, erstattungenZurZahlung, sessionAblaufen, sessionHolen } from '../shop/connect.js'
import { formatTag } from './datum.js'
import { UkFehler } from './fehler.js'
import { aktualisiereAnfrage, ladeAnfrage, 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 = []
/** Aufrufprotokoll nur im Test (UK_TESTPROTOKOLL, setzt useTestEnv); in Produktion waechst nichts. */
const testProtokoll = () => process.env.NODE_ENV === 'test' || process.env.UK_TESTPROTOKOLL === '1'
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)
/** 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}`
}
const lockKey = (a) => ['unterkunft:' + a.unterkunftId]
/**
* Checkout-Sitzung fuer eine gebuchte Online-Zahlung. Unter der Sperre der Unterkunft wird die Anfrage frisch gelesen (nach einem Storno
* entsteht keine Sitzung mehr). Eine noch offene, nicht abgelaufene Sitzung der Anfrage wird wiederverwendet (Doppelklick); sonst entsteht
* eine neue mit auf die volle Minute gerundetem Ablauf, sodass Wiederholungen derselben Minute mit dem Idempotency-Key identisch sind.
* @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 (typeof token !== 'string' || token === '') throw new UkFehler(400, 'ungueltig')
if (!anfrage?.$id) throw new UkFehler(409, 'status_ungueltig')
const nowMs = now instanceof Date ? now.getTime() : Number(now)
return withLocks(lockKey(anfrage), async () => {
const a = await ladeAnfrage(anfrage.$id)
if (!a || a.featureId !== ctx.featureId || a.status !== 'gebucht' || a.zahlart !== 'online') throw new UkFehler(409, 'status_ungueltig')
const brutto = a.summen?.bruttoCent
if (!Number.isInteger(brutto) || brutto <= 0) throw new UkFehler(409, 'status_ungueltig')
if (a.stripeSessionId) {
try {
const alt = await sessionHolen(accountId, a.stripeSessionId)
if (alt?.status === 'open' && alt.url && !(Number(alt.expires_at) * 1000 <= nowMs)) return { url: alt.url }
} catch (err) {
console.warn(`[unterkunft] Alte Zahlungssitzung nicht lesbar: ${err?.name ?? 'Fehler'}`)
}
}
const [unterkunft, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null])
const name = `Aufenthalt ${unterkunft?.name ?? ''} ${formatTag(a.von)}–${formatTag(a.bis)}${rechnung?.nummer ? `, Rechnung ${rechnung.nummer}` : ''}`.replace(/\s+/g, ' ').slice(0, KOERPER_MAX)
const metadata = { wkUk: '1', anfrageId: a.$id }
const minute = Math.floor(nowMs / 60000)
const params = {
mode: 'payment',
payment_method_types: ['card'],
locale: 'de',
line_items: [{ quantity: 1, price_data: { currency: 'eur', unit_amount: brutto, product_data: { name } } }],
expires_at: minute * 60 + SITZUNG_MINUTEN * 60,
metadata,
payment_intent_data: { metadata: { ...metadata, webklar: 'unterkunft' } },
success_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=ok'),
cancel_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=abbruch'),
...(typeof a.gast?.email === 'string' && a.gast.email ? { customer_email: a.gast.email } : {}),
}
const session = await checkoutAnlegen(accountId, params, { idempotencyKey: `uk-zahlung-${a.$id}-${minute}` })
if (!session.url) throw new UkFehler(502, 'zahlung_fehlgeschlagen')
await aktualisiereAnfrage(a.$id, { stripeSessionId: session.id })
return { url: session.url }
})
}
/** Nach dem Storno: eine noch offene, unbezahlte Checkout-Sitzung beenden (Fehler nur loggen: sie laeuft ohnehin in 31 Minuten ab). */
export async function beendeOffeneZahlung(ctx, anfrage) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (!anfrage?.stripeSessionId || !accountId || anfrage.bezahltAt) return
try {
await sessionAblaufen(accountId, anfrage.stripeSessionId)
} catch (err) {
console.warn(`[unterkunft] Offene Zahlungssitzung nicht beendet: ${err?.name ?? 'Fehler'}`)
}
}
/** Stimmt eine bezahlte Sitzung mit der Buchung ueberein: Betrag = Brutto der Anfrage (Cent), Waehrung eur? Sonst gilt sie nicht als Bezahlung. */
export function zahlungPasst(anfrage, session) {
const brutto = anfrage?.summen?.bruttoCent
return Number.isInteger(brutto) && session?.amount_total === brutto && String(session?.currency ?? '').toLowerCase() === 'eur'
}
/** Volle Rueckerstattung einer Zahlung, die zu keiner offenen Buchung mehr passt (nach Storno, Ablauf oder doppelt gezahlt). */
export async function erstatteUeberzaehligeZahlung(ctx, paymentIntentId) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (!accountId || typeof paymentIntentId !== 'string' || paymentIntentId === '') return
await erstatten(accountId, { paymentIntent: paymentIntentId, grund: 'Zahlung ohne offene Buchung', idempotencyKey: `uk-rueck-${paymentIntentId}`, webklar: 'unterkunft' })
console.warn('[unterkunft] Zahlung ohne offene Buchung automatisch erstattet')
}
/**
* Eingegangene Zahlung buchen. Passt sie nicht (Buchung storniert/abgelaufen oder schon mit anderer Zahlung bezahlt), wird sie voll erstattet;
* der Buchungsstatus bleibt dann unveraendert. Idempotent. Gibt die aktuelle Anfrage zurueck.
*/
export async function verbucheZahlung(ctx, anfrageId, paymentIntentId, { now = new Date() } = {}) {
const pi = typeof paymentIntentId === 'string' && paymentIntentId !== '' ? paymentIntentId : undefined
const vorher = await ladeAnfrage(anfrageId)
if (vorher?.status === 'bezahlt') {
if (pi && vorher.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi)
return vorher
}
try {
const bezahlt = await markiereBezahlt(ctx, anfrageId, { via: 'stripe', paymentIntentId: pi, now })
// gleichzeitige Zweitzahlung: zwischen dem Lesen oben und der Sperre hat eine andere Zahlung gebucht (markiereBezahlt gibt dann
// die bezahlte Anfrage ohne Fehler zurueck) -> die Zahlung, die nicht gebucht wurde, voll erstatten
if (pi && bezahlt?.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi)
return bezahlt
} catch (err) {
if (!(err instanceof UkFehler) || err.status !== 409) throw err
const jetzt = await ladeAnfrage(anfrageId)
// dieselbe Zahlung, die schon gebucht wurde (Wiederholung des Ereignisses nach spaeterem Storno): nichts erstatten
if (pi && jetzt?.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi)
return jetzt ?? vorher
}
}
// Abgleich mit Stripe hoechstens alle 30 Sekunden je Sitzung; abgelaufene Sitzungen nie wieder
const ABGLEICH_MS = 30 * 1000
const ABGLEICH_MAX = 5000
const abgleich = new Map()
export const leereAbgleichFuerTests = () => abgleich.clear()
function merkeAbgleich(schluessel, eintrag) {
abgleich.delete(schluessel)
if (abgleich.size >= ABGLEICH_MAX) abgleich.delete(abgleich.keys().next().value)
abgleich.set(schluessel, eintrag)
}
/**
* 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. erzwingen: ohne Pause (Rueckkehr von Stripe).
*/
export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date(), erzwingen = false } = {}) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online' || !anfrage.stripeSessionId || !accountId) return anfrage
const schluessel = `${anfrage.$id}:${anfrage.stripeSessionId}`
const nowMs = now instanceof Date ? now.getTime() : Number(now)
const bekannt = abgleich.get(schluessel)
if (bekannt?.abgelaufen) return anfrage
if (!erzwingen && bekannt && Math.abs(nowMs - bekannt.at) < ABGLEICH_MS) return anfrage
try {
const session = await sessionHolen(accountId, anfrage.stripeSessionId)
merkeAbgleich(schluessel, { at: nowMs, abgelaufen: session?.status === 'expired' })
if (session?.status === 'complete' && session.payment_status === 'paid') {
const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id
if (!zahlungPasst(anfrage, session)) {
console.warn(`[unterkunft] Abgleich: Betrag oder Währung weicht ab (Buchung ${anfrage.nummer}), Zahlung wird erstattet`)
await erstatteUeberzaehligeZahlung(ctx, pi)
return anfrage
}
return await verbucheZahlung(ctx, anfrage.$id, pi, { 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 } = {}) {
if (testProtokoll()) _aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey })
return erstatter(ctx, anfrage, betragCent, { now, idempotencyKey })
}
async function liveErstattetEcht(ctx, anfrage) {
const accountId = ctx?.einstellungen?.stripeAccountId
if (!accountId || !anfrage?.stripePaymentIntentId) return 0
const liste = await erstattungenZurZahlung(accountId, anfrage.stripePaymentIntentId)
return liste.filter((r) => r?.status !== 'failed' && r?.status !== 'canceled').reduce((s, r) => s + (Number.isInteger(r?.amount) ? r.amount : 0), 0)
}
let liveLeser = liveErstattetEcht
/** Nur fuer Tests: ersetzt die Abfrage des bei Stripe erstatteten Betrags (null stellt die echte wieder her). */
export function setzeLiveErstattetFuerTests(fn) {
liveLeser = typeof fn === 'function' ? fn : liveErstattetEcht
}
/** Summe der bei Stripe tatsaechlich erstatteten Betraege dieser Zahlung (0 ohne Konto oder Zahlung); Stripe-Fehler gehen nach oben. */
export async function liveErstattet(ctx, anfrage) {
return liveLeser(ctx, anfrage)
}