diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js new file mode 100644 index 0000000..39a8f00 --- /dev/null +++ b/server/services/unterkunft/ablauf.js @@ -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 } +} diff --git a/server/services/unterkunft/fehler.js b/server/services/unterkunft/fehler.js new file mode 100644 index 0000000..2dcd354 --- /dev/null +++ b/server/services/unterkunft/fehler.js @@ -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 + } +} diff --git a/server/services/unterkunft/mails.js b/server/services/unterkunft/mails.js new file mode 100644 index 0000000..a4c1147 --- /dev/null +++ b/server/services/unterkunft/mails.js @@ -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 }) +} diff --git a/server/services/unterkunft/rechnungen.js b/server/services/unterkunft/rechnungen.js new file mode 100644 index 0000000..9dd0003 --- /dev/null +++ b/server/services/unterkunft/rechnungen.js @@ -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 '--'; 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, + }) +} diff --git a/server/services/unterkunft/speicher.js b/server/services/unterkunft/speicher.js index 7bbe046..3177f4a 100644 --- a/server/services/unterkunft/speicher.js +++ b/server/services/unterkunft/speicher.js @@ -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)]) diff --git a/server/services/unterkunft/token.js b/server/services/unterkunft/token.js new file mode 100644 index 0000000..061696c --- /dev/null +++ b/server/services/unterkunft/token.js @@ -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' ("." 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) +} diff --git a/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js new file mode 100644 index 0000000..e0e7ea5 --- /dev/null +++ b/server/services/unterkunft/zahlung.js @@ -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 } +} diff --git a/test/unterkunftAblauf.test.js b/test/unterkunftAblauf.test.js new file mode 100644 index 0000000..a7d6742 --- /dev/null +++ b/test/unterkunftAblauf.test.js @@ -0,0 +1,212 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 4: Statusmaschine (ablauf.js) +const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), + datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const ab = await import('../server/services/unterkunft/ablauf.js') +const mails = await import('../server/services/unterkunft/mails.js') +const { UkFehler } = await import('../server/services/unterkunft/fehler.js') +const sp = await import('../server/services/unterkunft/speicher.js') +const { istFrei } = await import('../server/services/unterkunft/belegung.js') + +after(() => aw.close()) +const NOW = new Date('2026-11-01T12:00:00Z') +const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', onlineZahlung: false } } +const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo' } +const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } + +beforeEach(async () => { + for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) + mails._aufrufe.length = 0 + 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 }]), + reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'regel', + ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vorher.', + }) +}) + +const eingabe = (v = '2026-11-10', b = '2026-11-12', extra = {}) => ({ unterkunftId: 'u1', von: v, bis: b, personen: 2, gast: GAST, ...extra }) +const anlegen = (v, b) => ab.legeAnfrageAn(ctx, eingabe(v, b), { now: NOW, ipHash: 'x' }) +const arten = () => mails._aufrufe.map((m) => m.art || m.template) +const fehlerCode = async (p) => { + try { + await p + } catch (e) { + assert.ok(e instanceof UkFehler, String(e)) + return `${e.status} ${e.code}` + } + return 'kein Fehler' +} + +test('Anfrage wird neu, Nummer-Format, Mails G16 + K31', async () => { + const a = await anlegen() + assert.equal(a.status, 'neu') + assert.match(a.nummer, /^A-[A-Z2-9]{4}$/) + assert.deepEqual(arten(), ['G16', 'K31']) +}) + +test('Ungueltige Anfragen: nichts gespeichert', async () => { + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: 'f1', von: '2026-12-01', bis: '2026-12-05', quelle: 'manuell' }) + assert.equal(await fehlerCode(anlegen('2026-10-30', '2026-11-02')), '400 ungueltig') + assert.equal(await fehlerCode(anlegen('2026-11-10', '2026-11-11')), '400 mindestnaechte') + assert.equal(await fehlerCode(ab.legeAnfrageAn(ctx, eingabe('2026-11-10', '2026-11-12', { personen: 5 }), { now: NOW })), '400 zu_viele_personen') + assert.equal(await fehlerCode(anlegen('2026-12-03', '2026-12-06')), '409 zeitraum_belegt') + assert.equal(await fehlerCode(anlegen('2028-11-10', '2028-11-12')), '400 ungueltig') + assert.equal(aw.docs('ukAnfragen').size, 0) + assert.equal(mails._aufrufe.length, 0) +}) + +test('Anreise heute ist erlaubt', async () => { + const a = await anlegen('2026-11-01', '2026-11-03') + assert.equal(a.status, 'neu') +}) + +test('Review 2: ueberlappende Anfragen, zweite Freigabe scheitert ohne Mail', async () => { + const a = await anlegen('2026-11-10', '2026-11-13') + const b = await anlegen('2026-11-12', '2026-11-15') + mails._aufrufe.length = 0 + const r = await ab.gibFrei(ctx, a.$id, { now: NOW }) + assert.equal(r.anfrage.status, 'freigegeben') + assert.deepEqual(arten(), ['G17']) + assert.ok(r.token) + mails._aufrufe.length = 0 + assert.equal(await fehlerCode(ab.gibFrei(ctx, b.$id, { now: NOW })), '409 zeitraum_belegt') + assert.equal(mails._aufrufe.length, 0) + assert.equal((await sp.ladeAnfrage(b.$id)).status, 'neu') +}) + +test('gibFrei mit eigenen Posten und Schnappschuss; Fehler bei Unsinn', async () => { + const a = await anlegen() + assert.equal(await fehlerCode(ab.gibFrei(ctx, a.$id, { posten: [], now: NOW })), '400 ungueltig') + const r = await ab.gibFrei(ctx, a.$id, { posten: [{ text: 'Pauschale', menge: 1, einzelCent: 50000, ustProzent: 7 }], now: NOW }) + assert.equal(r.anfrage.summen.bruttoCent, 50000) + assert.equal(r.anfrage.stornobedingungenText, 'Kostenlos bis 7 Tage vorher.') + assert.equal(r.anfrage.rechtstexteFassung, '2026-10-07') + assert.equal(r.anfrage.ipHash, '') + assert.equal(Date.parse(r.anfrage.fristAt), NOW.getTime() + 3 * 86400000) + assert.equal(await fehlerCode(ab.gibFrei(ctx, a.$id, { now: NOW })), '409 status_ungueltig') +}) + +test('gibFrei ohne Posten rechnet den Preis', async () => { + const a = await anlegen() + const r = await ab.gibFrei(ctx, a.$id, { now: NOW }) + assert.equal(r.anfrage.summen.bruttoCent, 20000) +}) + +test('lehneAb nur aus neu, Mail G18', async () => { + const a = await anlegen() + mails._aufrufe.length = 0 + const r = await ab.lehneAb(ctx, a.$id, { text: 'Leider belegt', now: NOW }) + assert.equal(r.status, 'abgelehnt') + assert.deepEqual(arten(), ['G18']) + assert.equal(await fehlerCode(ab.lehneAb(ctx, a.$id, { text: 'x', now: NOW })), '409 status_ungueltig') +}) + +async function freigegeben() { + const a = await anlegen() + const { token } = await ab.gibFrei(ctx, a.$id, { now: NOW }) + mails._aufrufe.length = 0 + return { id: a.$id, token } +} + +test('bucheVerbindlich: falsches Token 404, nach Frist 410, Adresse Pflicht', async () => { + const { id, token } = await freigegeben() + assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, 'falsch', { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })), '404 nicht_gefunden') + assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, 'gibtsnicht', token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })), '404 nicht_gefunden') + const spaet = new Date(NOW.getTime() + 4 * 86400000) + assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: spaet })), '410 frist_abgelaufen') + assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: { name: 'x' }, zahlart: 'vorort', now: NOW })), '400 ungueltig') + assert.equal(aw.docs('ukRechnungen').size, 0) +}) + +test('bucheVerbindlich zweimal parallel: genau eine Rechnung, gleiche Nummer', async () => { + const { id, token } = await freigegeben() + const opt = { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW } + const [x, y] = await Promise.all([ab.bucheVerbindlich(ctx, id, token, opt), ab.bucheVerbindlich(ctx, id, token, opt)]) + assert.equal(aw.docs('ukRechnungen').size, 1) + assert.equal(x.rechnung.nummer, 'RE-2026-0001') + assert.equal(y.rechnung.nummer, x.rechnung.nummer) + assert.equal(x.anfrage.status, 'gebucht') + assert.deepEqual(arten(), ['G19', 'K32']) +}) + +test('bucheVerbindlich online ohne Stripe-Konto: 409 online_aus', async () => { + const { id, token } = await freigegeben() + assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })), '409 online_aus') +}) + +test('Fristablauf: abgelaufen, G21, Zeitraum frei', async () => { + const { id } = await freigegeben() + assert.equal(await istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW }), false) + assert.deepEqual(await ab.laufeFristenAb(NOW), { abgelaufen: 0 }) + const spaet = new Date(NOW.getTime() + 4 * 86400000) + assert.deepEqual(await ab.laufeFristenAb(spaet), { abgelaufen: 1 }) + assert.equal((await sp.ladeAnfrage(id)).status, 'abgelaufen') + assert.deepEqual(arten(), ['G21']) + assert.equal(await istFrei('u1', '2026-11-10', '2026-11-12', { now: spaet }), true) +}) + +test('Bezahlt: idempotent, G20 nur bei stripe', async () => { + const { id, token } = await freigegeben() + await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }) + mails._aufrufe.length = 0 + const a = await ab.markiereBezahlt(ctx, id, { via: 'vorort', now: NOW }) + assert.equal(a.status, 'bezahlt') + await ab.markiereBezahlt(ctx, id, { via: 'vorort', now: NOW }) + assert.equal(mails._aufrufe.length, 0) + const b = await anlegen('2026-12-10', '2026-12-12') + const f = await ab.gibFrei(ctx, b.$id, { now: NOW }) + await ab.bucheVerbindlich(ctx, b.$id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }) + mails._aufrufe.length = 0 + await ab.markiereBezahlt(ctx, b.$id, { via: 'stripe', paymentIntentId: 'pi_1', now: NOW }) + assert.deepEqual(arten(), ['G20']) + assert.equal(await fehlerCode(ab.markiereBezahlt(ctx, (await anlegen('2026-12-20', '2026-12-22')).$id, { via: 'vorort', now: NOW })), '409 status_ungueltig') +}) + +test('Storno: Stornorechnung mit eigener Nummer, G22; Erstattung > brutto = 400', async () => { + const { id, token } = await freigegeben() + const { rechnung } = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }) + mails._aufrufe.length = 0 + assert.equal(await fehlerCode(ab.storniere(ctx, id, { grund: 'x', erstattungCent: 100, now: NOW })), '400 ungueltig') + assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht') + const r = await ab.storniere(ctx, id, { grund: 'Wunsch', now: NOW }) + assert.equal(r.anfrage.status, 'storniert') + assert.equal(r.stornoRechnung.nummer, 'RE-2026-0002') + assert.equal(r.stornoRechnung.inhalt.bezugNummer, rechnung.nummer) + assert.deepEqual(arten(), ['G22']) + assert.equal(await istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW }), true) +}) + +test('Storno bezahlt per Stripe: Erstattung, Obergrenze; freigegeben ohne Rechnung', async () => { + const sctx = { ...ctx, einstellungen: { ...ctx.einstellungen, onlineZahlung: true, stripeAccountId: 'acct_1' } } + const { id, token } = await freigegeben() + await ab.bucheVerbindlich(sctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW }) + await ab.markiereBezahlt(sctx, id, { via: 'stripe', now: NOW }) + assert.equal(await fehlerCode(ab.storniere(sctx, id, { erstattungCent: 20001, now: NOW })), '400 ungueltig') + const r = await ab.storniere(sctx, id, { grund: 'g', erstattungCent: 20000, now: NOW }) + assert.equal(r.anfrage.erstattetCent, 20000) + const { id: id2 } = await (async () => { + const a = await anlegen('2026-12-10', '2026-12-12') + await ab.gibFrei(ctx, a.$id, { now: NOW }) + return { id: a.$id } + })() + const r2 = await ab.storniere(ctx, id2, { grund: 'g', now: NOW }) + assert.equal(r2.stornoRechnung, null) +}) + +test('Uebergaenge', () => { + assert.equal(ab.darfUebergang('neu', 'bezahlt'), false) + assert.equal(ab.darfUebergang('neu', 'freigegeben'), true) + assert.equal(ab.darfUebergang('bezahlt', 'storniert'), true) + assert.equal(ab.darfUebergang('abgelehnt', 'neu'), false) +}) diff --git a/test/unterkunftRechnungen.test.js b/test/unterkunftRechnungen.test.js new file mode 100644 index 0000000..d55fefb --- /dev/null +++ b/test/unterkunftRechnungen.test.js @@ -0,0 +1,79 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 4: Rechnungsnummern (rechnungen.js) und Token (token.js) +const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen'] +const aw = await startAppwriteMock({ schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])) }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const re = await import('../server/services/unterkunft/rechnungen.js') +const tk = await import('../server/services/unterkunft/token.js') +const sp = await import('../server/services/unterkunft/speicher.js') + +after(() => aw.close()) +const NOW = new Date('2026-11-01T12:00:00Z') + +beforeEach(async () => { + for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) +}) + +test('Nummern laufen hoch', async () => { + assert.equal(await re.naechsteNummer('f1', NOW), 'RE-2026-0001') + assert.equal(await re.naechsteNummer('f1', NOW), 'RE-2026-0002') + assert.equal(await re.naechsteNummer('f2', NOW), 'RE-2026-0001') +}) + +test('10 parallele Nummern: verschieden und lueckenlos', async () => { + const nr = await Promise.all(Array.from({ length: 10 }, () => re.naechsteNummer('f1', NOW))) + assert.deepEqual([...nr].sort(), Array.from({ length: 10 }, (_, i) => `RE-2026-${String(i + 1).padStart(4, '0')}`)) +}) + +test('Jahreswechsel nach Berliner Kalenderjahr', async () => { + assert.equal(await re.naechsteNummer('f1', new Date('2026-12-31T22:30:00Z')), 'RE-2026-0001') + assert.equal(await re.naechsteNummer('f1', new Date('2026-12-31T23:30:00Z')), 'RE-2027-0001') + assert.equal(await re.naechsteNummer('f1', new Date('2027-01-01T00:30:00+01:00')), 'RE-2027-0002') +}) + +test('Praefix aus den Einstellungen', async () => { + await sp.speichereEinstellungen('f1', { rechnungsPraefix: 'DH' }) + assert.equal(await re.naechsteNummer('f1', NOW), 'DH-2026-0001') +}) + +test('stelleAus storno negiert Posten und Summen, traegt bezugNummer', async () => { + const anfrage = { + $id: 'a1', unterkunftId: 'u1', nummer: 'A-ABCD', von: '2026-11-10', bis: '2026-11-12', personen: 2, zahlart: 'vorort', + rechnungsadresse: { name: 'Gast' }, + posten: [{ text: 'Nacht', menge: 2, einzelCent: 10000, ustProzent: 7 }], + summen: { bruttoCent: 20000, nettoCent: 18692, ust: [{ satz: 7, nettoCent: 18692, steuerCent: 1308 }] }, + } + const ctx = { featureId: 'f1', einstellungen: { firma: 'Haus GmbH', iban: 'DE00' } } + const r = await re.stelleAus(ctx, anfrage, { art: 'rechnung', now: NOW }) + assert.equal(r.nummer, 'RE-2026-0001') + assert.equal(r.inhalt.aussteller.firma, 'Haus GmbH') + assert.equal(r.inhalt.naechte, 2) + const s = await re.stelleAus(ctx, anfrage, { art: 'storno', bezugNummer: r.nummer, now: NOW }) + assert.equal(s.nummer, 'RE-2026-0002') + assert.equal(s.art, 'storno') + assert.equal(s.inhalt.posten[0].einzelCent, -10000) + assert.equal(s.inhalt.summen.bruttoCent, -20000) + assert.equal(s.inhalt.summen.ust[0].steuerCent, -1308) + assert.equal(s.inhalt.bezugNummer, 'RE-2026-0001') + assert.equal((await re.listeRechnungen('f1')).length, 2) + assert.equal((await re.ladeRechnung(s.$id)).nummer, 'RE-2026-0002') +}) + +test('Token: Hash passt nur zum Token; Formtoken', () => { + const { token, hash } = tk.neuesToken() + assert.equal(tk.tokenPasst(hash, token), true) + assert.equal(tk.tokenPasst(hash, token + 'x'), false) + assert.equal(tk.tokenPasst('', token), false) + const t = tk.formToken('abc', new Date(0 + 1e12)) + assert.equal(tk.pruefeFormToken('abc', t, new Date(1e12 + 10000)), 'ok') + assert.equal(tk.pruefeFormToken('abc', t, new Date(1e12 + 100)), 'zu_schnell') + assert.equal(tk.pruefeFormToken('abd', t, new Date(1e12 + 10000)), 'ungueltig') + assert.equal(tk.pruefeFormToken('abc', t, new Date(1e12 + 3 * 3600e3)), 'abgelaufen') + assert.match(tk.ipHashVon('1.2.3.4'), /^[0-9a-f]{16}$/) +})