Buchungskalender: Statusmaschine, Token und Rechnungsnummern

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 13:52:52 +00:00
1 parent 802b5d3ad4
commit 947e9da2e3
9 files changed
+760

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
import { randomInt } from 'node:crypto'
import { config } from '../../config.js'
import { withLocks } from '../keyedLock.js'
import { heute } from '../termine/zeit.js'
import { istTag, naechte } from './datum.js'
import { istFrei } from './belegung.js'
import { UkFehler } from './fehler.js'
import * as mails from './mails.js'
import { berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js'
import { stelleAus } from './rechnungen.js'
import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js'
import { neuesToken, tokenPasst } from './token.js'
import * as zahlung from './zahlung.js'
export const UEBERGAENGE = {
neu: ['freigegeben', 'abgelehnt'],
freigegeben: ['gebucht', 'abgelaufen', 'storniert'],
gebucht: ['bezahlt', 'storniert'],
bezahlt: ['storniert'],
abgelehnt: [],
abgelaufen: [],
storniert: [],
}
export const darfUebergang = (von, nach) => (UEBERGAENGE[von] ?? []).includes(nach)
const TAG_MS = 24 * 3600 * 1000
const iso = (ms) => new Date(ms).toISOString()
const ms = (now) => (now instanceof Date ? now.getTime() : Number(now))
const withLocksKey = (id) => ['unterkunft:' + id]
const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'
/** Mail nach erfolgreichem Schreiben: ein Mailfehler macht den schon gespeicherten Schritt nicht rueckgaengig. */
async function sende(fn) {
try {
await fn()
} catch (err) {
console.warn('Buchungskalender: Mail fehlgeschlagen:', err?.message || err)
}
}
function fuerFeature(ctx, a) {
if (!a || a.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden')
return a
}
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
function monateDazu(tag, n) {
const [j, m, t] = tag.split('-').map(Number)
const d = new Date(Date.UTC(j, m - 1 + n, 1))
const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), Math.min(t, letzter))).toISOString().slice(0, 10)
}
async function neueNummer(featureId) {
const vorhanden = new Set((await listeAnfragen(featureId)).map((a) => a.nummer))
for (let i = 0; i < 20; i++) {
let n = 'A-'
for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)]
if (!vorhanden.has(n)) return n
}
throw new UkFehler(500, 'nummer_vergeben')
}
export async function legeAnfrageAn(ctx, eingabe, { now = new Date(), ipHash = '' } = {}) {
const e = eingabe || {}
const anfrage = await withLocks(withLocksKey(e.unterkunftId), async () => {
const u = await ladeUnterkunft(e.unterkunftId)
if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw new UkFehler(404, 'nicht_gefunden')
if (!istTag(e.von) || !istTag(e.bis)) throw new UkFehler(400, 'ungueltig', { zeitraum: 'Ungültige Daten.' })
const n = naechte(e.von, e.bis)
const tag = heute(now)
if (n < 1 || e.von < tag) throw new UkFehler(400, 'ungueltig', { zeitraum: 'Der Zeitraum ist ungültig.' })
if (e.bis > monateDazu(tag, config.unterkunft.horizontMonate)) throw new UkFehler(400, 'ungueltig', { zeitraum: 'Der Zeitraum liegt zu weit in der Zukunft.' })
if (n < (u.mindestNaechte || 1)) throw new UkFehler(400, 'mindestnaechte', { mindestNaechte: u.mindestNaechte })
if (!Number.isInteger(e.personen) || e.personen < 1) throw new UkFehler(400, 'ungueltig', { personen: 'Ungültige Personenzahl.' })
if (e.personen > u.maxPersonen) throw new UkFehler(400, 'zu_viele_personen', { maxPersonen: u.maxPersonen })
const g = e.gast || {}
const gast = {
name: text(g.name, 150),
firma: text(g.firma, 150),
email: text(g.email, 200),
telefon: text(g.telefon, 50),
nachricht: text(g.nachricht, config.unterkunft.nachrichtMax ?? 2000),
}
if (!gast.name) throw new UkFehler(400, 'ungueltig', { name: 'Bitte einen Namen angeben.' })
if (!EMAIL_RE.test(gast.email)) throw new UkFehler(400, 'ungueltig', { email: 'Bitte eine gültige E-Mail-Adresse angeben.' })
if (!(await istFrei(u.$id, e.von, e.bis, { now }))) throw new UkFehler(409, 'zeitraum_belegt')
return legeAnfrageDokAn({
featureId: ctx.featureId,
customerId: ctx.customerId,
projectId: ctx.projectId,
unterkunftId: u.$id,
nummer: await neueNummer(ctx.featureId),
von: e.von,
bis: e.bis,
personen: e.personen,
status: 'neu',
gast,
ipHash: String(ipHash || ''),
erstattetCent: 0,
})
})
await sende(() => mails.anGast('G16', ctx, anfrage))
await sende(() => mails.anVermieter('K31', ctx, { anfrage }))
return anfrage
}
export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, now = new Date() } = {}) {
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const ergebnis = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status !== 'neu') throw new UkFehler(409, 'status_ungueltig')
const preise = await ladePreise(a.unterkunftId)
let sauber
if (posten !== undefined && posten !== null) {
const r = pruefePosten(posten, preise?.steuerart ?? 'regel')
if (r.fehler) throw new UkFehler(400, 'ungueltig', r.fehler)
sauber = r.posten
} else {
if (!preiseVollstaendig(preise)) throw new UkFehler(400, 'ungueltig', { posten: 'Preise sind nicht vollständig.' })
sauber = berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }).posten
}
const steuerart = preise?.steuerart ?? 'regel'
const summen = summiere(sauber, steuerart)
if (!(summen.bruttoCent > 0)) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe muss größer als 0 sein.' })
if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt')
const tage = Number.isInteger(fristTage) && fristTage >= 1 ? fristTage : preise?.fristTage || 3
const { token, hash } = neuesToken()
const neu = await aktualisiereAnfrage(a.$id, {
status: 'freigegeben',
posten: sauber,
summen,
fristAt: iso(ms(now) + tage * TAG_MS),
tokenHash: hash,
stornobedingungenText: preise?.stornobedingungen ?? '',
rechtstexteFassung: config.unterkunft.fassung,
nachrichtVermieter: text(nachricht, 2000),
ipHash: '',
freigegebenAt: iso(ms(now)),
})
return { anfrage: neu, token }
})
await sende(() => mails.anGast('G17', ctx, ergebnis.anfrage, { token: ergebnis.token }))
return ergebnis
}
export async function lehneAb(ctx, anfrageId, { text: grund, now = new Date() } = {}) {
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const neu = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status !== 'neu') throw new UkFehler(409, 'status_ungueltig')
return aktualisiereAnfrage(a.$id, { status: 'abgelehnt', nachrichtVermieter: text(grund, 2000), ipHash: '', abgelehntAt: iso(ms(now)) })
})
await sende(() => mails.anGast('G18', ctx, neu, { text: text(grund, 2000) }))
return neu
}
const ADRESSE_PFLICHT = ['strasse', 'plz', 'ort', 'land']
function pruefeAdresse(r) {
const x = r && typeof r === 'object' ? r : {}
const adresse = {
name: text(x.name, 150),
firma: text(x.firma, 150),
strasse: text(x.strasse, 150),
plz: text(x.plz, 10),
ort: text(x.ort, 100),
land: text(x.land, 60),
ustId: text(x.ustId, 20),
}
const felder = {}
if (!adresse.name && !adresse.firma) felder.name = 'Bitte Name oder Firma angeben.'
for (const k of ADRESSE_PFLICHT) if (!adresse[k]) felder[k] = 'Pflichtfeld.'
if (Object.keys(felder).length) throw new UkFehler(400, 'ungueltig', felder)
return adresse
}
export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse, zahlart, now = new Date() } = {}) {
const pre = await ladeAnfrage(anfrageId)
if (!pre || pre.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden')
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = await ladeAnfrage(anfrageId)
if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token)) throw new UkFehler(404, 'nicht_gefunden')
if (a.status === 'gebucht' || a.status === 'bezahlt') {
return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false }
}
if (a.status === 'abgelaufen') throw new UkFehler(410, 'frist_abgelaufen')
if (a.status !== 'freigegeben') throw new UkFehler(409, 'status_ungueltig')
if (!(Date.parse(a.fristAt) > ms(now))) throw new UkFehler(410, 'frist_abgelaufen')
if (zahlart !== 'online' && zahlart !== 'vorort') throw new UkFehler(400, 'ungueltig', { zahlart: 'Bitte eine Zahlart wählen.' })
const einst = ctx.einstellungen || {}
if (zahlart === 'online' && !(einst.onlineZahlung && einst.stripeAccountId)) throw new UkFehler(409, 'online_aus')
const adresse = pruefeAdresse(rechnungsadresse)
if (!Array.isArray(a.posten) || !a.summen) throw new UkFehler(409, 'status_ungueltig')
if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt')
const vorlaeufig = { ...a, rechnungsadresse: adresse, zahlart }
const rechnung = await stelleAus(ctx, vorlaeufig, { art: 'rechnung', now })
const anfrage = await aktualisiereAnfrage(a.$id, {
status: 'gebucht',
rechnungsadresse: adresse,
zahlart,
rechnungId: rechnung.$id,
gebuchtAt: iso(ms(now)),
})
return { anfrage, rechnung, neu: true }
})
if (r.neu) {
await sende(() => mails.anGast('G19', ctx, r.anfrage, { rechnung: r.rechnung }))
await sende(() => mails.anVermieter('K32', ctx, { anfrage: r.anfrage }))
}
return { anfrage: r.anfrage, rechnung: r.rechnung }
}
export async function markiereBezahlt(ctx, anfrageId, { via, paymentIntentId, now = new Date() } = {}) {
if (via !== 'stripe' && via !== 'vorort') throw new UkFehler(400, 'ungueltig', { via: 'Ungültig.' })
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status === 'bezahlt') return { anfrage: a, neu: false }
if (a.status !== 'gebucht') throw new UkFehler(409, 'status_ungueltig')
const felder = { status: 'bezahlt', bezahltVia: via, bezahltAt: iso(ms(now)) }
if (paymentIntentId) felder.stripePaymentIntentId = paymentIntentId
return { anfrage: await aktualisiereAnfrage(a.$id, felder), neu: true }
})
if (r.neu && via === 'stripe') await sende(() => mails.anGast('G20', ctx, r.anfrage))
return r.anfrage
}
export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0, now = new Date() } = {}) {
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (!darfUebergang(a.status, 'storniert')) throw new UkFehler(409, 'status_ungueltig')
const brutto = a.summen?.bruttoCent
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Ungültiger Betrag.' })
const bisher = a.erstattetCent || 0
if (erstattungCent > 0) {
if (a.bezahltVia !== 'stripe') throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Erstattung ist nur bei Online-Zahlung möglich.' })
if (!Number.isInteger(brutto) || erstattungCent > brutto - bisher) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Der Betrag ist zu hoch.' })
}
let erstattet = 0
if (erstattungCent > 0) erstattet = (await zahlung.erstatte(ctx, a, erstattungCent, { now })).erstattetCent
let stornoRechnung = null
if (a.rechnungId) {
const orig = await ladeRechnung(a.rechnungId)
if (orig) stornoRechnung = await stelleAus(ctx, a, { art: 'storno', bezugNummer: orig.nummer, now })
}
const anfrage = await aktualisiereAnfrage(a.$id, {
status: 'storniert',
stornoGrund: text(grund, 500),
storniertAt: iso(ms(now)),
erstattetCent: bisher + erstattet,
})
return { anfrage, stornoRechnung, erstattet }
})
await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: r.erstattet, grund }))
return { anfrage: r.anfrage, stornoRechnung: r.stornoRechnung }
}
/** Alle freigegebenen Anfragen mit abgelaufener Frist auf 'abgelaufen' setzen (Minutenjob, alle Unterkuenfte). */
export async function laufeFristenAb(now = new Date()) {
let abgelaufen = 0
for (const k of await listeAnfragenAlle('freigegeben')) {
if (!(Date.parse(k.fristAt) <= ms(now))) continue
const neu = await withLocks(withLocksKey(k.unterkunftId), async () => {
const a = await ladeAnfrage(k.$id)
if (!a || a.status !== 'freigegeben' || !(Date.parse(a.fristAt) <= ms(now))) return null
return aktualisiereAnfrage(a.$id, { status: 'abgelaufen', abgelaufenAt: iso(ms(now)) })
})
if (!neu) continue
abgelaufen++
const ctx = { featureId: neu.featureId, customerId: neu.customerId, projectId: neu.projectId, einstellungen: (await ladeEinstellungen(neu.featureId)) ?? {} }
await sende(() => mails.anGast('G21', ctx, neu))
}
return { abgelaufen }
}
+10
View File
@@ -0,0 +1,10 @@
/** Fachfehler des Buchungskalenders: status = HTTP-Status, code = maschinenlesbar, felder = Feldfehler fuer die Oberflaeche. */
export class UkFehler extends Error {
constructor(status, code, felder = {}) {
super(code)
this.name = 'UkFehler'
this.status = status
this.code = code
this.felder = felder
}
}
+10
View File
@@ -0,0 +1,10 @@
// Stub: Task 20 ersetzt den Inhalt, die Signaturen bleiben. Protokolliert die Aufrufe fuer Tests.
export const _aufrufe = []
export async function anGast(art, ctx, anfrage, extra = {}) {
_aufrufe.push({ an: 'gast', art, anfrageId: anfrage?.$id, extra })
}
export async function anVermieter(template, ctx, values = {}) {
_aufrufe.push({ an: 'vermieter', template, values })
}
+68
View File
@@ -0,0 +1,68 @@
import { withLocks } from '../keyedLock.js'
import { heute } from '../termine/zeit.js'
import { UkFehler } from './fehler.js'
import { naechte } from './datum.js'
import { ladeEinstellungen, ladeRechnung, ladeUnterkunft, legeRechnungAn, listeRechnungen as listeRe, speichereEinstellungen } from './speicher.js'
export { ladeRechnung }
export const listeRechnungen = (featureId) => listeRe(featureId)
const AUSSTELLER = ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email', 'telefon', 'steuernummer', 'ustId', 'iban', 'bic', 'bank']
/** Naechste lueckenlose Nummer '<Praefix>-<Jahr>-<NNNN>'; Jahr = Berliner Kalenderjahr, Jahreswechsel setzt den Zaehler auf 1. */
export function naechsteNummer(featureId, now = new Date()) {
return withLocks(['uk-rechnungsnr:' + featureId], async () => {
const einst = await ladeEinstellungen(featureId)
const jahr = Number(heute(now).slice(0, 4))
const z = einst?.rechnungZaehler
const n = z && z.jahr === jahr && Number.isInteger(z.n) ? z.n + 1 : 1
await speichereEinstellungen(featureId, { rechnungZaehler: { jahr, n } })
const praefix = (einst?.rechnungsPraefix || '').trim() || 'RE'
return `${praefix}-${jahr}-${String(n).padStart(4, '0')}`
})
}
const negiere = (summen) => ({
...summen,
bruttoCent: -summen.bruttoCent,
nettoCent: -summen.nettoCent,
ust: (summen.ust || []).map((u) => ({ ...u, nettoCent: -u.nettoCent, steuerCent: -u.steuerCent })),
})
/** Rechnung oder Stornorechnung zu einer Anfrage ausstellen (Nummer, Dokument). Die Sperre der Unterkunft haelt der Aufrufer. */
export async function stelleAus(ctx, anfrage, { art = 'rechnung', bezugNummer, now = new Date() } = {}) {
if (!Array.isArray(anfrage?.posten) || !anfrage.summen || typeof anfrage.summen.bruttoCent !== 'number') {
throw new UkFehler(409, 'status_ungueltig', { posten: 'Posten oder Summen fehlen.' })
}
const storno = art === 'storno'
const e = ctx.einstellungen || {}
const aussteller = Object.fromEntries(AUSSTELLER.map((k) => [k, e[k] ?? '']))
const unterkunft = await ladeUnterkunft(anfrage.unterkunftId)
const posten = anfrage.posten.map((p) => (storno ? { ...p, einzelCent: -p.einzelCent } : { ...p }))
const summen = storno ? negiere(anfrage.summen) : anfrage.summen
const nummer = await naechsteNummer(ctx.featureId, now)
const inhalt = {
aussteller,
empfaenger: anfrage.rechnungsadresse ?? null,
unterkunft: { name: unterkunft?.name ?? '' },
von: anfrage.von,
bis: anfrage.bis,
naechte: naechte(anfrage.von, anfrage.bis),
personen: anfrage.personen,
posten,
summen,
steuerart: (anfrage.summen.ust || []).length === 0 ? 'kleinunternehmer' : 'regel',
zahlart: anfrage.zahlart || '',
bezugNummer: bezugNummer ?? '',
anfrageNummer: anfrage.nummer,
}
return legeRechnungAn({
featureId: ctx.featureId,
anfrageId: anfrage.$id,
art,
nummer,
datum: heute(now),
bruttoCent: summen.bruttoCent,
inhalt,
})
}
+17
View File
@@ -12,6 +12,7 @@ const JSON_FELDER = {
ukPreise: ['staffeln'],
ukSperren: [],
ukAnfragen: ['gast', 'rechnungsadresse', 'posten', 'summen'],
ukRechnungen: ['inhalt'],
ukEinstellungen: ['rechnungZaehler', 'stripeStatus', 'anfrage'],
}
@@ -127,3 +128,19 @@ export function listeAnfragen(featureId, { status, unterkunftId } = {}) {
/** Fuer belegung.js: Anfragen einer Unterkunft mit einem der Status. */
export const listeAnfragenNachStatus = (unterkunftId, status) =>
liste('ukAnfragen', [Query.equal('unterkunftId', unterkunftId), Query.equal('status', status)])
// ---- ukRechnungen
export const ladeRechnung = (id) => ladeDok('ukRechnungen', id)
export async function legeRechnungAn(felder) {
return lesen('ukRechnungen', await createDocument(config.collections.ukRechnungen, schreiben('ukRechnungen', felder)))
}
export async function listeRechnungen(featureId) {
const alle = await liste('ukRechnungen', [Query.equal('featureId', featureId)])
return alle.sort((a, b) => String(a.nummer).localeCompare(String(b.nummer)))
}
/** Alle Anfragen (aller Kunden) mit einem Status; fuer den Fristen-Job. */
export const listeAnfragenAlle = (status) => liste('ukAnfragen', [Query.equal('status', status)])
+78
View File
@@ -0,0 +1,78 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { config } from '../../config.js'
import { ipSchluessel } from '../termine/limits.js'
/**
* Tokens des Buchungskalenders. Buchungslink-Token: 32 Byte zufaellig (base64url), gespeichert wird nur der SHA-256-Hash.
* Formular-Token: Kopie von shop/token.js mit eigener Beschriftung 'uk-form' ("<Zeit base36>.<HMAC>" je siteKey).
*/
const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000
const FORM_TOKEN_MAX_LAENGE = 100
const MAC_RE = /^[A-Za-z0-9_-]{43}$/
const ZEIT_RE = /^[0-9a-z]{1,13}$/
const MIN_STANDARD_SEKUNDEN = 3
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
export function neuesToken() {
const token = randomBytes(32).toString('base64url')
return { token, hash: sha256Hex(token) }
}
/** Zeitkonstanter Vergleich des gespeicherten Hashes mit dem Hash des vorgelegten Tokens. */
export function tokenPasst(hash, token) {
if (typeof hash !== 'string' || hash === '' || typeof token !== 'string' || token === '' || token.length > 200) return false
const erwartet = Buffer.from(hash)
const gegeben = Buffer.from(sha256Hex(token))
return erwartet.length === gegeben.length && timingSafeEqual(erwartet, gegeben)
}
export const formMinSekunden = () => {
const wert = config.unterkunft?.formMinSekunden
return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN
}
function schluessel() {
const geheimnis = config.sessionSecret
if (typeof geheimnis !== 'string' || geheimnis.length < 32) {
throw new Error('Buchungskalender-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).')
}
return createHmac('sha256', geheimnis).update('uk-form').digest()
}
const zeitMs = (now, was) => {
const ms = now instanceof Date ? now.getTime() : Number(now)
if (!Number.isFinite(ms) || ms < 0) throw new TypeError(`${was}: now ist kein gueltiger Zeitpunkt`)
return ms
}
const formMac = (siteKey, zeit) => createHmac('sha256', schluessel()).update(`${siteKey}|${zeit}`).digest('base64url')
export function formToken(siteKey, now = new Date()) {
if (typeof siteKey !== 'string' || siteKey === '') throw new TypeError('formToken: siteKey fehlt')
const zeit = zeitMs(now, 'formToken').toString(36)
return `${zeit}.${formMac(siteKey, zeit)}`
}
/** 'ok' | 'zu_schnell' | 'abgelaufen' (aelter als 2 h) | 'ungueltig'. */
export function pruefeFormToken(siteKey, token, now = new Date()) {
if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig'
if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig'
const teile = token.split('.')
if (teile.length !== 2) return 'ungueltig'
const [zeit, mac] = teile
if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig'
const erwartet = Buffer.from(formMac(siteKey, zeit))
const gegeben = Buffer.from(mac)
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36)
if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen'
if (alter < formMinSekunden() * 1000) return 'zu_schnell'
return 'ok'
}
/** Gesalzener Kurz-Hash der IP (16 Hex-Zeichen); die Adresse selbst wird nie gespeichert. */
export function ipHashVon(ip) {
return createHash('sha256').update(`uk-ip:${config.sessionSecret}:${ipSchluessel(ip)}`).digest('hex').slice(0, 16)
}
+7
View File
@@ -0,0 +1,7 @@
// Stub: Task 7 ersetzt den Inhalt. Erstattung ueber Stripe Connect.
import { UkFehler } from './fehler.js'
export async function erstatte(ctx, anfrage, betragCent, { now } = {}) {
if (!ctx?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus')
return { erstattetCent: betragCent }
}