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

+517
View File
@@ -23,6 +23,35 @@ const {
aboView,
productLabel,
aboStatus,
INVOICE_FILTERS,
DEFAULT_INVOICE_FILTER,
INVOICE_LIMITS,
PENDING_ASSIGN_HINT,
ageGroupCells,
invoiceActions,
invoiceRows,
invoiceConfirmText,
invoiceDoneMessage,
isFinalizedNotSent,
finalizedFilter,
invoiceFailureMessage,
parseQuantity,
parseUnitPrice,
lineTotalCents,
invoiceTotalCents,
validateInvoiceForm,
customerName,
customerPickerOptions,
customerSuggestions,
resolveCustomerText,
sortTransactions,
strongestSuggestion,
suggestionText,
assignmentText,
transactionRows,
invoiceAssignConfirmText,
assignModel,
assignBody,
} = await loadModule('src/lib/financeView.js')
const MINUS = '−'
@@ -607,3 +636,491 @@ test('productLabel und aboStatus', () => {
assert.deepEqual(aboStatus('weird'), { label: 'weird', tone: 'muted' })
assert.deepEqual(aboStatus(''), { label: 'unbekannt', tone: 'muted' })
})
// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
const INV = (over = {}) => ({
id: 'in_1', number: 'WK-0006', status: 'ueberfaellig', customerName: 'Kunde Zwei', customerEmail: 'k@example.invalid',
webklarCustomerId: 'c2', createdDate: '2026-09-01', dueDate: '2026-09-21', totalCents: 5000, remainingCents: 5000,
paidCents: 0, paidDate: null, overdueDays: 10, source: 'projekt', hostedUrl: 'https://pay.stripe.test/i/1',
pdfUrl: 'https://pay.stripe.test/i/1/pdf', outOfBand: false, bankTransactionId: '', ...over,
})
test('INVOICE_FILTERS: Reihenfolge Offen, Überfällig, Bezahlt, Entwürfe, Alle; Standard Offen', () => {
assert.deepEqual(INVOICE_FILTERS.map((f) => f.label), ['Offen', 'Überfällig', 'Bezahlt', 'Entwürfe', 'Alle'])
assert.deepEqual(INVOICE_FILTERS.map((f) => f.key), ['offen', 'ueberfaellig', 'bezahlt', 'entwurf', 'alle'])
assert.equal(DEFAULT_INVOICE_FILTER, 'offen')
})
test('ageGroupCells: vier Gruppen in fester Reihenfolge, fehlende zählen 0', () => {
const cells = ageGroupCells([
{ key: '1-30', anzahl: 2, summeCents: 12345 },
{ key: 'nicht-faellig', anzahl: 1, summeCents: 16879 },
])
assert.deepEqual(cells.map((c) => c.key), ['nicht-faellig', '1-30', '31-60', 'ueber-60'])
assert.deepEqual(cells.map((c) => c.label), ['Nicht fällig', '1–30 Tage', '31–60 Tage', 'Über 60 Tage'])
assert.deepEqual(cells.map((c) => c.count), [1, 2, 0, 0])
assert.deepEqual(cells.map((c) => c.sumText), ['168,79 €', '123,45 €', '0,00 €', '0,00 €'])
assert.deepEqual(ageGroupCells(null), [])
assert.deepEqual(ageGroupCells(undefined), [])
assert.equal(ageGroupCells([]).length, 4)
})
test('invoiceActions: Entwurf bekommt Senden und Löschen, keine Adressen', () => {
const a = invoiceActions(INV({ status: 'entwurf', number: '', hostedUrl: null, pdfUrl: null }))
assert.deepEqual(a.map((x) => x.key), ['send', 'delete'])
assert.deepEqual(a.map((x) => x.label), ['Senden', 'Löschen'])
})
test('invoiceActions: offen und überfällig (Projekt): Zahlungsseite, PDF, Erinnerung senden, Stornieren', () => {
for (const status of ['offen', 'ueberfaellig']) {
const a = invoiceActions(INV({ status }))
assert.deepEqual(a.map((x) => x.label), ['Zahlungsseite', 'PDF', 'Erinnerung senden', 'Stornieren'])
assert.equal(a[0].url, 'https://pay.stripe.test/i/1')
assert.equal(a[1].url, 'https://pay.stripe.test/i/1/pdf')
}
})
test('invoiceActions: Abo-Rechnungen nur Zahlungsseite und PDF, nie Senden/Stornieren/Löschen', () => {
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'offen' })).map((x) => x.key), ['hosted', 'pdf'])
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'ueberfaellig' })).map((x) => x.key), ['hosted', 'pdf'])
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'entwurf' })), [])
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'bezahlt' })).map((x) => x.key), ['pdf'])
})
test('invoiceActions: bezahlt nur PDF; unsichere Adressen fallen weg', () => {
assert.deepEqual(invoiceActions(INV({ status: 'bezahlt' })).map((x) => x.key), ['pdf'])
assert.deepEqual(invoiceActions(INV({ status: 'storniert' })).map((x) => x.key), ['pdf'])
const unsafe = invoiceActions(INV({ status: 'offen', hostedUrl: 'javascript:alert(1)', pdfUrl: '' }))
assert.deepEqual(unsafe.map((x) => x.key), ['resend', 'void'])
assert.deepEqual(invoiceActions(null), [])
})
test('invoiceRows: Spalten und Texte', () => {
const [r] = invoiceRows([INV()])
assert.equal(r.numberText, 'WK-0006')
assert.equal(r.customerText, 'Kunde Zwei')
assert.equal(r.createdText, '01.09.2026')
assert.equal(r.dueText, '21.09.2026')
assert.equal(r.totalText, '50,00 €')
assert.equal(r.remainingText, '50,00 €')
assert.equal(r.statusLabel, 'Überfällig')
assert.equal(r.statusTone, 'warn')
assert.equal(r.statusNote, '10 Tage überfällig')
assert.equal(r.isAbo, false)
})
test('invoiceRows: Entwurf ohne Nummer, Bezahlt per Überweisung, Offen nur bei offen/überfällig', () => {
const [draft, paid, paidStripe, open, one] = invoiceRows([
INV({ status: 'entwurf', number: '', dueDate: null, hostedUrl: null, pdfUrl: null, overdueDays: 0, remainingCents: 3000, totalCents: 3000 }),
INV({ status: 'bezahlt', paidDate: '2026-08-05', outOfBand: true, remainingCents: 0, totalCents: 16879 }),
INV({ status: 'bezahlt', paidDate: '2026-08-06', outOfBand: false, remainingCents: 0 }),
INV({ status: 'offen', overdueDays: 0, remainingCents: 1234 }),
INV({ overdueDays: 1 }),
])
assert.equal(draft.numberText, 'ohne Nr.')
assert.equal(draft.dueText, '–')
assert.equal(draft.remainingText, '–')
assert.equal(draft.statusLabel, 'Entwurf')
assert.equal(paid.statusNote, 'am 05.08.2026 · per Überweisung')
assert.equal(paid.remainingText, '–')
assert.equal(paidStripe.statusNote, 'am 06.08.2026')
assert.equal(open.statusNote, '')
assert.equal(open.remainingText, '12,34 €')
assert.equal(one.statusNote, '1 Tag überfällig')
})
test('invoiceRows: fehlende Namen, Abo-Kennzeichen, kaputte Einträge', () => {
const rows = invoiceRows([INV({ customerName: '', customerEmail: 'a@b.invalid', source: 'abo' }), INV({ customerName: '', customerEmail: '' }), null, 5])
assert.equal(rows.length, 2)
assert.equal(rows[0].customerText, 'a@b.invalid')
assert.equal(rows[0].isAbo, true)
assert.equal(rows[1].customerText, 'Unbekannter Kunde')
assert.deepEqual(invoiceRows(undefined), [])
})
test('invoiceConfirmText: Löschen, Stornieren, Erinnerung; sonst leer', () => {
const inv = INV({ status: 'offen' })
assert.equal(invoiceConfirmText('delete', inv), 'Den Entwurf für Kunde Zwei über 50,00 € wirklich löschen? Das lässt sich nicht rückgängig machen.')
assert.equal(invoiceConfirmText('void', inv), 'Die Rechnung WK-0006 an Kunde Zwei über 50,00 € wirklich stornieren? Das lässt sich nicht rückgängig machen.')
assert.equal(invoiceConfirmText('resend', inv), 'Die Rechnung WK-0006 jetzt noch einmal an k@example.invalid senden (Erinnerung)?')
assert.equal(invoiceConfirmText('send', inv), '')
assert.equal(invoiceConfirmText('pdf', inv), '')
})
test('invoiceDoneMessage', () => {
const r = { number: 'WK-0007', customerEmail: 'k@example.invalid' }
assert.equal(invoiceDoneMessage('send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
assert.equal(invoiceDoneMessage('create-send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
assert.equal(invoiceDoneMessage('resend', r), 'Die Erinnerung zu Rechnung WK-0007 wurde an k@example.invalid gesendet.')
assert.equal(invoiceDoneMessage('void', r), 'Rechnung WK-0007 wurde storniert.')
assert.equal(invoiceDoneMessage('delete'), 'Der Entwurf wurde gelöscht.')
assert.equal(invoiceDoneMessage('create-draft', { number: '' }), 'Der Entwurf wurde gespeichert.')
assert.equal(invoiceDoneMessage('send', {}), 'Die Rechnung wurde gesendet.')
assert.equal(invoiceDoneMessage('xyz', r), '')
})
test('invoiceFailureMessage: 502 mit invoiceId nennt die Nummer, sonst die Meldung des Servers', () => {
const e502 = Object.assign(new Error('Stripe ist gerade nicht erreichbar.'), { status: 502, invoiceId: 'in_9', invoiceNumber: 'WK-0009' })
assert.equal(isFinalizedNotSent(e502), true)
assert.equal(invoiceFailureMessage(e502), 'Rechnung WK-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
const noNumber = Object.assign(new Error('x'), { status: 502, invoiceId: 'in_9' })
assert.equal(invoiceFailureMessage(noNumber), 'Die Rechnung wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
const plain502 = Object.assign(new Error('Stripe ist gerade nicht erreichbar.'), { status: 502 })
assert.equal(isFinalizedNotSent(plain502), false)
assert.equal(invoiceFailureMessage(plain502), 'Stripe ist gerade nicht erreichbar.')
assert.equal(invoiceFailureMessage(Object.assign(new Error('Diese Rechnung wird gerade schon erstellt.'), { status: 409 })), 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(invoiceFailureMessage(null), 'Die Aktion ist fehlgeschlagen.')
assert.equal(isFinalizedNotSent(null), false)
})
test('invoiceFailureMessage: scheiterte nur das Nachlesen, gilt die Meldung des Servers (die Rechnung ist raus)', () => {
const sentButUnreadable = Object.assign(
new Error('Rechnung WK-0010 wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.'),
{ status: 502, invoiceId: 'in_10', invoiceNumber: 'WK-0010' }
)
assert.equal(invoiceFailureMessage(sentButUnreadable), 'Rechnung WK-0010 wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.')
const draftUnreadable = Object.assign(new Error('Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.'), { status: 502, invoiceId: 'in_11', invoiceNumber: '' })
assert.match(invoiceFailureMessage(draftUnreadable), /^Der Entwurf wurde angelegt/)
const notSent = Object.assign(new Error('Rechnung WK-0010 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), { status: 502, invoiceId: 'in_10', invoiceNumber: 'WK-0010' })
assert.equal(invoiceFailureMessage(notSent), 'Rechnung WK-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
})
test('finalizedFilter: mit Nummer Offen, ohne (Entwurf) Entwürfe', () => {
assert.equal(finalizedFilter({ invoiceNumber: 'WK-0010' }), 'offen')
assert.equal(finalizedFilter({ invoiceNumber: '' }), 'entwurf')
assert.equal(finalizedFilter({}), 'entwurf')
assert.equal(finalizedFilter(null), 'entwurf')
})
// --- Rechnung schreiben (Dialog) ---------------------------------------------------------------------------
const CUST = { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', customerStatus: 'customer' }
const FORM = (over = {}) => ({
customer: CUST,
items: [{ description: 'Webdesign', quantity: '2', price: '1.234,50' }],
days: '14',
memo: '',
...over,
})
test('parseQuantity: ganze Zahlen 1 bis 999', () => {
assert.equal(parseQuantity('1'), 1)
assert.equal(parseQuantity(' 12 '), 12)
assert.equal(parseQuantity('999'), 999)
assert.equal(parseQuantity('007'), 7)
assert.equal(parseQuantity(3), 3)
for (const bad of ['0', '1000', '', '1,5', '-1', 'a', null, undefined, '1.0']) assert.equal(parseQuantity(bad), null, String(bad))
})
test('parseUnitPrice: Betrag in Euro, 0,01 € bis 100.000,00 €', () => {
assert.equal(parseUnitPrice('1.234,50'), 123450)
assert.equal(parseUnitPrice('120'), 12000)
assert.equal(parseUnitPrice('0,01'), 1)
assert.equal(parseUnitPrice('100.000,00'), INVOICE_LIMITS.maxUnitCents)
for (const bad of ['0', '0,00', '100.000,01', '-5', 'abc', '', '12.50', null, undefined]) assert.equal(parseUnitPrice(bad), null, String(bad))
})
test('lineTotalCents und invoiceTotalCents: ungültige Positionen zählen 0', () => {
assert.equal(lineTotalCents({ quantity: '2', price: '1.234,50' }), 246900)
assert.equal(lineTotalCents({ quantity: '0', price: '5' }), null)
assert.equal(lineTotalCents({ quantity: '1', price: '' }), null)
assert.equal(lineTotalCents(null), null)
assert.equal(invoiceTotalCents([{ quantity: '2', price: '10' }, { quantity: 'x', price: '10' }, { quantity: '1', price: '0,50' }]), 2050)
assert.equal(invoiceTotalCents(undefined), 0)
})
test('validateInvoiceForm: gültiges Formular -> Anfrage in Cent (ohne leere Notiz)', () => {
const r = validateInvoiceForm(FORM())
assert.equal(r.ok, true)
assert.deepEqual(r.payload, {
customerId: 'c1',
items: [{ description: 'Webdesign', quantity: 2, unitAmountCents: 123450 }],
daysUntilDue: 14,
})
assert.equal(r.totalCents, 246900)
const withMemo = validateInvoiceForm(FORM({ memo: ' Danke! ', days: '0', items: [{ description: ' A ', quantity: '1', price: '0,01' }] }))
assert.equal(withMemo.ok, true)
assert.equal(withMemo.payload.memo, 'Danke!')
assert.equal(withMemo.payload.daysUntilDue, 0)
assert.equal(withMemo.payload.items[0].description, 'A')
})
test('validateInvoiceForm: Kunde fehlt, Lead, ohne E-Mail', () => {
assert.deepEqual(validateInvoiceForm(FORM({ customer: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
const lead = validateInvoiceForm(FORM({ customer: { ...CUST, customerStatus: 'lead' } }))
assert.equal(lead.ok, false)
assert.equal(lead.error, 'Für Kunde Eins kann keine Rechnung geschrieben werden: Noch kein fester Kunde.')
const noMail = validateInvoiceForm(FORM({ customer: { ...CUST, email: '' } }))
assert.equal(noMail.error, 'Für Kunde Eins kann keine Rechnung geschrieben werden: E-Mail fehlt.')
})
test('validateInvoiceForm: Positionen werden mit Nummer gemeldet', () => {
const good = { description: 'A', quantity: '1', price: '5' }
assert.equal(validateInvoiceForm(FORM({ items: [] })).error, 'Eine Rechnung braucht 1 bis 20 Positionen.')
assert.equal(validateInvoiceForm(FORM({ items: Array(21).fill(good) })).error, 'Eine Rechnung braucht 1 bis 20 Positionen.')
assert.equal(validateInvoiceForm(FORM({ items: [good, { ...good, description: ' ' }] })).error, 'Position 2: Bitte eine Beschreibung eingeben.')
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, description: 'x'.repeat(201) }] })).error, 'Position 1: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, quantity: '0' }] })).error, 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, price: '0' }] })).error, 'Position 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).')
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, price: 'abc' }] })).error, 'Position 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).')
assert.equal(validateInvoiceForm(FORM({ items: Array(20).fill(good) })).ok, true)
})
test('validateInvoiceForm: Summe, Zahlungsziel und Notiz', () => {
const big = { description: 'A', quantity: '999', price: '100.000,00' }
assert.equal(validateInvoiceForm(FORM({ items: [big] })).error, 'Die Rechnungssumme ist zu hoch (höchstens 999.999,99 €).')
for (const days of ['', '-1', '61', '1,5', 'x']) {
assert.equal(validateInvoiceForm(FORM({ days })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', days)
}
assert.equal(validateInvoiceForm(FORM({ days: '60' })).ok, true)
assert.equal(validateInvoiceForm(FORM({ memo: 'x'.repeat(1001) })).error, 'Die Notiz darf höchstens 1000 Zeichen lang sein.')
assert.equal(validateInvoiceForm(FORM({ memo: 'x'.repeat(1000) })).ok, true)
assert.equal(validateInvoiceForm(undefined).ok, false)
})
const CUSTOMERS = [
{ $id: 'c3', name: 'Zeta GmbH', email: 'zeta@example.invalid', customerStatus: 'customer' },
{ $id: 'c1', name: 'Alpha AG', email: 'alpha@example.invalid' }, // ohne Status = Kunde
{ $id: 'c2', name: 'Beta Lead', email: 'beta@example.invalid', customerStatus: 'lead' },
{ $id: 'c4', name: 'Gamma', email: '', customerStatus: 'customer' },
{ $id: 'c5', companyName: 'Delta Firma', email: 'delta@example.invalid', customerStatus: 'customer' },
{ $id: 'c6', name: 'Alpha AG', email: 'alpha2@example.invalid', customerStatus: 'customer' },
{ $id: 'c7', name: '', email: 'x@example.invalid' },
null,
]
test('customerName: name, sonst companyName', () => {
assert.equal(customerName({ name: ' A ', companyName: 'B' }), 'A')
assert.equal(customerName({ companyName: 'B' }), 'B')
assert.equal(customerName({}), '')
assert.equal(customerName(null), '')
})
test('customerPickerOptions: nach Namen sortiert, Grund bei gesperrten, gleiche Namen mit E-Mail unterscheidbar', () => {
const o = customerPickerOptions(CUSTOMERS)
assert.deepEqual(o.map((x) => x.label), [
'Alpha AG (alpha@example.invalid)',
'Alpha AG (alpha2@example.invalid)',
'Beta Lead',
'Delta Firma',
'Gamma',
'Zeta GmbH',
])
const byId = Object.fromEntries(o.map((x) => [x.id, x]))
assert.equal(byId.c1.ok, true)
assert.equal(byId.c2.ok, false)
assert.equal(byId.c2.reason, 'Noch kein fester Kunde')
assert.equal(byId.c4.ok, false)
assert.equal(byId.c4.reason, 'E-Mail fehlt')
assert.equal(byId.c5.name, 'Delta Firma')
assert.equal(new Set(o.map((x) => x.label.toLowerCase())).size, o.length)
assert.deepEqual(customerPickerOptions(undefined), [])
})
test('customerSuggestions: nur Kunden, für die eine Rechnung möglich ist; Namensanfang zuerst; Teiltreffer auch per E-Mail', () => {
const o = customerPickerOptions(CUSTOMERS)
const all = customerSuggestions(o, '')
assert.deepEqual(all.map((s) => s.value), ['Alpha AG (alpha@example.invalid)', 'Alpha AG (alpha2@example.invalid)', 'Delta Firma', 'Zeta GmbH'])
assert.equal(all[2].hint, 'delta@example.invalid')
assert.ok(!all.some((s) => s.value.includes('Beta') || s.value === 'Gamma'), 'Lead und Kunde ohne E-Mail fehlen')
assert.deepEqual(customerSuggestions(o, 'ta').map((s) => s.value), ['Delta Firma', 'Zeta GmbH'])
assert.deepEqual(customerSuggestions(o, 'zeta@').map((s) => s.value), ['Zeta GmbH'])
assert.deepEqual(customerSuggestions(o, 'gamma'), [])
assert.equal(customerSuggestions(o, '', 2).length, 2)
})
test('resolveCustomerText: ok, gesperrt mit Grund, unbekannt, leer', () => {
const o = customerPickerOptions(CUSTOMERS)
assert.equal(resolveCustomerText(o, '').state, 'empty')
assert.equal(resolveCustomerText(o, ' ').state, 'empty')
const ok = resolveCustomerText(o, 'zeta gmbh')
assert.equal(ok.state, 'ok')
assert.equal(ok.option.id, 'c3')
const blocked = resolveCustomerText(o, 'Beta Lead')
assert.equal(blocked.state, 'blocked')
assert.equal(blocked.option.reason, 'Noch kein fester Kunde')
assert.equal(resolveCustomerText(o, 'Gamma').option.reason, 'E-Mail fehlt')
assert.equal(resolveCustomerText(o, 'Zeta').state, 'unknown', 'nur der ganze Name zählt, ein Teil nicht')
assert.equal(resolveCustomerText(o, 'Nie gehört').state, 'unknown')
assert.equal(resolveCustomerText(o, 'Alpha AG').state, 'unknown', 'mehrdeutiger Name braucht die E-Mail im Feld')
})
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
const TX = (over = {}) => ({
$id: 'b1', status: 'BOOK', bookingDate: '2026-01-05', valueDate: '2026-01-05', amountCents: 16879, currency: 'EUR',
counterpartyName: 'KUNDE EINS GMBH', counterpartyIban: 'DE00', remittance: 'Rechnung WK-0005 vom 28.07.2026', mcc: '',
category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '', note: '',
vorschlaege: [], ...over,
})
test('sortTransactions: neueste zuerst, gleiche Tage behalten die Serverreihenfolge, Vormerkung nach Wertstellung', () => {
const list = [
TX({ $id: 'a', bookingDate: '2026-01-05' }),
TX({ $id: 'b', bookingDate: '2026-03-01' }),
TX({ $id: 'c', bookingDate: '2026-01-05' }),
TX({ $id: 'd', bookingDate: '', valueDate: '2026-02-10', status: 'PDNG' }),
null,
]
assert.deepEqual(sortTransactions(list).map((t) => t.$id), ['b', 'd', 'a', 'c'])
assert.deepEqual(sortTransactions(undefined), [])
const before = list.map((t) => t && t.$id)
sortTransactions(list)
assert.deepEqual(list.map((t) => t && t.$id), before, 'Eingabe bleibt unverändert')
})
test('strongestSuggestion und suggestionText', () => {
const low = { kind: 'invoice', id: 'in_l', number: 'WK-0001', confidence: 'niedrig', reason: '', amountDiffers: false }
const mid = { kind: 'invoice', id: 'in_m', number: 'WK-0002', confidence: 'mittel', reason: '', amountDiffers: true }
const hi = { kind: 'payout', id: 'po_h', number: '', confidence: 'hoch', reason: '', amountDiffers: false }
const hi2 = { kind: 'invoice', id: 'in_h2', number: 'WK-0003', confidence: 'hoch', reason: '', amountDiffers: false }
assert.equal(strongestSuggestion([low, mid, hi, hi2]), hi)
assert.equal(strongestSuggestion([low, mid]), mid)
assert.equal(strongestSuggestion([]), null)
assert.equal(strongestSuggestion(undefined), null)
assert.equal(suggestionText(hi2), 'Vorschlag: Rechnung WK-0003 (hoch)')
assert.equal(suggestionText(hi), 'Vorschlag: Stripe-Auszahlung (hoch)')
assert.equal(suggestionText(mid), 'Vorschlag: Rechnung WK-0002 (mittel) · Betrag weicht ab')
assert.equal(suggestionText({ kind: 'invoice', id: 'in_x', number: '' }), 'Vorschlag: Rechnung in_x')
assert.equal(suggestionText(null), '')
})
test('assignmentText: Rechnung mit Nummer, Kategorien, Offen', () => {
assert.equal(assignmentText(TX({ category: 'rechnung', matchedInvoiceNumber: 'WK-0005' })), 'Rechnung WK-0005')
assert.equal(assignmentText(TX({ category: 'rechnung', matchedInvoiceNumber: '', matchedInvoiceId: 'in_1' })), 'Rechnung in_1')
assert.equal(assignmentText(TX({ category: 'stripe-auszahlung' })), 'Stripe-Auszahlung')
assert.equal(assignmentText(TX({ category: 'fixkosten' })), 'Fixkosten')
assert.equal(assignmentText(TX({ category: 'rechnung-extern' })), 'Einnahme ohne Stripe-Rechnung')
assert.equal(assignmentText(TX({ category: 'privat' })), 'Privat/Umbuchung')
assert.equal(assignmentText(TX({ category: 'sonstiges' })), 'Sonstiges')
assert.equal(assignmentText(TX({ category: '' })), 'Offen')
assert.equal(assignmentText(TX({ category: 'neu' })), 'neu')
})
test('transactionRows: Texte, Vorzeichen, Vorschlag, Aktionen', () => {
const rows = transactionRows([
TX({ $id: 'in', vorschlaege: [{ kind: 'invoice', id: 'in_5', number: 'WK-0005', confidence: 'hoch', reason: 'x', amountDiffers: false }] }),
TX({ $id: 'out', bookingDate: '2026-01-04', amountCents: -1000, counterpartyName: 'Hetzner', remittance: 'Server' }),
TX({ $id: 'rg', bookingDate: '2026-01-03', category: 'rechnung', matchedInvoiceNumber: 'WK-0004', note: 'bar' }),
TX({ $id: 'pv', bookingDate: '2026-01-02', category: 'privat' }),
TX({ $id: 'pd', bookingDate: '', valueDate: '2026-01-01', status: 'PDNG', counterpartyName: '', counterpartyIban: 'DE99', vorschlaege: [{ kind: 'invoice', id: 'in_5', number: 'WK-0005', confidence: 'hoch', reason: 'x', amountDiffers: false }] }),
])
assert.deepEqual(rows.map((r) => r.id), ['in', 'out', 'rg', 'pv', 'pd'])
const [inn, out, rg, pv, pd] = rows
assert.equal(inn.dateText, '05.01.2026')
assert.equal(inn.counterparty, 'KUNDE EINS GMBH')
assert.equal(inn.remittance, 'Rechnung WK-0005 vom 28.07.2026')
assert.equal(inn.amountText, '+168,79 €')
assert.equal(inn.incoming, true)
assert.equal(inn.assignmentText, 'Offen')
assert.equal(inn.suggestionText, 'Vorschlag: Rechnung WK-0005 (hoch)')
assert.equal(inn.action, 'assign')
assert.equal(inn.actionLabel, 'Zuordnen')
assert.equal(out.amountText, `${MINUS}10,00 €`)
assert.equal(out.incoming, false)
assert.equal(out.action, 'assign')
assert.equal(out.suggestionText, '')
assert.equal(rg.assignmentText, 'Rechnung WK-0004')
assert.equal(rg.action, null, 'eine zugeordnete Rechnung lässt sich nicht lösen')
assert.equal(rg.actionLabel, '')
assert.equal(rg.note, 'bar')
assert.equal(pv.action, 'change')
assert.equal(pv.actionLabel, 'Ändern')
assert.equal(pd.pending, true)
assert.equal(pd.suggestionText, '', 'vorgemerkt: kein Rechnungsvorschlag')
assert.equal(pd.dateText, '01.01.2026')
assert.equal(pd.counterparty, 'DE99')
assert.equal(transactionRows(null).length, 0)
})
// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
const OPEN = (over = {}) => INV({ status: 'offen', overdueDays: 0, ...over })
const SUG_INV = (over = {}) => ({ kind: 'invoice', id: 'in_1', number: 'WK-0006', confidence: 'hoch', reason: 'Rechnungsnummer im Verwendungszweck', amountDiffers: false, ...over })
test('assignModel: Eingang mit Vorschlägen, anderen offenen Rechnungen und Kategorien', () => {
const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV(), { kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'Stripe-Auszahlung gleicher Betrag', amountDiffers: false }] })
const m = assignModel(tx, {
invoices: [OPEN(), OPEN({ id: 'in_2', number: 'WK-0007', remainingCents: 5000 }), OPEN({ id: 'in_3', number: 'WK-0008', remainingCents: 999 }), INV({ id: 'in_4', status: 'bezahlt' }), INV({ id: 'in_5', status: 'entwurf' })],
fixedCosts: [{ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'monat', active: true }],
})
assert.equal(m.incoming, true)
assert.equal(m.pending, false)
assert.equal(m.assigned, false)
assert.equal(m.hint, '')
assert.deepEqual(m.suggestions.map((s) => s.key), ['invoice:in_1', 'payout:po_1'])
assert.equal(m.suggestions[0].number, 'WK-0006')
assert.deepEqual(m.suggestions[0].body, { invoiceId: 'in_1' })
assert.deepEqual(m.suggestions[1].body, { payoutId: 'po_1' })
assert.deepEqual(m.invoices.map((o) => o.key), ['invoice:in_2', 'invoice:in_3'], 'Vorschlag nicht doppelt, nur offene/überfällige')
assert.equal(m.invoices[0].disabled, false)
assert.equal(m.invoices[1].disabled, true)
assert.equal(m.invoices[1].disabledReason, 'Betrag weicht ab (offen: 9,99 €)')
assert.deepEqual(m.fixedCosts, [], 'keine Fixkosten für Eingänge')
assert.deepEqual(m.categories.map((c) => c.label), ['Einnahme ohne Stripe-Rechnung', 'Privat/Umbuchung', 'Sonstiges'])
assert.deepEqual(m.categories.map((c) => c.body), [{ category: 'rechnung-extern' }, { category: 'privat' }, { category: 'sonstiges' }])
assert.ok(m.byKey['invoice:in_2'] && m.byKey['category:privat'] && m.byKey['payout:po_1'])
})
test('assignModel: Vorschlag mit abweichendem Betrag ist nicht wählbar', () => {
const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV({ amountDiffers: true, confidence: 'mittel' })] })
const m = assignModel(tx, { invoices: [] })
assert.equal(m.suggestions[0].disabled, true)
assert.match(m.suggestions[0].disabledReason, /Betrag weicht ab/)
const known = assignModel(TX({ amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN({ remainingCents: 4000 })] })
assert.equal(known.suggestions[0].disabled, true, 'auch wenn die Liste den Unterschied zeigt')
})
test('assignModel: Ausgang bekommt Fixkosten (nur aktive) und Privat/Sonstiges, keine Rechnungen', () => {
const tx = TX({ amountCents: -1000, vorschlaege: [SUG_INV()] })
const m = assignModel(tx, {
invoices: [OPEN()],
fixedCosts: [
{ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'monat', active: true },
{ $id: 'f2', name: 'Alt', amountCents: 500, interval: 'jahr', active: false },
{ $id: 'f3', name: 'Domain', amountCents: 1999, interval: 'jahr' },
],
})
assert.equal(m.outgoing, true)
assert.deepEqual(m.suggestions, [])
assert.deepEqual(m.invoices, [])
assert.deepEqual(m.fixedCosts.map((f) => f.key), ['fixedCost:f1', 'fixedCost:f3'])
assert.equal(m.fixedCosts[0].label, 'Hetzner · 10,00 € monatlich')
assert.deepEqual(m.fixedCosts[0].body, { fixedCostId: 'f1' })
assert.deepEqual(m.categories.map((c) => c.body.category), ['privat', 'sonstiges'])
})
test('assignModel: Vorgemerkt (PDNG) nur Kategorien und Fixkosten, mit Hinweis', () => {
assert.equal(PENDING_ASSIGN_HINT, 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.')
const incoming = assignModel(TX({ status: 'PDNG', amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN()] })
assert.equal(incoming.pending, true)
assert.equal(incoming.hint, PENDING_ASSIGN_HINT)
assert.deepEqual(incoming.suggestions, [])
assert.deepEqual(incoming.invoices, [])
assert.ok(incoming.categories.length > 0)
assert.ok(!Object.keys(incoming.byKey).some((k) => k.startsWith('invoice:') || k.startsWith('payout:')))
const outgoing = assignModel(TX({ status: 'PDNG', amountCents: -300 }), { fixedCosts: [{ $id: 'f1', name: 'X', amountCents: 300, interval: 'monat' }] })
assert.deepEqual(outgoing.fixedCosts.map((f) => f.key), ['fixedCost:f1'])
})
test('assignModel: zugeordnete Buchung zeigt nur die Zuordnung; Rechnung nicht lösbar', () => {
const privat = assignModel(TX({ category: 'privat' }), { invoices: [OPEN()] })
assert.equal(privat.assigned, true)
assert.equal(privat.currentText, 'Privat/Umbuchung')
assert.equal(privat.canUnassign, true)
assert.deepEqual(privat.byKey, {})
assert.equal(privat.categories.length, 0)
const rg = assignModel(TX({ category: 'rechnung', matchedInvoiceNumber: 'WK-0005' }))
assert.equal(rg.currentText, 'Rechnung WK-0005')
assert.equal(rg.canUnassign, false)
const none = assignModel(null)
assert.equal(none.assigned, false)
})
test('assignBody und invoiceAssignConfirmText', () => {
assert.deepEqual(assignBody({ body: { invoiceId: 'in_1' } }, ''), { invoiceId: 'in_1' })
assert.deepEqual(assignBody({ body: { category: 'privat' } }, ' Miete privat '), { category: 'privat', note: 'Miete privat' })
assert.deepEqual(assignBody(null, 'x'), { note: 'x' })
assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.')
})