Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen

Block 6 (Rechnungen: Filter, Altersgruppen, Aktionen, Dialog "Rechnung schreiben")
und Block 7 (Buchungen mit Paging, Zuordnen-Dialog, bei Rechnungen zweistufig).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 18bc3012e0
commit 1feeb18c08
12 files changed
+2436 -8

No files matched your search

+530 -2
View File
@@ -1,11 +1,16 @@
// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos)
// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos,
// Rechnungen, Buchungen, Zuordnen) (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
import {
AGE_GROUP_LABEL,
CATEGORY_LABEL,
INTERVAL_LABEL,
INVOICE_STATUS,
canInvoice,
dateDe,
euro,
euroWhole,
nextIntervalDate,
parseEuro,
percentDe,
signedEuro,
sinceLabel,
@@ -398,3 +403,526 @@ export function aboView(abos) {
}),
}
}
// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
/** Filter über der Rechnungsliste (Schlüssel = `status` der API); Standard ist „Offen“. */
export const INVOICE_FILTERS = [
{ key: 'offen', label: 'Offen' },
{ key: 'ueberfaellig', label: 'Überfällig' },
{ key: 'bezahlt', label: 'Bezahlt' },
{ key: 'entwurf', label: 'Entwürfe' },
{ key: 'alle', label: 'Alle' },
]
export const DEFAULT_INVOICE_FILTER = 'offen'
const obj = (v) => (v && typeof v === 'object' ? v : {})
const objects = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object') : [])
const OPEN_INVOICE = new Set(['offen', 'ueberfaellig'])
/**
* Altersgruppen der offenen Rechnungen als [{ key, label, count, sumText }] in der Reihenfolge nicht fällig, 1–30,
* 31–60, über 60 Tage (fehlende Gruppen zählen 0). Ohne Liste (kein Array) gibt es nichts anzuzeigen: [].
*/
export function ageGroupCells(altersgruppen) {
if (!Array.isArray(altersgruppen)) return []
return Object.keys(AGE_GROUP_LABEL).map((key) => {
const group = obj(altersgruppen.find((g) => g && g.key === key))
return {
key,
label: AGE_GROUP_LABEL[key],
count: isNum(group.anzahl) ? group.anzahl : 0,
sumText: euro(isNum(group.summeCents) ? group.summeCents : 0),
}
})
}
/**
* Aktionen einer Rechnung als [{ key, label, url? }] (key: hosted | pdf | send | resend | void | delete):
* Entwurf: Senden, Löschen; offen/überfällig: Zahlungsseite, PDF, Erinnerung senden, Stornieren; bezahlt: PDF.
* Abo-Rechnungen (source 'abo') verwaltet Stripe: höchstens Zahlungsseite und PDF. Adressen nur, wenn sie
* http(s) sind.
*/
export function invoiceActions(invoice) {
const inv = obj(invoice)
const abo = inv.source === 'abo'
const hosted = safeUrl(inv.hostedUrl)
const pdf = safeUrl(inv.pdfUrl)
const pdfAction = pdf ? [{ key: 'pdf', label: 'PDF', url: pdf }] : []
switch (inv.status) {
case 'entwurf':
return abo ? [] : [{ key: 'send', label: 'Senden' }, { key: 'delete', label: 'Löschen' }]
case 'offen':
case 'ueberfaellig':
return [
...(hosted ? [{ key: 'hosted', label: 'Zahlungsseite', url: hosted }] : []),
...pdfAction,
...(abo ? [] : [{ key: 'resend', label: 'Erinnerung senden' }, { key: 'void', label: 'Stornieren' }]),
]
default:
return pdfAction
}
}
/**
* Zeilen der Rechnungstabelle (Serverreihenfolge, neueste zuerst):
* { key, id, numberText, customerText, isAbo, createdText, dueText, totalText, remainingText, statusLabel,
* statusTone, statusNote, actions }. „Offen“ nur bei offen/überfällig; statusNote: „12 Tage überfällig“ bzw.
* „am 05.08.2026 · per Überweisung“ (außerhalb von Stripe bezahlt).
*/
export function invoiceRows(invoices) {
return objects(invoices).map((inv, index) => {
const status = has(INVOICE_STATUS, inv.status)
? INVOICE_STATUS[inv.status]
: { label: text(inv.status) || 'unbekannt', tone: 'muted' }
let note = ''
if (inv.status === 'ueberfaellig' && isNum(inv.overdueDays) && inv.overdueDays > 0) {
note = `${inv.overdueDays} ${inv.overdueDays === 1 ? 'Tag' : 'Tage'} überfällig`
} else if (inv.status === 'bezahlt') {
note = [text(inv.paidDate) ? `am ${dateDe(inv.paidDate)}` : '', inv.outOfBand ? 'per Überweisung' : '']
.filter(Boolean)
.join(' · ')
}
return {
key: text(inv.id) || `row-${index}`,
id: text(inv.id),
numberText: text(inv.number) || 'ohne Nr.',
customerText: text(inv.customerName) || text(inv.customerEmail) || 'Unbekannter Kunde',
isAbo: inv.source === 'abo',
createdText: dateDe(inv.createdDate),
dueText: dateDe(inv.dueDate),
totalText: euro(inv.totalCents),
remainingText: OPEN_INVOICE.has(inv.status) ? euro(inv.remainingCents) : '–',
statusLabel: status.label,
statusTone: status.tone,
statusNote: note,
actions: invoiceActions(inv),
}
})
}
/** Rückfrage vor dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für Aktionen ohne Rückfrage. */
export function invoiceConfirmText(actionKey, invoice) {
const inv = obj(invoice)
const who = text(inv.customerName) || text(inv.customerEmail) || 'den Kunden'
const total = euro(inv.totalCents)
const number = text(inv.number) || 'ohne Nummer'
switch (actionKey) {
case 'delete':
return `Den Entwurf für ${who} über ${total} wirklich löschen? Das lässt sich nicht rückgängig machen.`
case 'void':
return `Die Rechnung ${number} an ${who} über ${total} wirklich stornieren? Das lässt sich nicht rückgängig machen.`
case 'resend':
return `Die Rechnung ${number} jetzt noch einmal an ${text(inv.customerEmail) || who} senden (Erinnerung)?`
default:
return ''
}
}
/** Erfolgsmeldung nach einer Rechnungsaktion (send | resend | void | delete | create-draft | create-send). */
export function invoiceDoneMessage(actionKey, rechnung) {
const inv = obj(rechnung)
const number = text(inv.number)
const mail = text(inv.customerEmail)
const subject = number ? `Rechnung ${number}` : 'Die Rechnung'
const to = mail ? ` an ${mail}` : ''
switch (actionKey) {
case 'send':
case 'create-send':
return `${subject} wurde${to} gesendet.`
case 'resend':
return `Die Erinnerung zu ${number ? `Rechnung ${number}` : 'der Rechnung'} wurde${to} gesendet.`
case 'void':
return `${subject} wurde storniert.`
case 'delete':
return 'Der Entwurf wurde gelöscht.'
case 'create-draft':
return 'Der Entwurf wurde gespeichert.'
default:
return ''
}
}
/** Die Rechnung besteht schon bei Stripe, der Aufruf scheiterte danach (502 mit invoiceId). */
export function isFinalizedNotSent(err) {
return Boolean(err) && err.status === 502 && Boolean(err.invoiceId)
}
// Der Server meldet auch „gesendet, konnte aber nicht gelesen werden“ (Rechnung ist raus, nur das Nachlesen scheiterte)
const UNREADABLE = /nicht gelesen/i
/**
* Meldung zu einem Fehler bei einer Rechnungsaktion. Bei 502 mit invoiceId (festgeschrieben, nicht versendet) der
* feststehende Text mit der Nummer; scheiterte nur das Nachlesen, gilt die genauere Meldung des Servers.
*/
export function invoiceFailureMessage(err) {
const message = text(err?.message) || 'Die Aktion ist fehlgeschlagen.'
if (isFinalizedNotSent(err) && !UNREADABLE.test(message)) {
const number = text(err.invoiceNumber)
return `${number ? `Rechnung ${number}` : 'Die Rechnung'} wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.`
}
return message
}
/** Welcher Filter die bei Stripe entstandene Rechnung zeigt: mit Nummer „Offen“, sonst (Entwurf) „Entwürfe“. */
export function finalizedFilter(err) {
return text(err?.invoiceNumber) ? 'offen' : 'entwurf'
}
// --- Rechnung schreiben (Dialog) ---------------------------------------------------------------------------
/** Grenzen der Rechnungs-API (Portal, stripeFinance.js); die Oberfläche prüft vorab, der Server entscheidet. */
export const INVOICE_LIMITS = {
maxItems: 20,
maxDescription: 200,
maxQuantity: 999,
maxUnitCents: 10_000_000,
maxTotalCents: 99_999_999,
maxDays: 60,
defaultDays: 14,
maxMemo: 1000,
}
/** Menge als Text -> ganze Zahl 1..999, sonst null. */
export function parseQuantity(value) {
const s = text(typeof value === 'number' ? String(value) : value)
if (!/^[0-9]{1,4}$/.test(s)) return null
const n = Number(s)
return n >= 1 && n <= INVOICE_LIMITS.maxQuantity ? n : null
}
/** Einzelpreis als Text („1.234,50“) -> Cent (1..100.000,00 €), sonst null. */
export function parseUnitPrice(value) {
try {
const cents = parseEuro(typeof value === 'string' ? value : '')
return cents >= 1 && cents <= INVOICE_LIMITS.maxUnitCents ? cents : null
} catch {
return null
}
}
/** Zeilensumme einer Position { quantity, price } (Texte) in Cent; null, wenn Menge oder Preis nicht gültig sind. */
export function lineTotalCents(item) {
const quantity = parseQuantity(item?.quantity)
const unit = parseUnitPrice(item?.price)
return quantity === null || unit === null ? null : quantity * unit
}
/** Summe aller gültigen Positionen in Cent (ungültige zählen 0). */
export function invoiceTotalCents(items) {
return objects(items).reduce((sum, item) => sum + (lineTotalCents(item) ?? 0), 0)
}
/**
* Formular -> Anfrage. form = { customer, items: [{ description, quantity, price }], days, memo } (Texte).
* { ok: true, payload: { customerId, items: [{ description, quantity, unitAmountCents }], daysUntilDue, memo? },
* totalCents } oder { ok: false, error } mit der ersten Meldung. `senden` setzt der Aufrufer.
*/
export function validateInvoiceForm(form) {
const f = obj(form)
const fail = (error) => ({ ok: false, error })
if (!f.customer) return fail('Bitte einen Kunden wählen.')
const check = canInvoice(f.customer)
if (!check.ok) return fail(`Für ${customerName(f.customer) || 'diesen Kunden'} kann keine Rechnung geschrieben werden: ${check.reason}.`)
const items = objects(f.items)
if (items.length < 1 || items.length > INVOICE_LIMITS.maxItems) {
return fail(`Eine Rechnung braucht 1 bis ${INVOICE_LIMITS.maxItems} Positionen.`)
}
const lines = []
for (let i = 0; i < items.length; i += 1) {
const label = `Position ${i + 1}`
const description = text(items[i].description)
if (!description) return fail(`${label}: Bitte eine Beschreibung eingeben.`)
if (description.length > INVOICE_LIMITS.maxDescription) {
return fail(`${label}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.`)
}
const quantity = parseQuantity(items[i].quantity)
if (quantity === null) return fail(`${label}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.`)
const unit = parseUnitPrice(items[i].price)
if (unit === null) return fail(`${label}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).`)
lines.push({ description, quantity, unitAmountCents: unit })
}
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
if (totalCents > INVOICE_LIMITS.maxTotalCents) return fail('Die Rechnungssumme ist zu hoch (höchstens 999.999,99 €).')
const daysText = text(typeof f.days === 'number' ? String(f.days) : f.days)
const days = /^[0-9]{1,3}$/.test(daysText) ? Number(daysText) : null
if (days === null || days > INVOICE_LIMITS.maxDays) {
return fail(`Das Zahlungsziel muss eine ganze Zahl von 0 bis ${INVOICE_LIMITS.maxDays} Tagen sein.`)
}
const memo = text(f.memo)
if (memo.length > INVOICE_LIMITS.maxMemo) return fail(`Die Notiz darf höchstens ${INVOICE_LIMITS.maxMemo} Zeichen lang sein.`)
const payload = { customerId: f.customer.$id, items: lines, daysUntilDue: days }
if (memo) payload.memo = memo
return { ok: true, payload, totalCents }
}
/** Anzeigename eines Kunden (Appwrite-Dokument): name, sonst companyName. */
export function customerName(customer) {
return text(customer?.name) || text(customer?.companyName)
}
/**
* Kundenauswahl: alle Kunden mit Namen als [{ id, name, label, email, ok, reason }] nach Namen sortiert (de).
* ok/reason nach canInvoice. label ist der Text im Feld und eindeutig (bei gleichem Namen mit E-Mail ergänzt).
*/
export function customerPickerOptions(customers) {
const named = objects(customers)
.map((c) => ({ c, name: customerName(c) }))
.filter((x) => x.name && text(x.c.$id))
.sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }))
const count = new Map()
for (const { name } of named) count.set(name.toLowerCase(), (count.get(name.toLowerCase()) || 0) + 1)
const used = new Set()
return named.map(({ c, name }) => {
const email = text(c.email)
let label = count.get(name.toLowerCase()) > 1 ? `${name} (${email || c.$id})` : name
if (used.has(label.toLowerCase())) label = `${label} · ${String(c.$id).slice(-4)}`
used.add(label.toLowerCase())
const check = canInvoice(c)
return { id: c.$id, customer: c, name, label, email, ok: check.ok, reason: check.ok ? '' : check.reason }
})
}
/** Vorschläge für das Kundenfeld: nur Kunden mit ok, die den Text enthalten (Namensanfang zuerst), höchstens `max`. */
export function customerSuggestions(options, input, max = 12) {
const needle = text(input).toLowerCase()
const list = objects(options).filter((o) => o.ok)
const hit = (o) => !needle || o.label.toLowerCase().includes(needle) || o.email.toLowerCase().includes(needle)
const starts = list.filter((o) => hit(o) && o.label.toLowerCase().startsWith(needle))
const rest = list.filter((o) => hit(o) && !o.label.toLowerCase().startsWith(needle))
return [...starts, ...rest].slice(0, max).map((o) => ({ value: o.label, hint: o.email }))
}
/**
* Was im Kundenfeld steht: { state: 'empty' | 'ok' | 'blocked' | 'unknown', option }. blocked = Kunde gefunden, aber
* ohne Rechnung möglich (option.reason nennt den Grund); unknown = Text passt zu keinem Kunden.
*/
export function resolveCustomerText(options, input) {
const value = text(input).toLowerCase()
if (!value) return { state: 'empty', option: null }
const option = objects(options).find((o) => o.label.toLowerCase() === value) || null
if (!option) return { state: 'unknown', option: null }
return { state: option.ok ? 'ok' : 'blocked', option }
}
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */
export function sortTransactions(list) {
return objects(list)
.map((tx, index) => ({ tx, index }))
.sort((a, b) => {
const da = dayOf(a.tx)
const db = dayOf(b.tx)
if (da === db) return a.index - b.index
return da < db ? 1 : -1
})
.map((x) => x.tx)
}
/** Stärkster Vorschlag (hoch vor mittel vor niedrig, sonst Serverreihenfolge) oder null. */
export function strongestSuggestion(vorschlaege) {
const list = objects(vorschlaege)
let best = null
for (const s of list) {
if (!best || (RANK[s.confidence] ?? 9) < (RANK[best.confidence] ?? 9)) best = s
}
return best
}
/** „Vorschlag: Rechnung WK-0005 (hoch)“ / „Vorschlag: Stripe-Auszahlung (hoch) · Betrag weicht ab“; '' ohne Vorschlag. */
export function suggestionText(suggestion) {
if (!suggestion || typeof suggestion !== 'object') return ''
const what = suggestion.kind === 'payout' ? 'Stripe-Auszahlung' : `Rechnung ${text(suggestion.number) || text(suggestion.id)}`
const level = has(RANK, suggestion.confidence) ? ` (${suggestion.confidence})` : ''
return `Vorschlag: ${what}${level}${suggestion.amountDiffers ? ' · Betrag weicht ab' : ''}`
}
/** Text der Zuordnung: „Offen“, „Rechnung WK-0005“, „Stripe-Auszahlung“, „Fixkosten“, „Einnahme ohne Stripe-Rechnung“ … */
export function assignmentText(tx) {
const t = obj(tx)
const category = text(t.category)
if (category === 'rechnung') return `Rechnung ${text(t.matchedInvoiceNumber) || text(t.matchedInvoiceId) || ''}`.trim()
if (has(CATEGORY_LABEL, category)) return CATEGORY_LABEL[category]
return category || CATEGORY_LABEL['']
}
/**
* Zeilen der Buchungstabelle in der Reihenfolge von sortTransactions:
* { key, id, tx, dateText, counterparty, remittance, amountText, incoming, pending, assignmentText, assigned, note,
* suggestionText (leer bei zugeordneten und vorgemerkten Buchungen), action: 'assign' | 'change' | null, actionLabel }.
* Zuordnen für jede offene Buchung, „Ändern“ für zugeordnete außer Rechnungen (sie lassen sich nicht lösen).
*/
export function transactionRows(buchungen) {
return sortTransactions(buchungen).map((tx, index) => {
const category = text(tx.category)
const assigned = Boolean(category)
let action = null
if (!assigned) action = 'assign'
else if (category !== 'rechnung') action = 'change'
return {
key: text(tx.$id) || `tx-${index}`,
id: text(tx.$id),
tx,
dateText: dateDe(dayOf(tx)),
counterparty: text(tx.counterpartyName) || text(tx.counterpartyIban) || 'Unbekannt',
remittance: text(tx.remittance),
amountText: signedEuro(tx.amountCents),
incoming: isNum(tx.amountCents) && tx.amountCents > 0,
pending: tx.status === 'PDNG',
assignmentText: assignmentText(tx),
assigned,
note: text(tx.note),
// Vorgemerkte Buchungen lassen sich keiner Rechnung zuordnen: kein Vorschlag
suggestionText: assigned || tx.status === 'PDNG' ? '' : suggestionText(strongestSuggestion(tx.vorschlaege)),
action,
actionLabel: action === 'assign' ? 'Zuordnen' : action === 'change' ? 'Ändern' : '',
}
})
}
// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
export const PENDING_ASSIGN_HINT = 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.'
/** Bestätigungstext vor dem Bezahlt-Markieren (nicht umkehrbar). */
export function invoiceAssignConfirmText(number) {
return `Die Rechnung ${number} wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.`
}
const fixedCostLabel = (f) => `${text(f.name) || 'Fixkosten'} · ${euro(f.amountCents)} ${INTERVAL_LABEL[f.interval] || ''}`.trim()
/**
* Auswahl im Zuordnen-Dialog zu einer Buchung:
* { incoming, outgoing, pending, assigned, currentText, canUnassign, hint, suggestions, invoices, fixedCosts,
* categories, byKey }. Jede Option: { key, type ('invoice'|'payout'|'fixedCost'|'category'), label, detail,
* disabled, disabledReason, number, body } mit body = Anfrage an die API (ohne Notiz).
* - zugeordnete Buchung: keine Optionen, nur currentText (und canUnassign außer bei Rechnungen)
* - vorgemerkt (PDNG): nur Fixkosten (Ausgang) und Kategorien, mit Hinweis
* - Eingang: Vorschläge des Servers, andere offene Rechnungen, Kategorien (auch „Einnahme ohne Stripe-Rechnung“)
* - Ausgang: Fixkosten (aktive), Privat/Umbuchung, Sonstiges
* Rechnungen, deren offener Betrag vom Betrag der Buchung abweicht, sind nicht wählbar (Teilzahlungen in Stripe).
*/
export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) {
const t = obj(tx)
const amount = t.amountCents
const incoming = isNum(amount) && amount > 0
const outgoing = isNum(amount) && amount < 0
const pending = t.status === 'PDNG'
const category = text(t.category)
const assigned = Boolean(category)
const model = {
incoming,
outgoing,
pending,
assigned,
currentText: assigned ? assignmentText(t) : '',
canUnassign: assigned && category !== 'rechnung',
hint: pending && !assigned ? PENDING_ASSIGN_HINT : '',
suggestions: [],
invoices: [],
fixedCosts: [],
categories: [],
byKey: {},
}
if (assigned) return model
const open = objects(invoices).filter((inv) => OPEN_INVOICE.has(inv.status) && text(inv.id))
const invoiceOption = (inv, extra = {}) => {
const differs = isNum(amount) && inv.remainingCents !== amount
return {
key: `invoice:${inv.id}`,
type: 'invoice',
label: `${text(inv.number) || inv.id} · ${text(inv.customerName) || text(inv.customerEmail) || 'Unbekannter Kunde'} · ${euro(inv.remainingCents)} offen · fällig ${dateDe(inv.dueDate)}`,
detail: '',
disabled: differs,
disabledReason: differs ? `Betrag weicht ab (offen: ${euro(inv.remainingCents)})` : '',
number: text(inv.number),
body: { invoiceId: inv.id },
...extra,
}
}
if (incoming && !pending) {
const seen = new Set()
for (const s of objects(t.vorschlaege)) {
if (s.kind === 'payout' && text(s.id)) {
model.suggestions.push({
key: `payout:${s.id}`,
type: 'payout',
label: 'Stripe-Auszahlung',
detail: text(s.reason),
confidence: s.confidence,
disabled: false,
disabledReason: '',
number: '',
body: { payoutId: s.id },
})
} else if (s.kind === 'invoice' && text(s.id) && !seen.has(s.id)) {
seen.add(s.id)
const known = open.find((inv) => inv.id === s.id)
const differs = s.amountDiffers === true || (known ? known.remainingCents !== amount : false)
const number = text(s.number) || text(known?.number)
model.suggestions.push({
key: `invoice:${s.id}`,
type: 'invoice',
label: `Rechnung ${number || s.id}${known ? ` · ${text(known.customerName) || text(known.customerEmail)}` : ''}`,
detail: [text(s.reason), known ? `${euro(known.remainingCents)} offen` : ''].filter(Boolean).join(' · '),
confidence: s.confidence,
disabled: differs,
disabledReason: differs ? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen' : '',
number,
body: { invoiceId: s.id },
})
}
}
model.invoices = open.filter((inv) => !seen.has(inv.id)).map((inv) => invoiceOption(inv))
}
if (outgoing) {
model.fixedCosts = objects(fixedCosts)
.filter((f) => f.active !== false && text(f.$id))
.map((f) => ({
key: `fixedCost:${f.$id}`,
type: 'fixedCost',
label: fixedCostLabel(f),
detail: '',
disabled: false,
disabledReason: '',
number: '',
body: { fixedCostId: f.$id },
}))
}
const categories = [...(incoming ? ['rechnung-extern'] : []), 'privat', 'sonstiges']
model.categories = categories.map((value) => ({
key: `category:${value}`,
type: 'category',
label: CATEGORY_LABEL[value],
detail: '',
disabled: false,
disabledReason: '',
number: '',
body: { category: value },
}))
for (const option of [...model.suggestions, ...model.invoices, ...model.fixedCosts, ...model.categories]) {
model.byKey[option.key] = option
}
return model
}
/** Anfrage-Body für assignTransaction: Ziel der Option plus Notiz (nur wenn nicht leer; höchstens 500 Zeichen). */
export function assignBody(option, note) {
const body = { ...obj(option).body }
const n = text(note)
if (n) body.note = n
return body
}