Merge auftraege/l2-rechnungen: Rechnungen (Strang L2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e5fc5e2c74
12 files changed
+2083
-70
No files matched your search
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
@@ -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', '', '', '', '']])
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user