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'],
|
ukPreise: ['staffeln'],
|
||||||
ukSperren: [],
|
ukSperren: [],
|
||||||
ukAnfragen: ['gast', 'rechnungsadresse', 'posten', 'summen'],
|
ukAnfragen: ['gast', 'rechnungsadresse', 'posten', 'summen'],
|
||||||
|
ukRechnungen: ['inhalt'],
|
||||||
ukEinstellungen: ['rechnungZaehler', 'stripeStatus', 'anfrage'],
|
ukEinstellungen: ['rechnungZaehler', 'stripeStatus', 'anfrage'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,3 +128,19 @@ export function listeAnfragen(featureId, { status, unterkunftId } = {}) {
|
|||||||
/** Fuer belegung.js: Anfragen einer Unterkunft mit einem der Status. */
|
/** Fuer belegung.js: Anfragen einer Unterkunft mit einem der Status. */
|
||||||
export const listeAnfragenNachStatus = (unterkunftId, status) =>
|
export const listeAnfragenNachStatus = (unterkunftId, status) =>
|
||||||
liste('ukAnfragen', [Query.equal('unterkunftId', unterkunftId), Query.equal('status', 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