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 { BRUTTO_MAX_CENT, berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js' import { stelleAus } from './rechnungen.js' import { aktualisiereAnfrage, ladeAnfrage, findeRechnung, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js' import { linkGueltig, 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) } } /** Rechnung zu Anfrage und Art ausstellen, aber nur einmal: eine vorhandene wird wiederverwendet (Wiederholung nach Teilfehler). */ async function rechnungEinmal(ctx, a, art, opts) { return (await findeRechnung(a.$id, art)) ?? stelleAus(ctx, a, { art, ...opts }) } function fuerFeature(ctx, a) { if (!a || a.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden') return a } /** Ohne Rechnungsauftrag (Zustimmung des Vermieters) darf nichts freigegeben oder verbindlich gebucht werden. */ function brauchtZustimmung(ctx) { if (!ctx?.einstellungen?.rechnungAuftragAt) throw new UkFehler(409, 'zustimmung_fehlt') } 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('K36', ctx, { anfrage })) return anfrage } export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, now = new Date() } = {}) { const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId)) brauchtZustimmung(ctx) 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), steuerart } if (!(summen.bruttoCent > 0)) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe muss größer als 0 sein.' }) if (summen.bruttoCent > BRUTTO_MAX_CENT) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe ist zu hoch.' }) 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) || !linkGueltig(a, now)) 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 } } brauchtZustimmung(ctx) 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 (!einst.firma || !einst.strasse || !einst.plz || !einst.ort || !(einst.steuernummer || einst.ustId)) { throw new UkFehler(409, 'einstellungen_unvollstaendig') } 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') // Existiert die Rechnung schon (Wiederholung nach Teilfehler), gelten ihre Adresse und Zahlart weiter. const vorhanden = await findeRechnung(a.$id, 'rechnung') const endgAdresse = vorhanden?.inhalt?.empfaenger ?? adresse const endgZahlart = vorhanden?.inhalt?.zahlart || zahlart const vorlaeufig = { ...a, rechnungsadresse: endgAdresse, zahlart: endgZahlart } const rechnung = vorhanden ?? (await rechnungEinmal(ctx, vorlaeufig, 'rechnung', { now })) const anfrage = await aktualisiereAnfrage(a.$id, { status: 'gebucht', rechnungsadresse: endgAdresse, zahlart: endgZahlart, 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('K37', 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 } function pruefeErstattung(a, betragCent, bisher = a.erstattetCent || 0) { const brutto = a.summen?.bruttoCent if (a.bezahltVia !== 'stripe') throw new UkFehler(409, 'status_ungueltig', { erstattungCent: 'Erstattung ist nur bei Online-Zahlung möglich.' }) if (!Number.isInteger(betragCent) || betragCent <= 0 || !Number.isInteger(brutto) || betragCent > brutto - bisher) { throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Der Betrag ist ungültig oder zu hoch.' }) } return bisher } /** * Erstattung einer stornierten, online bezahlten Buchung. Vor dem Stripe-Aufruf wird der bei Stripe LIVE erstattete Betrag der Zahlung * gelesen; bisher = max(erstattetCent, live), die Obergrenze wird gegen diesen Wert geprueft (kein doppeltes Erstatten, wenn ein frueherer * Versuch bei Stripe durchging, aber nicht gespeichert wurde). Liegt die Datenbank hinter Stripe zurueck, wird sie zuerst nachgezogen. * erstattetCent wird ERST NACH dem Stripe-Erfolg geschrieben, ohne Rollback; der charge.refunded-Webhook heilt ebenfalls. * wiederholung: Wiederholung eines Storno-Aufrufs - ist der Betrag bei Stripe schon erstattet, wird nur nachgezogen (nichts erstattet). */ export async function erstatteBetrag(ctx, anfrageId, { betragCent, now = new Date(), wiederholung = false } = {}) { const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId)) return withLocks(withLocksKey(pre.unterkunftId), async () => { let a = fuerFeature(ctx, await ladeAnfrage(anfrageId)) if (a.status !== 'storniert') throw new UkFehler(409, 'status_ungueltig') if (a.bezahltVia !== 'stripe') pruefeErstattung(a, betragCent) const live = await zahlung.liveErstattet(ctx, a) const bisher = Math.max(a.erstattetCent || 0, live) if (live > (a.erstattetCent || 0)) a = await aktualisiereAnfrage(a.$id, { erstattetCent: live }) if (wiederholung && Number.isInteger(betragCent) && betragCent > 0 && live >= betragCent) return a pruefeErstattung(a, betragCent, bisher) const res = await zahlung.erstatte(ctx, a, betragCent, { now, idempotencyKey: `uk-erstattung-${anfrageId}-${bisher + betragCent}` }) return aktualisiereAnfrage(a.$id, { erstattetCent: bisher + res.erstattetCent }) }) } export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0, now = new Date() } = {}) { if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Ungültiger Betrag.' }) 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 === 'storniert') return { anfrage: a, stornoRechnung: await findeRechnung(a.$id, 'storno'), neu: false } if (!darfUebergang(a.status, 'storniert')) throw new UkFehler(409, 'status_ungueltig') if (erstattungCent > 0) pruefeErstattung(a, erstattungCent) let stornoRechnung = null if (a.rechnungId) { const orig = await ladeRechnung(a.rechnungId) if (orig) stornoRechnung = await rechnungEinmal(ctx, a, 'storno', { bezugNummer: orig.nummer, bezug: orig, now }) } const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) }) return { anfrage, stornoRechnung, neu: true } }) if (r.neu) await sende(() => zahlung.beendeOffeneZahlung(ctx, r.anfrage)) if (r.neu) await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: erstattungCent, grund: text(grund, 500) })) let anfrage = r.anfrage if (erstattungCent > 0) { try { anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now, wiederholung: !r.neu }) } catch (err) { if (err instanceof UkFehler && err.status < 500 && err.code !== 'online_aus') throw err throw new UkFehler(502, 'erstattung_fehlgeschlagen') } } return { 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 } }