Merge auftraege/l2-rechnungen: Rechnungen (Strang L2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:20:36 +00:00
commit e5fc5e2c74
12 files changed
+2083 -70

No files matched your search

+26 -3
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { withLocks } from '../../services/keyedLock.js'
import { AuftragError } from '../../services/auftraege/fehler.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
@@ -21,13 +22,14 @@ import {
createProjectInvoice,
deleteDraft,
listInvoices,
rechnungsVorschau,
sendInvoiceNow,
voidInvoice,
} from '../../services/finance/stripeFinance.js'
/**
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* und rufen die Dienste in server/services/finance auf.
*/
const router = Router()
@@ -148,7 +150,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
*/
function sendFinanceError(res, what, err) {
if (err instanceof AssignError || err instanceof InvoiceError) {
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
if (err.status === 502 && err.cause) logFailure(what, err.cause)
const body = { error: err.message }
@@ -208,11 +210,22 @@ router.get('/rechnungen', async (req, res) => {
})
router.post('/rechnungen', async (req, res) => {
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}
const {
customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
daysUntilDue, memo, senden, idempotencyKey,
} = req.body || {}
try {
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
const rechnung = await createProjectInvoice({
customerId,
art,
projektId,
auftragId,
items,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
memo,
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
@@ -227,6 +240,16 @@ router.post('/rechnungen', async (req, res) => {
}
})
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
router.post('/rechnungen/vorschau', async (req, res) => {
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
try {
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
} catch (err) {
return sendFinanceError(res, 'Rechnungsvorschau', err)
}
})
router.post('/rechnungen/:id/senden', async (req, res) => {
try {
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
+283
View File
@@ -0,0 +1,283 @@
/**
* Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche,
* Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent.
* Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen.
*/
import { AuftragError } from './fehler.js'
import { LIMITS } from './konstanten.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
const TAG_MS = 86_400_000
const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max
/** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */
export function kuerzen(text, max) {
if (text.length <= max) return text
let teil = text.slice(0, max - 1)
const letzter = teil.charCodeAt(teil.length - 1)
if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1)
return `${teil.trimEnd()}…`
}
/** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */
export function stundenText(minuten) {
return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
}
/** Nur ein String YYYY-MM-DD, den es als Tag gibt. */
export function gueltigerTag(ymd) {
const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
if (!treffer) return false
const [j, m, t] = treffer.slice(1).map(Number)
const pruef = new Date(Date.UTC(j, m - 1, t))
return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t
}
/** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */
export function kalenderwoche(ymd) {
if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
const tag = new Date(`${ymd}T00:00:00Z`)
const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0
const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS)
const jahr = donnerstag.getUTCFullYear()
const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1
return { jahr, woche }
}
/** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */
export function leistungszeitraumText(von, bis) {
if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.')
if (von === bis) return formatDe(von)
if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}`
return `${formatDe(von)}–${formatDe(bis)}`
}
/** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */
function pruefeEingabePositionen(items, { leerErlaubt, praefix }) {
const leer = items === undefined || items === null
if (leer && leerErlaubt) return []
if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`)
}
const positionen = items.map((item, index) => {
const label = `Position ${index + 1}`
const beschreibung = typeof item?.description === 'string' ? item.description.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) {
throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) {
throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return {
schluessel: `${praefix}-${index + 1}`,
beschreibung,
menge: item.quantity,
einzelpreisCents: item.unitAmountCents,
betragCents: item.quantity * item.unitAmountCents,
}
})
if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) {
throw new AuftragError(400, 'Rechnungssumme zu hoch.')
}
return positionen
}
/** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-<n>. */
export function festePositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' })
}
/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-<n>. */
export function zusatzPositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' })
}
/** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */
export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) {
if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`)
}
positionen.forEach((p, index) => {
const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0
if (laenge < 1 || laenge > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
})
const summe = positionen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
return summe
}
const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '')
/** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */
function aufwandZeile(einheit, satzCents, zeilentexte) {
const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : ''
const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}`
const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}`
const betragCents = Math.round((einheit.minuten * satzCents) / 60)
return {
schluessel: einheit.schluessel,
beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`,
menge: 1,
einzelpreisCents: betragCents,
betragCents,
}
}
/**
* Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu
* KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich.
*/
export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) {
if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null }
if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.')
for (const blatt of blaetter) {
if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) {
throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
}
}
const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id)))
const tage = []
for (const blatt of sortiert) {
let tag = tage.at(-1)
if (!tag || tag.datum !== blatt.datum) {
tag = { datum: blatt.datum, minuten: 0, texte: [] }
tage.push(tag)
}
tag.minuten += blatt.minuten
const text = vereinheitlicht(blatt.text)
if (text && !tag.texte.includes(text)) tag.texte.push(text)
}
const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte })
const wochenEinheit = (woche) => {
const texte = []
for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text)
const ww = String(woche.woche).padStart(2, '0')
return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte }
}
const einheiten = []
if (tage.length <= maxZeilen) {
einheiten.push(...tage.map(tagesEinheit))
} else {
const wochen = []
for (const tag of tage) {
const kw = kalenderwoche(tag.datum)
let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche)
if (!woche) {
woche = { ...kw, tage: [] }
wochen.push(woche)
}
woche.tage.push(tag)
}
let anzahl = tage.length
for (const woche of wochen) {
if (anzahl > maxZeilen && woche.tage.length > 1) {
anzahl -= woche.tage.length - 1
einheiten.push(wochenEinheit(woche))
} else einheiten.push(...woche.tage.map(tagesEinheit))
}
if (anzahl > maxZeilen) {
throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`)
}
}
return {
positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)),
minuten: tage.reduce((s, t) => s + t.minuten, 0),
von: tage[0].datum,
bis: tage.at(-1).datum,
}
}
/** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */
export function auftragDatum(auftrag) {
if (auftrag.gesendetAm) {
const zeitpunkt = new Date(auftrag.gesendetAm)
if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt)
}
return auftrag.auftragVom || ''
}
const angegeben = (wert) => wert !== undefined && wert !== null
/** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */
export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) {
const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {}
const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : ''
if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
if (angegeben(prozentEingabe) && angegeben(betragEingabe)) {
throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
}
let prozent = null
let betrag
if (angegeben(betragEingabe)) {
if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) {
throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
}
betrag = betragEingabe
} else {
prozent = angegeben(prozentEingabe) ? prozentEingabe
: auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent
: LIMITS.standardAnzahlungProzent
if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) {
throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
}
betrag = Math.round((auftrag.summeCents * prozent) / 100)
if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.')
}
const rest = auftrag.summeCents - bereitsBerechnetCents
if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
if (betrag > rest) {
throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
}
const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %`
const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}`
const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : ''
const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor
return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag }
}
// Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen
export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }
/** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */
export function schlussPositionen(auftrag, fruehere, zusatz = []) {
const positionenAb = auftrag.positionen || []
if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) {
throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`)
}
if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) {
throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`)
}
const abzuege = (fruehere || []).filter((f) => f.betragCents > 0)
if (abzuege.length > SCHLUSS_GRENZEN.abzuege) {
throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`)
}
const zeilen = positionenAb.map((p, i) => ({
schluessel: `ab-${i + 1}`,
beschreibung: p.beschreibung,
menge: p.menge,
einzelpreisCents: p.einzelpreisCents,
betragCents: p.menge * p.einzelpreisCents,
}))
zeilen.push(...zusatzPositionen(zusatz))
const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId))
for (const f of sortiert) {
zeilen.push({
schluessel: `abzug-${f.invoiceId}`,
beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`,
menge: 1,
einzelpreisCents: -f.betragCents,
betragCents: -f.betragCents,
})
}
const summe = zeilen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
return zeilen
}
+382
View File
@@ -0,0 +1,382 @@
/**
* Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz).
* Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der
* Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus).
*/
import { config } from '../../config.js'
import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { withLocks } from '../keyedLock.js'
import { AuftragError } from './fehler.js'
import {
RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
} from './konstanten.js'
import {
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText,
anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from './positionen.js'
import { auftraegeAktiv } from './schalter.js'
import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js'
import {
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
} from './arbeitsblaetter.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
// Appwrite-Dokument-ID
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
export async function ladeFestenKunden(customerId) {
if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.')
let kunde
try {
kunde = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.')
throw err
}
const stufe = String(kunde.customerStatus || '').trim().toLowerCase()
if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.')
return kunde
}
/**
* Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). Mit weich = true liefert jeder
* Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht).
*/
async function ladeProjekt(kundenId, projektId, { weich = false } = {}) {
const fehler = (status, meldung) => {
if (weich) return null
throw new AuftragError(status, meldung)
}
if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.')
if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.')
let projekt
try {
projekt = await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.')
throw err
}
if (projekt.customerId !== kundenId) return fehler(404, 'Projekt nicht gefunden.')
return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' }
}
/** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */
async function ladeAuftrag(kundenId, auftragId) {
if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.')
const auftrag = await getAuftrag(auftragId)
if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.')
return auftrag
}
/**
* Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an
* Appwrite). Fehler mit Status 404 sind still. Wirft nie.
*/
export async function buche(was, fn) {
try {
await fn()
} catch (err) {
if (err?.status === 404) return
console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
}
/** Schlussrechnung finalisiert: die AB geht von versendet oder bestaetigt auf abgerechnet. Unter der Sperre der AB. */
export async function setzeAbAbgerechnet(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && (ab.status === 'versendet' || ab.status === 'bestaetigt')) await updateAuftrag(ab.id, { status: 'abgerechnet' })
})
}
/** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */
export async function setzeAbZurueck(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && ab.status === 'abgerechnet') await updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' })
})
}
/** Projektdatensatz inaktiv setzen (Blaetter wieder frei); bei einer Schlussrechnung geht die AB zurueck. */
export async function gibProjektRechnungFrei(invoiceId) {
const doc = await updateProjektRechnung(invoiceId, { aktiv: false })
if (doc.art === 'schluss' && doc.auftragId) await setzeAbZurueck(doc.auftragId)
return doc
}
const fehltBeiStripe = (err) => err?.statusCode === 404 || err?.code === 'resource_missing'
/**
* Selbstheilung: belegt ist Map<blattId, { invoiceId, rechnungsnummer }>. Je Rechnung bei Stripe nachsehen: storniert oder
* geloescht -> Datensatz inaktiv, die Belegung gilt nicht; sonst gilt sie (mit der Nummer von Stripe, fehlende Nummer wird
* im Datensatz nachgetragen). Rueckgabe: die noch geltenden Belegungen.
*/
async function pruefeBelegungen(belegt) {
const urteile = new Map()
for (const { invoiceId } of belegt.values()) {
if (urteile.has(invoiceId)) continue
let roh
try {
roh = await getStripe().invoices.retrieve(invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
urteile.set(invoiceId, null)
continue
}
const nummer = roh.status === 'draft' ? '' : roh.number || ''
urteile.set(invoiceId, nummer)
}
const gueltig = new Map()
for (const [blattId, belegung] of belegt) {
const nummer = urteile.get(belegung.invoiceId)
if (nummer === null) continue
if (nummer && nummer !== belegung.rechnungsnummer && !gueltig.has(`nachgetragen:${belegung.invoiceId}`)) {
gueltig.set(`nachgetragen:${belegung.invoiceId}`, true)
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(belegung.invoiceId, { rechnungsnummer: nummer }))
}
gueltig.set(blattId, { invoiceId: belegung.invoiceId, rechnungsnummer: nummer })
}
for (const schluessel of [...gueltig.keys()]) if (schluessel.startsWith('nachgetragen:')) gueltig.delete(schluessel)
return gueltig
}
function pruefeLeistungszeitraum(wert) {
if (wert === undefined || wert === null) return ''
if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) {
throw new AuftragError(400, `Der Leistungszeitraum muss ein Text mit höchstens ${LIMITS.maxLeistungszeitraum} Zeichen sein.`)
}
return wert.trim()
}
/** Gemeinsamer Abschluss aller Arten: Grenzen, Zusatzfelder, Metadaten, Projektdatensatz. */
function schliesseAb({ kunde, art, projekt, auftrag, positionen, leistungszeitraum, arbeitsblattIds = [], createdBy }) {
const summeCents = pruefePositionen(positionen, { max: art === 'schluss' ? SCHLUSS_GRENZEN.zeilen : LIMITS.maxPositionen })
const projektId = projekt?.id ?? auftrag?.projektId ?? ''
const projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140)
const customFields = []
if (art !== 'frei' && projektName) customFields.push({ name: 'Projekt', value: projektName })
if (auftrag) customFields.push({ name: 'Auftrag', value: `${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` })
if (leistungszeitraum) customFields.push({ name: 'Leistungszeitraum', value: leistungszeitraum })
const metadata = { webklarCustomerId: kunde.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500), art }
if (projektId) metadata.projektId = projektId
if (projektName) metadata.projektName = projektName
if (auftrag) {
metadata.auftragId = auftrag.id
if (auftrag.nummer) metadata.auftragNummer = auftrag.nummer
}
const projektRechnung = art === 'frei' ? null : {
kundenId: kunde.$id,
projektId,
auftragId: auftrag?.id ?? '',
art,
betragCents: summeCents,
arbeitsblattIds: art === 'aufwand' ? [...arbeitsblattIds].sort() : [],
rechnungsnummer: '',
aktiv: true,
erstelltVon: String(createdBy || '').slice(0, 255),
erstelltAm: new Date().toISOString(),
}
return { kunde, art, projekt: projekt ?? null, auftrag: auftrag ?? null, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung }
}
/** Baut den RechnungsEntwurf einer Rechnung der Art frei, festbetrag, aufwand, anzahlung oder schluss. */
export async function baueRechnung(eingabe) {
const art = eingabe.art ?? 'frei'
if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.')
const kunde = await ladeFestenKunden(eingabe.customerId)
if (AB_RECHNUNGS_ARTEN.includes(art) && !auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum)
const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy }
if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) })
if (art === 'festbetrag') {
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) })
}
if (art === 'aufwand') return baueAufwand(basis, eingabe)
return baueAbRechnung(basis, eingabe)
}
const MAX_BLAETTER = 400
const KEIN_BLATT = 'Arbeitsblatt nicht gefunden.'
function pruefeZeilentexte(wert) {
if (wert === undefined || wert === null) return {}
if (typeof wert !== 'object' || Array.isArray(wert) || Object.values(wert).some((text) => typeof text !== 'string')) {
throw new AuftragError(400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
}
return wert
}
/** Auftrag der Aufwandsrechnung: ausdruecklich genannt (muss passen) oder die juengste gueltige Aufwands-AB des Projekts. */
async function ladeAufwandAuftrag(kunde, projekt, auftragId) {
if (auftragId !== undefined && auftragId !== null && auftragId !== '') {
if (!auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'aufwand') throw new AuftragError(400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
if (!VEREINBARTE_STATUS.includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
return auftrag
}
if (!auftraegeAktiv(kunde)) return null
const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })
return liste.find((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)) ?? null
}
/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */
async function waehleBlaetter(kunde, ids, eigeneTickets) {
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
}
const eindeutig = [...new Set(ids)]
if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.')
if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT)
const blaetter = await ladeArbeitsblaetterNachIds(eindeutig)
if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT)
if (blaetter.some((b) => !abrechenbar(b))) {
throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
}
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)]))
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
for (const id of eindeutig) {
const b = gueltig.get(id)
if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`)
}
}
return blaetter
}
/** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */
async function standardBlaetter(kunde, projekt, eigeneTickets) {
const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : []
const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id))
const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : [])
let offene = await lade()
if (!offene.length && zeitquelle.length) {
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map()
for (const blatt of await ladeArbeitsblaetter(zeitquelle)) {
if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id))
}
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
if (gueltig.size < belegt.size) offene = await lade()
}
}
if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
return offene
}
async function baueAufwand(basis, eingabe) {
const { kunde } = basis
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId)
const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
const zusatz = zusatzPositionen(eingabe.items)
const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte)
const eigeneTickets = new Set(
(await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id)
)
const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null
const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets)
const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length })
return schliesseAb({
...basis,
projekt,
auftrag,
positionen: [...tage, ...zusatz],
leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis),
arbeitsblattIds: blaetter.map((b) => b.id),
})
}
const FINALISIERT = ['open', 'paid', 'uncollectible']
/**
* Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard
* stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen.
*/
async function ladeFruehere(auftragId) {
const ergebnis = []
for (const doc of await listProjektRechnungen({ auftragId })) {
let roh
try {
roh = await getStripe().invoices.retrieve(doc.invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId))
continue
}
const nummer = roh.number || ''
if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) {
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer }))
}
ergebnis.push({
invoiceId: doc.invoiceId,
art: doc.art,
status: roh.status,
rechnungsnummer: nummer,
datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)),
betragCents: roh.total ?? 0,
})
}
return ergebnis
}
/** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */
async function baueAbRechnung(basis, eingabe) {
const { kunde, art } = basis
const { auftragId } = eingabe
if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
let auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
if (auftrag.status === 'abgerechnet') {
await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck
auftrag = await ladeAuftrag(kunde.$id, auftragId)
}
if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
let projekt = null
if (typeof eingabe.projektId === 'string' && eingabe.projektId) {
projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
} else if (auftrag.projektId) {
projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true })
}
const fruehere = await ladeFruehere(auftrag.id)
const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss'))
if (entwurf) {
throw new AuftragError(409, art === 'schluss'
? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.`
: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.')
}
const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status))
let positionen
if (art === 'anzahlung') {
const angaben = eingabe.anzahlung ?? {}
if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))]
} else {
positionen = schlussPositionen(
auftrag,
finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })),
eingabe.items
)
}
return schliesseAb({ ...basis, projekt, auftrag, positionen })
}
+83 -66
View File
@@ -1,8 +1,11 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { getDocument } from '../appwriteAdmin.js'
import { addDays, berlinDate, daysBetween } from './dates.js'
import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js'
import { withLocks } from '../keyedLock.js'
import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js'
import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js'
import { getSettings } from './settings.js'
/**
@@ -87,6 +90,11 @@ export function mapInvoice(raw, { today }) {
pdfUrl: raw.invoice_pdf || null,
outOfBand: Boolean(metadata.bankTransactionId),
bankTransactionId: metadata.bankTransactionId || '',
projektId: metadata.projektId || '',
projektName: metadata.projektName || '',
art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei',
auftragId: metadata.auftragId || '',
auftragNummer: metadata.auftragNummer || '',
}
}
@@ -126,11 +134,6 @@ export async function listPayouts({ force = false } = {}) {
// Projektrechnungen schreiben
// ---------------------------------------------------------------------------
const MAX_ITEMS = 20
const MAX_DESCRIPTION = 200
const MAX_QUANTITY = 999
const MAX_UNIT_CENTS = 10_000_000
const MAX_TOTAL_CENTS = 99_999_999
const MAX_DAYS_UNTIL_DUE = 60
const DEFAULT_DAYS_UNTIL_DUE = 14
const MAX_MEMO = 1000
@@ -202,45 +205,6 @@ export function invoiceFooter(bankDetailsText = '') {
const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max
/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */
function validateItems(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) {
throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`)
}
const lines = items.map((item, index) => {
const label = `Position ${index + 1}`
const description = typeof item?.description === 'string' ? item.description.trim() : ''
if (description.length < 1 || description.length > MAX_DESCRIPTION) {
throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`)
}
if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) {
throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`)
}
if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) {
throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents }
})
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.')
return lines
}
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
async function loadBillableCustomer(customerId) {
if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.')
let customer
try {
customer = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.')
throw err
}
const stage = String(customer.customerStatus || '').trim().toLowerCase()
if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.')
return customer
}
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
async function retrieveInvoice(invoiceId) {
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
@@ -257,12 +221,34 @@ async function readInvoice(invoiceId) {
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
}
// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche)
const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei')
async function bucheNummer(invoiceId, number, art) {
if (!PROJEKT_ARTEN.includes(art) || !number) return
await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number }))
}
/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */
async function bucheFinalisiert(invoiceId, number, art, auftragId) {
if (!PROJEKT_ARTEN.includes(art)) return
await bucheNummer(invoiceId, number, art)
if (art === 'schluss' && auftragId) await buche('AB abrechnen', () => setzeAbAbgerechnet(auftragId))
}
/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */
async function bucheStorniert(invoiceId, art) {
if (!PROJEKT_ARTEN.includes(art)) return
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
}
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
// Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der
// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen.
async function finalizeAndSend(stripe, invoiceId) {
async function finalizeAndSend(stripe, invoiceId, { art = 'frei', auftragId = '' } = {}) {
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
const number = finalized.number || ''
await bucheFinalisiert(invoiceId, number, art, auftragId)
try {
await stripe.invoices.sendInvoice(invoiceId)
} catch (cause) {
@@ -338,14 +324,20 @@ function validateIdempotencyKey(key) {
*/
export async function createProjectInvoice({
customerId,
art = 'frei',
projektId,
auftragId,
items,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
memo = '',
senden = false,
idempotencyKey,
createdBy,
}) {
const lines = validateItems(items)
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`)
}
@@ -353,10 +345,18 @@ export async function createProjectInvoice({
if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`)
const key = validateIdempotencyKey(idempotencyKey)
const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex')
// Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400)
const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds
const fingerprint = createHash('sha256')
.update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true]))
.digest('hex')
claimCreate(fingerprint)
try {
const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy })
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
// Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander,
// damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen
const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen()
releaseCreate(fingerprint, true)
return invoice
} catch (err) {
@@ -366,11 +366,21 @@ export async function createProjectInvoice({
}
}
async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) {
const customer = await loadBillableCustomer(customerId)
/**
* Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch
* Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre.
*/
export async function rechnungsVorschau(eingabe) {
const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe)
return { positionen, summeCents, leistungszeitraum, customFields }
}
async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) {
const entwurf = await baueRechnung(eingabe)
const customer = entwurf.kunde
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
if (senden) assertAddressForSend(customer, entwurf.summeCents)
const { bankDetailsText } = await getSettings()
const stripe = getStripe()
@@ -389,22 +399,23 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
currency: 'eur',
footer: invoiceFooter(bankDetailsText),
description: note || undefined,
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
metadata: entwurf.metadata,
custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined,
rendering: { pdf: { page_size: 'a4' } },
},
key ? { idempotencyKey: key } : undefined
)
invoiceId = draft.id
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
for (const line of lines) {
await stripe.invoiceItems.create({
customer: stripeCustomerId,
invoice: invoiceId,
description: line.description,
quantity: line.quantity,
unit_amount_decimal: String(line.unitAmountCents),
currency: 'eur',
})
for (const position of entwurf.positionen) {
const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung }
// Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity
if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents })
else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) })
posten.currency = 'eur'
await stripe.invoiceItems.create(posten)
}
if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung)
} catch (err) {
// Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar
if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {})
@@ -413,7 +424,7 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
}
try {
const number = senden ? await finalizeAndSend(stripe, invoiceId) : ''
const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : ''
try {
return await readInvoice(invoiceId)
} catch (cause) {
@@ -448,7 +459,7 @@ export async function sendInvoiceNow(invoiceId) {
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
let customer = null
if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId)
if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId)
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
@@ -459,8 +470,12 @@ export async function sendInvoiceNow(invoiceId) {
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
await finalizeAndSend(getStripe(), invoice.id)
} else await getStripe().invoices.sendInvoice(invoice.id)
await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId })
} else {
// Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen
await bucheNummer(invoice.id, invoice.number, invoice.art)
await getStripe().invoices.sendInvoice(invoice.id)
}
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
@@ -474,6 +489,7 @@ export async function voidInvoice(invoiceId) {
if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.')
try {
await getStripe().invoices.voidInvoice(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
@@ -487,6 +503,7 @@ export async function deleteDraft(invoiceId) {
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.')
try {
await getStripe().invoices.del(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
} finally {
invalidateFinanceCache()
}
+52
View File
@@ -0,0 +1,52 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
const aw = await startAppwriteMock({ schema: {} })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { getStripe } = await import('../server/services/stripeBilling.js')
after(() => Promise.all([aw.close(), st.close()]))
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra })
const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra })
test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => {
const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }]
const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } })
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder)
assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } })
const gelesen = await getStripe().invoices.retrieve(invoice.id)
assert.deepEqual(gelesen.custom_fields, felder)
assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } })
await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' })
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht')
const schlicht = await create()
assert.equal(st.invoices.get(schlicht.id).custom_fields, null)
assert.equal(st.invoices.get(schlicht.id).rendering, null)
})
test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => {
const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' }))
await refused(create({ custom_fields: viele }), /up to 4 custom fields/)
await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/)
await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/)
await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/)
const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } })
assert.equal(st.invoices.get(vier.id).custom_fields.length, 4)
})
test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => {
const invoice = await create()
await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' })
await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 })
const gespeichert = st.invoices.get(invoice.id)
assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]])
assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000])
await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/)
await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/)
assert.equal(st.invoices.get(invoice.id).total, 25000)
})
+303
View File
@@ -0,0 +1,303 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen,
festePositionen, zusatzPositionen, pruefePositionen, aufwandPositionen,
auftragDatum, anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from '../server/services/auftraege/positionen.js'
/** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */
const wirft = (fn, status, message) =>
assert.throws(fn, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
assert.equal(err.message, message)
return true
})
const ANZAHL = 'Eine Rechnung braucht 1 bis 20 Positionen.'
const ok = { description: 'Domain', quantity: 1, unitAmountCents: 1200 }
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
const faelle = [[0, '0'], [15, '0,25'], [30, '0,5'], [45, '0,75'], [60, '1'], [90, '1,5'], [135, '2,25'], [390, '6,5'], [600, '10']]
for (const [minuten, text] of faelle) assert.equal(stundenText(minuten), text, `${minuten} Minuten`)
})
test('leistungszeitraumText: ein Tag, ein Jahr, Jahreswechsel (Halbgeviertstrich U+2013 ohne Leerzeichen)', () => {
assert.equal(leistungszeitraumText('2026-10-06', '2026-10-06'), '06.10.2026')
assert.equal(leistungszeitraumText('2026-10-01', '2026-10-20'), '01.10.–20.10.2026')
assert.equal(leistungszeitraumText('2026-09-28', '2026-10-05'), '28.09.–05.10.2026')
assert.equal(leistungszeitraumText('2026-12-28', '2027-01-05'), '28.12.2026–05.01.2027')
})
test('kalenderwoche: ISO-Woche samt ISO-Jahr, gueltigerTag: nur Tage, die es gibt', () => {
const faelle = [
['2026-10-05', 2026, 41], ['2026-10-06', 2026, 41], ['2026-10-11', 2026, 41], ['2026-10-12', 2026, 42],
['2025-12-29', 2026, 1], ['2026-01-01', 2026, 1], ['2026-12-31', 2026, 53], ['2027-01-03', 2026, 53],
['2027-01-04', 2027, 1], ['2024-12-30', 2025, 1], ['2021-01-03', 2020, 53],
]
for (const [tag, jahr, woche] of faelle) assert.deepEqual(kalenderwoche(tag), { jahr, woche }, tag)
assert.throws(() => kalenderwoche('2026-02-30'), RangeError)
assert.equal(gueltigerTag('2026-02-28'), true)
for (const tag of ['2026-02-30', '2026-13-01', '06.10.2026', '2026-1-1', '', undefined, 20261006]) assert.equal(gueltigerTag(tag), false, String(tag))
})
test('kuerzen: höchstens max Zeichen, Auslassungszeichen am Ende, kein halbes Emoji', () => {
assert.equal(kuerzen('kurz', 10), 'kurz')
assert.equal(kuerzen('abcdefghij', 10), 'abcdefghij')
assert.equal(kuerzen('abcdefghijk', 10), 'abcdefghi…')
assert.equal(kuerzen('ab cdefghijk', 4), 'ab…')
assert.equal(kuerzen('abcd😀fghij', 6), 'abcd…')
})
test('festePositionen: Eingabe wie bisher, Beschreibung getrimmt, Schlüssel pos-n, Betrag = Menge × Einzelpreis', () => {
assert.deepEqual(festePositionen([{ description: ' Website ', quantity: 2, unitAmountCents: 5000 }, ok]), [
{ schluessel: 'pos-1', beschreibung: 'Website', menge: 2, einzelpreisCents: 5000, betragCents: 10000 },
{ schluessel: 'pos-2', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 },
])
assert.equal(festePositionen(Array.from({ length: 20 }, () => ok)).length, 20)
})
test('festePositionen: Grenzen und Meldungen wie bei der bisherigen Rechnung', () => {
wirft(() => festePositionen([]), 400, ANZAHL)
wirft(() => festePositionen(undefined), 400, ANZAHL)
wirft(() => festePositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
for (const description of ['', ' ', 'x'.repeat(201), 5]) wirft(() => festePositionen([{ ...ok, description }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
for (const quantity of [0, 1000, 1.5, '2', -1, null]) wirft(() => festePositionen([ok, { ...ok, quantity }]), 400, 'Position 2: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) {
wirft(() => festePositionen([{ ...ok, unitAmountCents }]), 400, 'Position 1: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.')
}
const neun = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
wirft(() => festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }]), 400, 'Rechnungssumme zu hoch.')
assert.equal(festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }]).at(-1).betragCents, 9_999_999)
})
test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; pruefePositionen: Zahl (mit wählbarer Obergrenze), Texte, Summe mit Minuszeilen', () => {
for (const leer of [undefined, null, []]) assert.deepEqual(zusatzPositionen(leer), [])
assert.deepEqual(zusatzPositionen([{ description: 'Domain', quantity: 2, unitAmountCents: 1500 }]), [
{ schluessel: 'zusatz-1', beschreibung: 'Domain', menge: 2, einzelpreisCents: 1500, betragCents: 3000 },
])
wirft(() => zusatzPositionen('Domain'), 400, ANZAHL)
wirft(() => zusatzPositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
wirft(() => zusatzPositionen([{ ...ok, description: '' }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
const pos = (beschreibung, cents) => ({ schluessel: 'x', beschreibung, menge: 1, einzelpreisCents: cents, betragCents: cents })
assert.equal(pruefePositionen([pos('A', 5000), pos('abzüglich Anzahlung', -2000)]), 3000)
assert.equal(pruefePositionen([pos('A', 99_999_999)]), 99_999_999)
wirft(() => pruefePositionen([]), 400, ANZAHL)
wirft(() => pruefePositionen(Array.from({ length: 21 }, () => pos('A', 1))), 400, ANZAHL)
// eine Schlussrechnung darf bis 40 Zeilen haben: der Aufrufer hebt die Obergrenze an
assert.equal(pruefePositionen(Array.from({ length: 40 }, () => pos('A', 1)), { max: 40 }), 40)
wirft(() => pruefePositionen(Array.from({ length: 41 }, () => pos('A', 1)), { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
wirft(() => pruefePositionen([], { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
wirft(() => pruefePositionen([pos('A', 1), pos('', 1)]), 400, 'Position 2: Die Beschreibung braucht 1 bis 200 Zeichen.')
wirft(() => pruefePositionen([pos('x'.repeat(201), 1)]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
wirft(() => pruefePositionen([pos('A', 99_999_999), pos('B', 1)]), 400, 'Rechnungssumme zu hoch.')
})
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
const blatt = (id, datum, minuten, text = '', over = {}) => ({ id, ticketId: 't1', woid: 'WO-1', datum, minuten, text, aboZeit: false, kommentar: false, ...over })
const werktage = (start, n) => {
const tage = []
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
const tag = new Date(ms)
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
}
return tage
}
test('aufwandPositionen: eine Zeile je Tag mit Texten, Stunden und Satz (Menge 1, Betrag = Stunden × Satz)', () => {
const erg = aufwandPositionen([blatt('w1', '2026-10-06', 90, 'Startseite, Kontaktformular'), blatt('w2', '2026-10-06', 45, '')], { satzCents: 10000 })
assert.deepEqual(erg.positionen, [
{ schluessel: '2026-10-06', beschreibung: `06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 22500, betragCents: 22500 },
])
assert.deepEqual([erg.minuten, erg.von, erg.bis], [135, '2026-10-06', '2026-10-06'])
})
test('aufwandPositionen: Texte eines Tages mit "; " verbunden, Doppelte entfallen, Leerraum vereinheitlicht, ohne Text "Arbeitszeit"', () => {
const erg = aufwandPositionen([
blatt('w1', '2026-10-06', 60, 'Startseite'), blatt('w2', '2026-10-06', 60, 'Kontaktformular\nmit Anhang'), blatt('w3', '2026-10-06', 15, ' Startseite '),
blatt('w4', '2026-10-07', 30, ''),
], { satzCents: 10000 })
assert.deepEqual(erg.positionen.map((p) => p.beschreibung), [
`06.10.2026: Startseite; Kontaktformular mit Anhang – 2,25 Std. à 100,00${NBSP}€`,
`07.10.2026: Arbeitszeit – 0,5 Std. à 100,00${NBSP}€`,
])
assert.deepEqual(erg.positionen.map((p) => p.betragCents), [22500, 5000])
})
test('aufwandPositionen: Reihenfolge der Eingabe egal (nach Tag und Kennung), von und bis, Satz ohne glatte Cent je Viertelstunde', () => {
const erg = aufwandPositionen([blatt('w3', '2026-10-08', 15, 'C'), blatt('w1', '2026-10-06', 15, 'A'), blatt('w2', '2026-10-07', 15, 'B')], { satzCents: 9999 })
assert.deepEqual(erg.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07', '2026-10-08'])
assert.deepEqual([erg.von, erg.bis, erg.minuten], ['2026-10-06', '2026-10-08', 45])
assert.equal(erg.positionen[0].beschreibung, `06.10.2026: A – 0,25 Std. à 99,99${NBSP}€`)
assert.equal(erg.positionen[0].betragCents, 2500) // 15 Minuten × 99,99 €/Std. = 24,9975 € → ganze Cent gerundet
})
test('aufwandPositionen: zeilentexte ersetzen den Text vor dem Gedankenstrich; leer oder unbekannt wirkt nicht; Zeile bleibt bei höchstens 200 Zeichen', () => {
const standard = `06.10.2026: Startseite – 2,25 Std. à 100,00${NBSP}€`
const mit = (zeilentexte, text = 'Startseite') =>
aufwandPositionen([blatt('w1', '2026-10-06', 135, text)], { satzCents: 10000, zeilentexte }).positionen[0].beschreibung
assert.equal(mit({ '2026-10-06': '06.10.2026: Neue Startseite' }), `06.10.2026: Neue Startseite – 2,25 Std. à 100,00${NBSP}€`)
for (const wirkungslos of [undefined, null, {}, { '2026-10-06': ' ' }, { '2026-10-07': 'anderer Tag' }, { '2026-10-06': 5 }]) assert.equal(mit(wirkungslos), standard)
const schluss = ` – 2,25 Std. à 100,00${NBSP}€`
const ersetzt = mit({ '2026-10-06': 'x'.repeat(300) })
assert.equal(ersetzt.length, 200)
assert.ok(ersetzt.endsWith(`x…${schluss}`))
const lang = mit(undefined, 'y'.repeat(300))
assert.equal(lang.length, 200)
assert.ok(lang.startsWith('06.10.2026: yyy'))
assert.ok(lang.endsWith(`y…${schluss}`))
})
test('aufwandPositionen: zu viele Tage -> die ältesten Wochen werden zu KW-Zeilen (ISO-Woche, älteste zuerst), Einzeltage bleiben Tageszeilen', () => {
// KW 41: 5., 6., 7.10.; KW 42: 12., 13., 14.10.; KW 43: 19., 20.10.; je 90 Minuten
const tage = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20']
const blaetter = tage.map((tag, i) => blatt(`w${i + 1}`, tag, 90, 'ABCDEFGH'[i]))
const lauf = (maxZeilen) => aufwandPositionen(blaetter, { satzCents: 10000, maxZeilen })
assert.equal(lauf(8).positionen.length, 8)
assert.deepEqual(lauf(6).positionen.map((p) => p.schluessel), ['KW-2026-41', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20'])
assert.deepEqual(lauf(6).positionen[0], {
schluessel: 'KW-2026-41', beschreibung: `KW 41/2026: A; B; C – 4,5 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 45000, betragCents: 45000,
})
assert.deepEqual(lauf(5).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19', '2026-10-20'])
assert.deepEqual(lauf(3).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', 'KW-2026-43'])
assert.equal(lauf(3).positionen[2].beschreibung, `KW 43/2026: G; H – 3 Std. à 100,00${NBSP}€`)
for (const n of [8, 6, 5, 3]) assert.equal(lauf(n).positionen.reduce((s, p) => s + p.betragCents, 0), 8 * 15000, `Summe bei ${n} Zeilen`)
assert.deepEqual([lauf(3).von, lauf(3).bis, lauf(3).minuten], ['2026-10-05', '2026-10-20', 720])
wirft(() => lauf(2), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 2 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
// eine Woche mit nur einem Tag wird nicht zusammengefasst (es spart keine Zeile)
const einzel = [blatt('a', '2026-10-06', 60, 'A'), blatt('b', '2026-10-12', 60, 'B'), blatt('c', '2026-10-13', 60, 'C'), blatt('d', '2026-10-14', 60, 'D')]
assert.deepEqual(aufwandPositionen(einzel, { satzCents: 10000, maxZeilen: 2 }).positionen.map((p) => p.schluessel), ['2026-10-06', 'KW-2026-42'])
})
test('aufwandPositionen: Standard 20 Zeilen, zeilentexte auch für KW-Zeilen, Woche 53 über den Jahreswechsel, mehr als 20 Wochen -> 400', () => {
const blaetter = werktage('2026-10-05', 25).map((tag, i) => blatt(`w${String(i + 1).padStart(2, '0')}`, tag, 60, `T${i + 1}`)) // KW 41 bis 45
const erg = aufwandPositionen(blaetter, { satzCents: 10000, zeilentexte: { 'KW-2026-42': 'KW 42/2026: Relaunch Teil 2' } })
assert.equal(erg.positionen.length, 17)
assert.deepEqual(erg.positionen.slice(0, 3).map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19'])
assert.equal(erg.positionen[1].beschreibung, `KW 42/2026: Relaunch Teil 2 – 5 Std. à 100,00${NBSP}€`)
assert.equal(erg.minuten, 1500)
const wechsel = ['2026-12-30', '2026-12-31', '2027-01-01', '2027-01-04'].map((tag, i) => blatt(`j${i}`, tag, 60, `J${i}`))
const zwei = aufwandPositionen(wechsel, { satzCents: 10000, maxZeilen: 2 })
assert.deepEqual(zwei.positionen.map((p) => p.schluessel), ['KW-2026-53', '2027-01-04'])
assert.equal(zwei.positionen[0].beschreibung, `KW 53/2026: J0; J1; J2 – 3 Std. à 100,00${NBSP}€`)
const montage = Array.from({ length: 21 }, (_, i) => new Date(Date.UTC(2026, 0, 5 + 7 * i)).toISOString().slice(0, 10))
const wochen = montage.map((tag, i) => blatt(`m${i + 1}`, tag, 60, 'x'))
wirft(() => aufwandPositionen(wochen, { satzCents: 10000 }), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 20 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
assert.equal(aufwandPositionen(wochen.slice(0, 20), { satzCents: 10000 }).positionen.length, 20)
})
test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () => {
const eins = [blatt('w1', '2026-10-06', 60, 'A')]
for (const satzCents of [0, -1, 1.5, '10000', 10_000_001, undefined]) wirft(() => aufwandPositionen(eins, { satzCents }), 400, 'Der Stundensatz ist ungültig.')
assert.deepEqual(aufwandPositionen([], { satzCents: 10000 }), { positionen: [], minuten: 0, von: null, bis: null })
for (const kaputt of [{ datum: '06.10.2026' }, { datum: '2026-13-01' }, { minuten: 0 }, { minuten: 7.5 }, { minuten: '60' }]) {
wirft(() => aufwandPositionen([blatt('w1', '2026-10-06', 60, 'A', kaputt)], { satzCents: 10000 }), 400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
}
})
const AB = {
id: 'a1', nummer: 'AB-2026-0002', titel: 'Website anelia-wolf.com', art: 'festpreis', status: 'versendet',
gesendetAm: '2026-10-06T08:15:00.000+00:00', auftragVom: '2026-10-05', summeCents: 80000,
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
}
const ANZ_KOPF = 'gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)'
test('auftragDatum: Berliner Tag des Sendens, sonst der Tag der Absprache', () => {
assert.equal(auftragDatum(AB), '2026-10-06')
assert.equal(auftragDatum({ ...AB, gesendetAm: '2026-10-06T22:30:00.000+00:00' }), '2026-10-07')
assert.equal(auftragDatum({ ...AB, gesendetAm: null }), '2026-10-05')
})
test('anzahlungPosition: ohne Angaben der Prozentsatz aus dem Zahlungsplan (sonst 50 %), Text wie in der Spec', () => {
assert.deepEqual(anzahlungPosition(AB, {}, 0), {
schluessel: 'anzahlung', beschreibung: `Anzahlung 50 % ${ANZ_KOPF}`, menge: 1, einzelpreisCents: 40000, betragCents: 40000,
})
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }, {}, 0).betragCents, 40000)
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' } }, undefined, 0).betragCents, 24000)
})
test('anzahlungPosition: Prozent oder Betrag, Bezeichnung änderbar, Text je Variante, lange Titel gekürzt', () => {
assert.equal(anzahlungPosition(AB, { prozent: 30 }, 0).betragCents, 24000)
assert.equal(anzahlungPosition(AB, { prozent: 100 }, 0).betragCents, 80000)
const teil = anzahlungPosition(AB, { prozent: 25, bezeichnung: ' Teilrechnung ' }, 0)
assert.deepEqual([teil.beschreibung, teil.betragCents], [`Teilrechnung 25 % ${ANZ_KOPF}`, 20000])
const betrag = anzahlungPosition(AB, { betragCents: 25000 }, 0)
assert.deepEqual([betrag.beschreibung, betrag.betragCents], [`Anzahlung ${ANZ_KOPF}`, 25000])
const komma = anzahlungPosition(AB, { prozent: 33.3 }, 0)
assert.deepEqual([komma.beschreibung, komma.betragCents], [`Anzahlung 33,3 % ${ANZ_KOPF}`, 26640])
const lang = anzahlungPosition({ ...AB, titel: 'T'.repeat(300) }, { prozent: 50 }, 0).beschreibung
assert.equal(lang.length, 200)
assert.ok(lang.startsWith('Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (TTT'))
assert.ok(lang.endsWith('T…)'))
assert.equal(anzahlungPosition({ ...AB, titel: '' }, { prozent: 50 }, 0).beschreibung, 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026')
})
test('anzahlungPosition: höchstens Summe minus bereits Berechnetes, nichts mehr offen -> 409', () => {
assert.equal(anzahlungPosition(AB, {}, 40000).betragCents, 40000)
wirft(() => anzahlungPosition(AB, {}, 50000), 400, `Die Anzahlung darf höchstens 300,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
wirft(() => anzahlungPosition(AB, { betragCents: 80001 }, 0), 400, `Die Anzahlung darf höchstens 800,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
wirft(() => anzahlungPosition(AB, {}, 80000), 409, 'Nichts mehr offen.')
wirft(() => anzahlungPosition(AB, {}, 90000), 409, 'Nichts mehr offen.')
})
test('anzahlungPosition: ungültige Angaben', () => {
wirft(() => anzahlungPosition(AB, { prozent: 30, betragCents: 100 }, 0), 400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
for (const prozent of [0, -5, 100.5, Number.NaN, '50', Infinity]) wirft(() => anzahlungPosition(AB, { prozent }, 0), 400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
for (const betragCents of [0, -5, 12.5, '100', Number.NaN]) wirft(() => anzahlungPosition(AB, { betragCents }, 0), 400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
wirft(() => anzahlungPosition(AB, { prozent: 0.0001 }, 0), 400, 'Der Betrag muss mindestens 0,01 € betragen.')
for (const bezeichnung of ['', ' ', 'B'.repeat(61), 7]) wirft(() => anzahlungPosition(AB, { bezeichnung }, 0), 400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
})
const FRUEH7 = { invoiceId: 'in_7', rechnungsnummer: 'WEBKLAR-0007', datum: '2026-10-08', betragCents: 40000, art: 'anzahlung' }
test('schlussPositionen: alle AB-Positionen, Zusatz, dann je frühere Rechnung eine Minuszeile (negativer Betrag, Menge 1)', () => {
assert.deepEqual(schlussPositionen(AB, [FRUEH7], [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }]), [
{ schluessel: 'ab-1', beschreibung: 'Website', menge: 1, einzelpreisCents: 60000, betragCents: 60000 },
{ schluessel: 'ab-2', beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000, betragCents: 20000 },
{ schluessel: 'zusatz-1', beschreibung: 'Domain-Registrierung', menge: 1, einzelpreisCents: 1500, betragCents: 1500 },
{ schluessel: 'abzug-in_7', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 },
])
})
test('schlussPositionen: ohne frühere Rechnungen nur AB-Positionen; Teilrechnung für andere Arten; Abzüge nach Datum; Summe 0 ohne Zeile', () => {
assert.deepEqual(schlussPositionen(AB, [], []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
assert.deepEqual(schlussPositionen(AB, []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
const spaeter = { invoiceId: 'in_9', rechnungsnummer: 'WEBKLAR-0009', datum: '2026-10-12', betragCents: 10000, art: 'festbetrag' }
const ohneBetrag = { ...FRUEH7, invoiceId: 'in_0', betragCents: 0 }
assert.deepEqual(schlussPositionen(AB, [spaeter, ohneBetrag, FRUEH7]).slice(2).map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
['abzug-in_7', 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', -40000],
['abzug-in_9', 'abzüglich Teilrechnung, Rechnung WEBKLAR-0009 vom 12.10.2026', -10000],
])
})
test('schlussPositionen: Summe nie 0 oder darunter (409), nie über 99.999.999 (400)', () => {
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 80000 }]), 409, 'Nichts mehr offen.')
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 90000 }]), 409, 'Nichts mehr offen.')
assert.equal(schlussPositionen(AB, [{ ...FRUEH7, betragCents: 79999 }]).reduce((s, p) => s + p.betragCents, 0), 1)
const gross = { ...AB, positionen: Array.from({ length: 10 }, (_, i) => ({ beschreibung: `P${i}`, menge: 1, einzelpreisCents: 10_000_000 })) }
wirft(() => schlussPositionen(gross, []), 400, 'Rechnungssumme zu hoch.')
})
test('schlussPositionen: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge), jede Gruppe mit eigener Grenze und Meldung', () => {
assert.deepEqual(SCHLUSS_GRENZEN, { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 })
const abMit = (n) => ({ ...AB, summeCents: n * 1000, positionen: Array.from({ length: n }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 })) })
const abzug = (n) => ({ invoiceId: `in_k${String(n).padStart(2, '0')}`, rechnungsnummer: `WK-${String(n).padStart(4, '0')}`, datum: '2026-10-08', betragCents: 100, art: 'anzahlung' })
const abzuege = (n) => Array.from({ length: n }, (_, i) => abzug(i + 1))
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
// 20 AB-Positionen und ein Abzug: gültig (21 Zeilen)
assert.equal(schlussPositionen(abMit(20), abzuege(1)).length, 21)
// volle Ausbaustufe: 20 + 10 + 10 = 40 Zeilen, Reihenfolge AB, Nachträge, Abzüge
const voll = schlussPositionen(abMit(20), abzuege(10), nachtraege(10))
assert.equal(voll.length, 40)
assert.deepEqual([0, 19, 20, 29, 30, 39].map((i) => voll[i].schluessel), ['ab-1', 'ab-20', 'zusatz-1', 'zusatz-10', 'abzug-in_k01', 'abzug-in_k10'])
assert.equal(voll.reduce((s, p) => s + p.betragCents, 0), 20000 + 5000 - 1000)
// jede Gruppe über ihrer Grenze: 400 mit eigener Meldung
wirft(() => schlussPositionen(abMit(20), abzuege(11)), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
wirft(() => schlussPositionen(abMit(20), [], nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
wirft(() => schlussPositionen(abMit(21), []), 400, 'Die Auftragsbestätigung hat 21 Positionen; eine Schlussrechnung übernimmt höchstens 20.')
// andere Eingaben für die Zusatzpositionen prüft zusatzPositionen wie sonst
wirft(() => schlussPositionen(AB, [], 'Domain'), 400, ANZAHL)
})
+100
View File
@@ -0,0 +1,100 @@
// Gemeinsame Testdaten für die Rechnungstests des Strangs L2. Erst nach useTestEnv() importieren (zieht config.js).
import { config } from '../server/config.js'
import { deleteDocument } from '../server/services/appwriteAdmin.js'
import { createAuftrag, updateAuftrag } from '../server/services/auftraege/auftragStore.js'
import { createProjektRechnung } from '../server/services/auftraege/projektRechnungenStore.js'
/** n Arbeitstage (Montag bis Freitag) ab start (YYYY-MM-DD, einschließlich). */
export function werktage(start, n) {
const tage = []
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
const tag = new Date(ms)
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
}
return tage
}
const deutsch = (tag) => tag.split('-').reverse().join('.')
/**
* Kunden k1 (fest, mit Anschrift: Frau Wolf), k2 (fest), lead1 (Interessent); Projekte p1 (k1, Ticket t1), p2 (k2, Ticket t2),
* p3 (k1, Ticket t4); Tickets t1 bis t4 (t2 gehört k2); Arbeitsblätter ws1 bis wsT3 (Oktober) und wl01 bis wl20 (Großprojekt).
*/
export function seedRechnungsDaten(aw) {
const kunde = (id, over = {}) =>
aw.seed('customers', { $id: id, email: `${id}@example.invalid`, name: `Kunde ${id}`, customerStatus: 'active', status: 'active', street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin', ...over })
kunde('k1', { name: 'Anelia Wolf', email: 'wolf@example.invalid' })
kunde('k2')
kunde('lead1', { customerStatus: 'lead' })
const projekt = (id, customerId, projectName, ticketId) => aw.seed('websiteProjects', { $id: id, customerId, projectName, ticketId })
projekt('p1', 'k1', 'Website anelia-wolf.com', 't1')
projekt('p2', 'k2', 'Fremdes Projekt', 't2')
projekt('p3', 'k1', 'Großprojekt', 't4')
for (const [id, customerId, woid] of [['t1', 'k1', 'WO-100'], ['t2', 'k2', 'WO-200'], ['t3', 'k1', 'WO-101'], ['t4', 'k1', 'WO-102']]) {
aw.seed('workorders', { $id: id, customerId, woid, topic: `Ticket ${woid}`, status: 'occupied' })
}
const blatt = (id, workorderId, startDate, totalTime, details, over = {}) =>
aw.seed('worksheets', { $id: id, workorderId, woid: 'WO', startDate, totalTime, details, serviceType: 'remote', countsToPlan: false, isComment: false, ...over })
blatt('ws1', 't1', '06.10.2026', 90, 'Startseite')
blatt('ws2', 't1', '06.10.2026', 45, 'Kontaktformular')
blatt('ws3', 't1', '07.10.2026', 60, 'Impressum')
blatt('wsAbo', 't1', '07.10.2026', 60, 'Wartung', { countsToPlan: true })
blatt('wsKomm', 't1', '07.10.2026', 30, 'Notiz', { isComment: true })
blatt('wsNull', 't1', '07.10.2026', 0, 'Leer')
blatt('wsFremd', 't2', '07.10.2026', 60, 'Fremd')
blatt('wsT3', 't3', '08.10.2026', 30, 'Zusatzticket')
// Großprojekt: 20 Arbeitstage (vier Wochen, KW 45 bis 48) zu je 60 Minuten
werktage('2026-11-02', 20).forEach((tag, i) => blatt(`wl${String(i + 1).padStart(2, '0')}`, 't4', deutsch(tag), 60, `Etappe ${i + 1}`))
}
/**
* Eine versendete Festpreis-AB des Kunden k1 zu p1 (80.000 Cent: Website 600 €, Texte 2 × 100 €; Anzahlung 50 %; AB-2026-0002,
* gesendet am 06.10.2026) über die L0-Speicher anlegen. over gilt für createAuftrag, patch für das anschließende updateAuftrag
* (Status, Nummer, bestaetigtAm, …).
*/
export async function legeAuftragAn(over = {}, patch = {}) {
const roh = await createAuftrag({
kundenId: 'k1', projektId: 'p1', titel: 'Website anelia-wolf.com', zeitquelle: [], art: 'festpreis',
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: 'kenso@webklar.com',
...over,
})
return updateAuftrag(roh.id, { status: 'versendet', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, gesendetAm: '2026-10-06T08:15:00.000Z', ...patch })
}
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
/**
* Eine Projektrechnung wie nach createProjectInvoice: Stripe-Rechnung im Nachbau (status draft, open, paid oder void; ohneStripe: die
* Rechnung gibt es bei Stripe nicht, z. B. im Dashboard gelöscht) plus aktiver Projektdatensatz. docNummer ist die Nummer im Datensatz
* (Vorgabe: die der Rechnung, bei einem Entwurf leer).
*/
export async function legeRechnungAn(st, id, {
status = 'draft', nummer = 'WEBKLAR-0007', total = 40000, art = 'aufwand', auftragId = '', projektId = 'p1',
arbeitsblattIds = [], finalisiert = '2026-10-08T10:00:00.000Z', ohneStripe = false, docNummer,
} = {}) {
const entwurf = status === 'draft'
if (!ohneStripe) {
st.invoices.set(id, {
id, object: 'invoice', number: entwurf ? null : nummer, status, customer: 'cus_x', created: unix(finalisiert), total, amount_due: total,
amount_paid: status === 'paid' ? total : 0, amount_remaining: status === 'paid' ? 0 : total,
status_transitions: { finalized_at: entwurf ? null : unix(finalisiert) }, metadata: { art }, lines: { object: 'list', data: [], has_more: false },
})
}
await createProjektRechnung(id, {
kundenId: 'k1', projektId, auftragId, art, betragCents: total, arbeitsblattIds,
rechnungsnummer: docNummer ?? (entwurf ? '' : nummer), aktiv: true, erstelltVon: 'k', erstelltAm: new Date().toISOString(),
})
}
/** Aufträge und Projektdatensätze löschen, Stripe-Nachbau (Rechnungen, Kunden, Aufrufe) leeren. */
export async function leereRechnungsDaten(aw, st) {
for (const sammlung of [config.collections.auftraege, config.collections.projektRechnungen]) {
for (const id of aw.docs(sammlung).keys()) await deleteDocument(sammlung, id)
}
st.invoices.clear()
st.customers.clear()
st.calls.length = 0
}
+409
View File
@@ -0,0 +1,409 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
// Nachbau von Appwrite mit dem Schema und den Datumsfeldern der neuen Collections und dem Standard-Limit 25 (wie in den Tests von L0)
const aw = await startAppwriteMock({
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
datetimes: AUFTRAEGE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const { baueRechnung } = await import('../server/services/auftraege/rechnungsbau.js')
const { getProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
after(() => Promise.all([aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(() => leereRechnungsDaten(aw, st))
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
const wirft = (promise, status, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
assert.equal(err.message, message)
return true
})
test('baueRechnung frei: freie Positionen, nur Kopf-Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
assert.equal(e.art, 'frei')
assert.equal(e.kunde.$id, 'k1')
assert.equal(e.projekt, null)
assert.equal(e.auftrag, null)
assert.deepEqual(e.positionen, [{ schluessel: 'pos-1', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 }])
assert.equal(e.summeCents, 1200)
assert.equal(e.leistungszeitraum, '')
assert.deepEqual(e.customFields, [])
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'frei' })
assert.equal(e.projektRechnung, null)
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN })).metadata.erstelltVon, '')
})
test('baueRechnung frei: Projekt, Auftrag, Arbeitsblätter und Anzahlung der Eingabe bleiben ohne Wirkung', async () => {
const e = await baueRechnung({ customerId: 'k1', art: 'frei', items: POSTEN, projektId: 'p1', auftragId: 'a1', arbeitsblattIds: ['ws1'], anzahlung: { prozent: 10 } })
assert.equal(e.projekt, null)
assert.equal(e.auftrag, null)
assert.equal(e.projektRechnung, null)
assert.deepEqual(e.customFields, [])
assert.deepEqual(Object.keys(e.metadata).sort(), ['art', 'erstelltVon', 'quelle', 'webklarCustomerId'])
})
test('baueRechnung: Leistungszeitraum ist optional, wird getrimmt und hat höchstens 140 Zeichen', async () => {
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: ' Oktober 2026 ' })
assert.equal(e.leistungszeitraum, 'Oktober 2026')
assert.deepEqual(e.customFields, [{ name: 'Leistungszeitraum', value: 'Oktober 2026' }])
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: 'z'.repeat(140) })).leistungszeitraum.length, 140)
for (const leer of [undefined, null, '', ' ']) assert.deepEqual((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: leer })).customFields, [])
const MELDUNG = 'Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.'
for (const leistungszeitraum of ['z'.repeat(141), 5, {}]) await wirft(baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum }), 400, MELDUNG)
})
test('baueRechnung: unbekannte Art, unbekannter oder nicht fester Kunde, ungültige Positionen -> 400 ohne Stripe-Aufruf', async () => {
for (const art of ['quatsch', 'FREI', 5, {}]) await wirft(baueRechnung({ customerId: 'k1', art, items: POSTEN }), 400, 'Unbekannte Rechnungsart.')
for (const customerId of [undefined, '', 'gibtEsNicht', '../x', 5]) await wirft(baueRechnung({ customerId, items: POSTEN }), 400, 'Kunde nicht gefunden.')
await wirft(baueRechnung({ customerId: 'lead1', items: POSTEN }), 400, 'Rechnungen nur für feste Kunden.')
await wirft(baueRechnung({ customerId: 'k1' }), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
assert.equal(st.calls.length, 0)
})
test('baueRechnung festbetrag: Projekt, Zusatzfeld Projekt, Metadaten und Projektdatensatz', async () => {
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
assert.equal(e.auftrag, null)
assert.equal(e.summeCents, 50000)
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }])
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'festbetrag', projektId: 'p1', projektName: 'Website anelia-wolf.com' })
const { erstelltAm, ...rest } = e.projektRechnung
assert.match(erstelltAm, /^\d{4}-\d{2}-\d{2}T/)
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 50000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
// eine genannte auftragId bleibt bei dieser Art ohne Wirkung
assert.equal((await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', auftragId: 'a1', items })).auftrag, null)
})
test('baueRechnung festbetrag: lange Projektnamen werden auf 140 Zeichen gekürzt, leere ersetzt', async () => {
aw.seed('websiteProjects', { $id: 'plang', customerId: 'k1', projectName: 'P'.repeat(200), ticketId: '' })
aw.seed('websiteProjects', { $id: 'psub', customerId: 'k1', projectName: '', subdomain: 'wolf-test', ticketId: '' })
aw.seed('websiteProjects', { $id: 'pleer', customerId: 'k1', ticketId: '' })
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'plang', items: POSTEN })
assert.equal(e.customFields[0].value.length, 140)
assert.ok(e.customFields[0].value.endsWith('P…'))
assert.equal(e.metadata.projektName, e.customFields[0].value)
const name = async (projektId) => (await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items: POSTEN })).customFields[0].value
assert.equal(await name('psub'), 'wolf-test')
assert.equal(await name('pleer'), 'Projekt')
})
test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projekte sind 404 (kein Unterschied), ungültige Positionen 400', async () => {
const mit = (projektId, items = POSTEN) => baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items })
for (const projektId of [undefined, '', null, 5]) await wirft(mit(projektId), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
for (const projektId of ['gibtEsNicht', 'p2', '../x']) await wirft(mit(projektId), 404, 'Projekt nicht gefunden.')
await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
assert.equal(st.calls.length, 0)
})
const eingabe = (over = {}) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', createdBy: 'kenso@webklar.com', ...over })
const aufwandAb = (patch = {}, over = {}) =>
legeAuftragAn(
{ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 12000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['t1', 't3'], ...over },
{ nummer: 'AB-2026-0005', laufnummer: 5, status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z', ...patch }
)
test('baueRechnung aufwand: alle offenen Blätter des Projekttickets, eine Zeile je Tag, Satz 100 €, Zeitraum, Zusatzfeld', async () => {
const e = await baueRechnung(eingabe())
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 22500], ['2026-10-07', 10000]])
assert.equal(e.positionen[0].beschreibung, `06.10.2026: Startseite; Kontaktformular – 2,25 Std. à 100,00${NBSP}€`)
assert.equal(e.positionen[1].beschreibung, `07.10.2026: Impressum – 1 Std. à 100,00${NBSP}€`)
assert.equal(e.summeCents, 32500)
assert.equal(e.leistungszeitraum, '06.10.–07.10.2026')
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Leistungszeitraum', value: '06.10.–07.10.2026' }])
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
assert.equal(e.auftrag, null)
assert.equal(e.metadata.art, 'aufwand')
assert.equal(e.metadata.projektId, 'p1')
const { erstelltAm, ...rest } = e.projektRechnung
assert.ok(erstelltAm)
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'aufwand', betragCents: 32500, arbeitsblattIds: ['ws1', 'ws2', 'ws3'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
assert.equal(st.calls.length, 0, 'Aufwand liest nichts bei Stripe')
})
test('baueRechnung aufwand: ausgewählte Blätter (auch von einem anderen Ticket des Kunden), Zusatzposition, eigener Zeitraum und Zeilentext', async () => {
const e = await baueRechnung(eingabe({
arbeitsblattIds: ['ws2', 'wsT3', 'ws2'], items: POSTEN, leistungszeitraum: ' Oktober 2026 ',
zeilentexte: { '2026-10-08': '08.10.2026: Zusatzticket Impressum' },
}))
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 7500], ['2026-10-08', 5000], ['zusatz-1', 1200]])
assert.equal(e.positionen[1].beschreibung, `08.10.2026: Zusatzticket Impressum – 0,5 Std. à 100,00${NBSP}€`)
assert.equal(e.summeCents, 13700)
assert.equal(e.leistungszeitraum, 'Oktober 2026')
assert.deepEqual(e.projektRechnung.arbeitsblattIds, ['ws2', 'wsT3'])
})
test('baueRechnung aufwand: ungültige Auswahl -> 400 ohne Stripe-Aufruf (fremde, unbekannte, unabrechenbare, zu viele Blätter)', async () => {
const mit = (arbeitsblattIds) => baueRechnung(eingabe({ arbeitsblattIds }))
await wirft(mit([]), 400, 'Keine Arbeitsblätter ausgewählt.')
for (const kaputt of ['ws1', [5], { 0: 'ws1' }]) await wirft(mit(kaputt), 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
for (const id of ['gibtEsNicht', 'wsFremd', '../x']) await wirft(mit(['ws1', id]), 400, 'Arbeitsblatt nicht gefunden.')
for (const id of ['wsAbo', 'wsKomm', 'wsNull']) {
await wirft(mit(['ws1', id]), 400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
}
await wirft(mit(Array.from({ length: 401 }, (_, i) => `ws${i}`)), 400, 'Zu viele Arbeitsblätter ausgewählt (höchstens 400).')
assert.equal(st.calls.length, 0)
})
test('baueRechnung aufwand: bereits abgerechnete Blätter -> 409 mit der Nummer von Stripe oder Entwurf, stornierte geben frei, Standardauswahl nur Offenes', async () => {
await legeRechnungAn(st, 'in_alt1', { status: 'open', nummer: 'WEBKLAR-0009', arbeitsblattIds: ['ws1'] })
const mit = () => baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws3'] }))
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).')
st.invoices.set('in_alt1', { ...st.invoices.get('in_alt1'), status: 'draft', number: null })
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws2', 'ws3'])
await updateProjektRechnung('in_alt1', { aktiv: false })
assert.equal((await mit()).positionen.length, 2)
await legeRechnungAn(st, 'in_alt2', { arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
await wirft(baueRechnung(eingabe()), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
})
test('baueRechnung aufwand: Selbstheilung - eine im Stripe-Dashboard stornierte oder gelöschte Rechnung hält ihre Blätter nicht fest, eine fehlende Nummer wird nachgetragen', async () => {
await legeRechnungAn(st, 'in_leer1', { status: 'void', nummer: 'WEBKLAR-0010', arbeitsblattIds: ['ws1'] })
await legeRechnungAn(st, 'in_leer2', { ohneStripe: true, arbeitsblattIds: ['ws2'] })
const e = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws2'] }))
assert.deepEqual([e.positionen.length, e.summeCents], [1, 22500])
for (const id of ['in_leer1', 'in_leer2']) assert.equal((await getProjektRechnung(id)).aktiv, false, id)
// auch die Standardauswahl heilt, bevor sie "nichts offen" meldet
await legeRechnungAn(st, 'in_leer3', { status: 'void', nummer: 'WEBKLAR-0012', arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
assert.equal((await getProjektRechnung('in_leer3')).aktiv, false)
// bei Stripe finalisiert, im Datensatz fehlt die Nummer: die Meldung nennt die Nummer von Stripe, der Datensatz wird nachgetragen
await legeRechnungAn(st, 'in_offen', { status: 'open', nummer: 'WEBKLAR-0011', docNummer: '', arbeitsblattIds: ['ws3'] })
await wirft(baueRechnung(eingabe({ arbeitsblattIds: ['ws3'] })), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0011).')
assert.equal((await getProjektRechnung('in_offen')).rechnungsnummer, 'WEBKLAR-0011')
})
test('baueRechnung aufwand: Satz, Auftrag und Zeitquelle aus der jüngsten gültigen Aufwands-AB; ohne Schalter bleibt es bei 100 €', async () => {
const ab = await aufwandAb()
const e = await baueRechnung(eingabe())
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 27000], ['2026-10-07', 12000], ['2026-10-08', 6000]])
assert.ok(e.positionen[0].beschreibung.endsWith(` – 2,25 Std. à 120,00${NBSP}€`))
assert.equal(e.leistungszeitraum, '06.10.–08.10.2026')
assert.equal(e.auftrag.nummer, 'AB-2026-0005')
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum'])
assert.equal(e.customFields[1].value, 'AB-2026-0005 vom 06.10.2026')
assert.deepEqual([e.metadata.auftragId, e.metadata.auftragNummer], [ab.id, 'AB-2026-0005'])
assert.equal(e.projektRechnung.auftragId, ab.id)
// ein neuerer, aber stornierter Aufwandsauftrag ändert nichts
await aufwandAb({ nummer: 'AB-2026-0006', status: 'storniert' }, { stundensatzCents: 15000 })
assert.equal((await baueRechnung(eingabe())).auftrag.nummer, 'AB-2026-0005')
config.auftraege.mode = 'off'
try {
const ohne = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1'] }))
assert.equal(ohne.auftrag, null)
assert.equal(ohne.positionen[0].betragCents, 15000) // 90 Minuten zu 100 €
await wirft(baueRechnung(eingabe({ auftragId: ab.id })), 400, SCHALTER_AUS)
} finally {
config.auftraege.mode = 'on'
}
})
test('baueRechnung aufwand: ausdrücklich genannter Auftrag muss passen (Aufwand, eigener Kunde, gültig, gleiches Projekt)', async () => {
const festpreis = await legeAuftragAn()
const fremd = await legeAuftragAn({ kundenId: 'k2', projektId: 'p2' }, { nummer: 'AB-2026-0008' })
const entwurf = await aufwandAb({ nummer: 'AB-2026-0009', status: 'entwurf' })
const anderes = await aufwandAb({ nummer: 'AB-2026-0010' }, { projektId: 'p3' })
const mit = (auftragId) => baueRechnung(eingabe({ auftragId }))
await wirft(mit(festpreis.id), 400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
await wirft(mit(fremd.id), 404, 'Auftrag nicht gefunden.')
await wirft(mit('gibtEsNicht'), 404, 'Auftrag nicht gefunden.')
await wirft(mit(entwurf.id), 400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
await wirft(mit(anderes.id), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
const gueltig = await aufwandAb({ nummer: 'AB-2026-0011' })
assert.equal((await mit(gueltig.id)).auftrag.id, gueltig.id)
})
test('baueRechnung aufwand: Zusatzpositionen zählen zu den 20 Zeilen, die ältesten Wochen werden zu KW-Zeilen', async () => {
const ohne = await baueRechnung(eingabe({ projektId: 'p3' }))
assert.equal(ohne.positionen.length, 20)
assert.equal(ohne.positionen[0].schluessel, '2026-11-02')
const mit = await baueRechnung(eingabe({ projektId: 'p3', items: POSTEN }))
assert.equal(mit.positionen.length, 17)
assert.deepEqual([mit.positionen[0].schluessel, mit.positionen[1].schluessel, mit.positionen.at(-1).schluessel], ['KW-2026-45', '2026-11-09', 'zusatz-1'])
assert.equal(mit.summeCents, 20 * 10000 + 1200)
assert.ok(mit.positionen.every((p) => p.beschreibung.length <= 200))
})
test('baueRechnung aufwand: Projekt ist Pflicht, fremdes Projekt 404, Projekt ohne offene Blätter 409, Zeilentexte müssen ein Objekt mit Texten sein', async () => {
for (const projektId of [undefined, '', null]) await wirft(baueRechnung(eingabe({ projektId })), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
await wirft(baueRechnung(eingabe({ projektId: 'p2' })), 404, 'Projekt nicht gefunden.')
aw.seed('websiteProjects', { $id: 'pohne', customerId: 'k1', projectName: 'Ohne Ticket', ticketId: '' })
await wirft(baueRechnung(eingabe({ projektId: 'pohne' })), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
for (const zeilentexte of [['x'], 'x', 5, { '2026-10-06': 5 }]) {
await wirft(baueRechnung(eingabe({ zeilentexte })), 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
}
assert.equal((await baueRechnung(eingabe({ zeilentexte: null }))).positionen.length, 2)
})
/** Frühere Rechnung zur AB (Art anzahlung, wenn nicht anders gesagt): Stripe-Rechnung im Nachbau plus aktiver Projektdatensatz. */
const fruehereRechnung = (ab, { id, art = 'anzahlung', status = 'open', ...optionen }) => legeRechnungAn(st, id, { art, status, auftragId: ab.id, ...optionen })
test('baueRechnung anzahlung: Position aus dem Zahlungsplan, Zusatzfelder Projekt und Auftrag, Metadaten, Projektdatensatz', async () => {
const ab = await legeAuftragAn()
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, createdBy: 'kenso@webklar.com' })
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
['anzahlung', 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)', 40000],
])
assert.equal(e.summeCents, 40000)
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }])
assert.deepEqual(e.metadata, {
webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'anzahlung',
projektId: 'p1', projektName: 'Website anelia-wolf.com', auftragId: ab.id, auftragNummer: 'AB-2026-0002',
})
assert.equal(e.auftrag.id, ab.id)
assert.equal(e.projekt.id, 'p1')
const { erstelltAm, ...rest } = e.projektRechnung
assert.ok(erstelltAm)
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: ab.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
assert.equal(st.calls.length, 0, 'ohne frühere Rechnungen gibt es nichts bei Stripe zu lesen')
})
test('baueRechnung anzahlung: Prozent oder Betrag änderbar; nur finalisierte Rechnungen mindern das Offene', async () => {
const ab = await legeAuftragAn()
const lauf = (anzahlung) => baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung })
assert.ok((await lauf({ prozent: 25, bezeichnung: 'Teilrechnung' })).positionen[0].beschreibung.startsWith('Teilrechnung 25 % gemäß'))
assert.equal((await lauf({ betragCents: 25000 })).summeCents, 25000)
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000 })
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 99000, nummer: 'WEBKLAR-0008' }) // storniert: zählt nicht
await fruehereRechnung(ab, { id: 'in_f3', status: 'draft', total: 5000 }) // Entwurf einer Anzahlung: zählt nicht
assert.equal((await lauf(undefined)).summeCents, 40000)
assert.equal((await getProjektRechnung('in_f2')).aktiv, false, 'die im Dashboard stornierte Rechnung wurde freigegeben')
await wirft(lauf({ betragCents: 40001 }), 400, `Die Anzahlung darf höchstens 400,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
await fruehereRechnung(ab, { id: 'in_f4', status: 'paid', total: 40000, nummer: 'WEBKLAR-0009' })
await wirft(lauf(undefined), 409, 'Nichts mehr offen.')
for (const kaputt of ['viel', 5, ['x']]) await wirft(lauf(kaputt), 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
})
test('baueRechnung anzahlung: ein Entwurf der Schlussrechnung zur AB blockiert weitere Anzahlungen (409)', async () => {
const ab = await legeAuftragAn()
await fruehereRechnung(ab, { id: 'in_s1', status: 'draft', art: 'schluss', total: 40000 })
await wirft(
baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }),
409,
'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.'
)
})
test('baueRechnung anzahlung/schluss: Voraussetzungen (Schalter, Auftrag, Festpreis, Status, Kunde, Projekt)', async () => {
const ab = await legeAuftragAn()
for (const art of ['anzahlung', 'schluss']) {
await wirft(baueRechnung({ customerId: 'k1', art }), 400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: 'gibtEsNicht' }), 404, 'Auftrag nicht gefunden.')
await wirft(baueRechnung({ customerId: 'k2', art, auftragId: ab.id }), 404, 'Auftrag nicht gefunden.')
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id, projektId: 'p3' }), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
config.auftraege.mode = 'off'
try {
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id }), 400, SCHALTER_AUS)
await wirft(baueRechnung({ customerId: 'k1', art }), 400, SCHALTER_AUS)
} finally {
config.auftraege.mode = 'on'
}
}
const aufwand = await legeAuftragAn({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000 }, { nummer: 'AB-2026-0006' })
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: aufwand.id }), 400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
for (const [i, status] of ['entwurf', 'storniert', 'ersetzt', 'widerrufen', 'abgerechnet'].entries()) {
const x = await legeAuftragAn({}, { nummer: `AB-2026-010${i}`, status })
for (const art of ['anzahlung', 'schluss']) await wirft(baueRechnung({ customerId: 'k1', art, auftragId: x.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
}
const bestaetigt = await legeAuftragAn({}, { nummer: 'AB-2026-0007', status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: bestaetigt.id })).summeCents, 40000)
})
test('baueRechnung schluss: AB-Positionen, Zusatz und eine Minuszeile je finalisierter, nicht stornierter früherer Rechnung', async () => {
const ab = await legeAuftragAn()
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000, nummer: 'WEBKLAR-0007', finalisiert: '2026-10-08T10:00:00.000Z' })
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 20000, nummer: 'WEBKLAR-0008' })
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }] })
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['ab-1', 60000], ['ab-2', 20000], ['zusatz-1', 1500], ['abzug-in_f1', -40000]])
assert.equal(e.positionen.at(-1).beschreibung, 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026')
assert.equal(e.summeCents, 41500)
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag'])
assert.equal(e.projektRechnung.art, 'schluss')
assert.equal(e.projektRechnung.betragCents, 41500)
assert.equal(e.projektRechnung.auftragId, ab.id)
})
test('baueRechnung schluss: offener Entwurf zur AB blockiert (409), Rechnungen fremder ABs und bei Stripe gelöschte zählen nicht, nichts mehr offen -> 409', async () => {
const ab = await legeAuftragAn()
const andere = await legeAuftragAn({}, { nummer: 'AB-2026-0003' })
await fruehereRechnung(andere, { id: 'in_g1', status: 'draft', total: 11111 })
await fruehereRechnung(andere, { id: 'in_g2', status: 'paid', total: 22222, nummer: 'WEBKLAR-0005' })
const lauf = () => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
assert.equal((await lauf()).summeCents, 80000)
await fruehereRechnung(ab, { id: 'in_h1', status: 'draft', total: 40000 })
await wirft(lauf(), 409, `Erst den Entwurf über 400,00${NBSP}€ senden oder löschen.`)
await updateProjektRechnung('in_h1', { aktiv: false })
assert.equal((await lauf()).summeCents, 80000)
await fruehereRechnung(ab, { id: 'in_h2', status: 'paid', total: 80000, nummer: 'WEBKLAR-0008' })
await wirft(lauf(), 409, 'Nichts mehr offen.')
await updateProjektRechnung('in_h2', { aktiv: false })
// aktiver Projektdatensatz, aber die Rechnung gibt es bei Stripe nicht mehr (im Dashboard gelöscht): zählt nicht
await fruehereRechnung(ab, { id: 'in_weg', ohneStripe: true, total: 5000, docNummer: 'X' })
const e = await lauf()
assert.equal(e.summeCents, 80000)
assert.equal(e.positionen.length, 2)
assert.equal((await getProjektRechnung('in_weg')).aktiv, false, 'der Datensatz der gelöschten Rechnung wurde freigegeben')
})
test('baueRechnung anzahlung: AB ohne Projekt oder mit gelöschtem Projekt -> Zusatzfeld Projekt zeigt den AB-Titel; ein genanntes fremdes Projekt 404', async () => {
const ohne = await legeAuftragAn({ projektId: '', titel: 'Logo-Entwurf' }, { nummer: 'AB-2026-0012' })
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id })
assert.equal(e.projekt, null)
assert.deepEqual(e.customFields[0], { name: 'Projekt', value: 'Logo-Entwurf' })
assert.equal(e.metadata.projektId, undefined)
assert.equal(e.projektRechnung.projektId, '')
const weg = await legeAuftragAn({ projektId: 'pWeg' }, { nummer: 'AB-2026-0013' })
const w = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: weg.id })
assert.equal(w.projekt, null)
assert.deepEqual([w.metadata.projektId, w.projektRechnung.projektId, w.customFields[0].value], ['pWeg', 'pWeg', 'Website anelia-wolf.com'])
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p1' })).projekt.id, 'p1')
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
})
test('baueRechnung schluss: eine im Dashboard stornierte Schlussrechnung gibt die AB wieder frei (Selbstheilung), eine offene hält sie', async () => {
const ab = await legeAuftragAn({}, { status: 'abgerechnet', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
await fruehereRechnung(ab, { id: 'in_sl1', art: 'schluss', status: 'open', total: 80000 })
await wirft(baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
st.invoices.set('in_sl1', { ...st.invoices.get('in_sl1'), status: 'void' }) // im Dashboard storniert
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
assert.equal(e.summeCents, 80000)
assert.equal((await getAuftrag(ab.id)).status, 'bestaetigt')
assert.equal((await getProjektRechnung('in_sl1')).aktiv, false)
})
test('baueRechnung schluss: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge); mehr Abzüge oder Nachträge -> 400', async () => {
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 }))
const ab = await legeAuftragAn({ positionen, summeCents: 20000 }, { nummer: 'AB-2026-0014' })
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
const lauf = (items) => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items })
const abzug = (i) => fruehereRechnung(ab, { id: `in_z${String(i).padStart(2, '0')}`, status: 'paid', total: 100, nummer: `WK-${String(i).padStart(4, '0')}` })
assert.equal((await lauf(undefined)).positionen.length, 20)
await abzug(1)
const eins = await lauf(undefined)
assert.deepEqual([eins.positionen.length, eins.summeCents], [21, 19900], '20 AB-Positionen und ein Abzug sind gültig')
for (let i = 2; i <= 10; i += 1) await abzug(i)
const voll = await lauf(nachtraege(10))
assert.deepEqual([voll.positionen.length, voll.summeCents, voll.projektRechnung.betragCents], [40, 24000, 24000])
await wirft(lauf(nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
await abzug(11)
await wirft(lauf(undefined), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
})
+127
View File
@@ -0,0 +1,127 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
const aw = await startAppwriteMock({
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
datetimes: AUFTRAEGE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(async () => {
await leereRechnungsDaten(aw, st)
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
})
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
const base = '/api/admin/finanzen'
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET')
const erwarte = async (pfad, body, status, error) => {
const res = await post(`${base}${pfad}`, body, admin)
assert.equal(res.status, status, JSON.stringify(res.body))
assert.deepEqual(res.body, { error })
}
test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => {
const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200])
assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4')
})
test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => {
const ab = await legeAuftragAn()
const antwort = async (body) => {
const res = await post(`${base}/rechnungen`, body, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
return res.body.rechnung
}
const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN })
assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', ''])
const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] })
assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500])
const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' })
assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002'])
const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000])
})
test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => {
const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
assert.equal(res.body.summeCents, 32500)
assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026')
assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
assert.equal(schreibaufrufe().length, 0)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => {
const ab = await legeAuftragAn()
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.')
await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.')
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.')
await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.')
config.auftraege.mode = 'off'
try {
await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS)
} finally {
config.auftraege.mode = 'on'
}
}
assert.equal(schreibaufrufe().length, 0)
})
test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => {
const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
}
})
test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => {
const ab = await legeAuftragAn()
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
assert.equal(schreibaufrufe().length, 0)
})
test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => {
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401)
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403)
}
assert.equal(st.invoices.size, 0)
await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin)
await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin)
const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen
const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer]
assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']])
})
+288
View File
@@ -0,0 +1,288 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
const aw = await startAppwriteMock({
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
datetimes: AUFTRAEGE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const {
mapInvoice, createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests,
} = await import('../server/services/finance/stripeFinance.js')
const { getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { berlinDate, formatDe } = await import('../server/services/finance/dates.js')
after(() => Promise.all([aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(async () => {
mock.restoreAll()
await leereRechnungsDaten(aw, st)
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
})
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const writes = () => st.calls.filter((c) => c.method !== 'GET')
const wirft = (promise, status, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
assert.equal(err.message, message)
return true
})
test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder frei', () => {
const roh = (over = {}) => ({ id: 'in_1', object: 'invoice', status: 'open', customer: 'cus_1', created: 1_790_000_000, total: 1000, metadata: {}, lines: { object: 'list', data: [] }, ...over })
const felder = (r) => [r.projektId, r.projektName, r.art, r.auftragId, r.auftragNummer]
const heute = { today: '2026-10-06' }
assert.deepEqual(felder(mapInvoice(roh(), heute)), ['', '', 'frei', '', ''])
assert.deepEqual(felder(mapInvoice(roh({ subscription: 'sub_1' }), heute)), ['', '', 'abo', '', ''])
const metadata = { projektId: 'p1', projektName: 'Website Wolf', art: 'schluss', auftragId: 'a1', auftragNummer: 'AB-2026-0002' }
assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002'])
for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`)
})
test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
assert.equal(create.body['metadata[art]'], 'frei')
assert.equal(create.body['metadata[webklarCustomerId]'], 'k1')
assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields')))
assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k)))
assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, ''])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => {
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body['custom_fields[0][name]'], 'Projekt')
assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com')
assert.equal(create.body['custom_fields[1][name]'], undefined)
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com'])
assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000])
const doc = await getProjektRechnung(rechnung.id)
assert.deepEqual(
[doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon],
[rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com']
)
})
test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => {
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' })
const items = callsTo('POST', '/v1/invoiceitems')
assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]])
assert.ok(items[0].body.description.startsWith('06.10.2026: '))
const [create] = callsTo('POST', '/v1/invoices')
assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum'])
assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026')
assert.equal(rechnung.totalCents, 32500)
assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
})
test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => {
const ab = await legeAuftragAn()
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' })
assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen'])
st.calls.length = 0
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' })
const [create] = callsTo('POST', '/v1/invoices')
assert.deepEqual(
[0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]),
[['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']]
)
const items = callsTo('POST', '/v1/invoiceitems')
const kunde = items[0].body.customer
assert.deepEqual(items.map((c) => c.body), [
{ customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' },
{ customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' },
{ customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' },
])
assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf'])
})
test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => {
const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' }
aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 })
await assert.rejects(createProjectInvoice(eingabe))
assert.equal(st.invoices.size, 0)
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
// der Schutz vor doppeltem Anlegen ist wieder frei
assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf')
})
test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => {
const basis = { customerId: 'k1', items: POSTEN }
const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.'
await createProjectInvoice(basis)
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' })
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' })
await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT)
const ab = await legeAuftragAn()
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } })
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } })
await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT)
assert.equal(st.invoices.size, 5)
})
test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => {
const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' })
assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
assert.equal(v.summeCents, 32500)
assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
assert.equal(v.leistungszeitraum, '06.10.–07.10.2026')
assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
assert.equal(writes().length, 0)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => {
await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
const ab = await legeAuftragAn()
const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id })
assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung'])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben')
})
test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => {
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' })
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.status, 409)
assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
assert.equal(st.invoices.size, 1)
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1)
})
test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => {
const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo })
await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A'))
await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
const rest = await createProjectInvoice(eingabe(['ws3'], 'C'))
assert.equal(rest.totalCents, 10000)
assert.equal(st.invoices.size, 2)
})
test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => {
const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
})
test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => {
const ab = await legeAuftragAn()
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo })
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.status, 409)
assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`)
assert.equal(st.invoices.size, 1)
})
test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => {
const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
assert.match(gesendet.number, /^WK-\d{4}$/)
assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number)
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' })
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '')
const nachher = await sendInvoiceNow(entwurf.id)
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number)
const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true })
assert.equal(await getProjektRechnung(frei.id), null)
})
test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => {
const ab = await legeAuftragAn()
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
const gesendet = await sendInvoiceNow(entwurf.id)
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number)
await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
})
test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => {
for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) {
await leereRechnungsDaten(aw, st)
resetInvoiceCreateGuardForTests()
const ab = await legeAuftragAn({}, patch)
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
await voidInvoice(anzahlung.id)
assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false)
assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet')
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true })
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
await voidInvoice(schluss.id)
assert.equal((await getProjektRechnung(schluss.id)).aktiv, false)
assert.equal((await getAuftrag(ab.id)).status, erwartet)
// die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe
assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000)
}
})
test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => {
const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' }
const entwurf = await createProjectInvoice(args)
await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
await deleteDraft(entwurf.id)
assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false)
const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true })
assert.equal(offen.status, 'offen')
await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`)
await voidInvoice(offen.id)
assert.equal((await getProjektRechnung(offen.id)).aktiv, false)
assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf')
})
test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => {
const log = mock.method(console, 'error', () => {})
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 })
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
assert.equal(rechnung.status, 'offen')
assert.equal(callsTo('POST', /\/send$/).length, 1)
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen')))
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '')
const erneut = await sendInvoiceNow(rechnung.id)
assert.equal(erneut.status, 'offen')
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number)
})
test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => {
const ab = await legeAuftragAn()
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
let freigeben
const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve }))
const senden = sendInvoiceNow(entwurf.id)
await new Promise((resolve) => setTimeout(resolve, 200))
assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB')
freigeben()
await Promise.all([gehalten, senden])
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
})
+5
View File
@@ -114,6 +114,11 @@ test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => {
pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf',
outOfBand: false,
bankTransactionId: '',
projektId: '',
projektName: '',
art: 'frei',
auftragId: '',
auftragNummer: '',
})
})
+25 -1
View File
@@ -26,7 +26,10 @@ import http from 'node:http'
* Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit),
* .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API
* liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400.
* custom_fields (hoechstens 4, Name 1 bis 40, Wert 1 bis 140 Zeichen) und rendering[pdf][page_size] (a4, auto, letter) werden wie
* bei Stripe geprueft, gespeichert (sonst null) und mit der Rechnung geliefert; Rechnungsposten mit amount (auch negativ)
* tragen weder quantity noch unit_amount_decimal. Der Kunde einer neuen
* Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile
* Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen).
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
@@ -461,6 +464,22 @@ export async function startFakeStripe() {
}
const metadata = mergeMetadata({}, data.metadata)
if (Array.isArray(metadata)) return metadata
const customFields = data.custom_fields
if (customFields !== undefined) {
if (!Array.isArray(customFields) || customFields.length > 4) return invalid('custom_fields', 'You can specify up to 4 custom fields.')
for (const [i, field] of customFields.entries()) {
if (typeof field?.name !== 'string' || field.name.length < 1 || field.name.length > 40) {
return invalid(`custom_fields[${i}][name]`, 'Custom field names must be between 1 and 40 characters.')
}
if (typeof field.value !== 'string' || field.value.length < 1 || field.value.length > 140) {
return invalid(`custom_fields[${i}][value]`, 'Custom field values must be between 1 and 140 characters.')
}
}
}
const pageSize = data.rendering?.pdf?.page_size
if (pageSize !== undefined && !['a4', 'auto', 'letter'].includes(pageSize)) {
return invalid('rendering[pdf][page_size]', 'Invalid page_size: must be one of a4, auto, letter.')
}
const customer = customers.get(data.customer)
const invoice = {
id: nextId('in'),
@@ -479,6 +498,8 @@ export async function startFakeStripe() {
description: data.description ?? null,
footer: data.footer ?? null,
metadata,
custom_fields: customFields ?? null,
rendering: data.rendering ?? null,
created: nowUnix(),
total: 0,
subtotal: 0,
@@ -496,6 +517,9 @@ export async function startFakeStripe() {
if (method === 'POST' && path === '/v1/invoiceitems') {
const data = unflatten(body)
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
if (data.amount !== undefined && (data.quantity !== undefined || data.unit_amount_decimal !== undefined)) {
return invalid('amount', 'You may only specify one of these parameters: amount, or quantity with unit_amount_decimal.')
}
const quantity = data.quantity !== undefined ? Number(data.quantity) : 1
if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.')
let unit = null