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:
1 parent
18bc3012e0
commit
1feeb18c08
12 files changed
+2436
-8
No files matched your search
+530
-2
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user