330 lines
17 KiB
JavaScript
330 lines
17 KiB
JavaScript
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, findeRechnung, 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)
|
|
}
|
|
}
|
|
|
|
/** 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('K31', 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 (!(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 }
|
|
}
|
|
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('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
|
|
}
|
|
|
|
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, 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 }
|
|
}
|