Buchungskalender: Statusmaschine, Token und Rechnungsnummern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
802b5d3ad4
commit
947e9da2e3
9 files changed
+760
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)])
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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}$/)
|
||||
})
|
||||
Reference in new issue
Block a user