Files
Webklar-Kundenbereich/test/financeAssign.test.js
T
ClaudeandClaude Sonnet 5.5 83ee9d5a25 Finanzen: Zuordnung zu Rechnungen robuster (Speicherfehler nach pay, Doppelzuordnung, Protokoll)
M1: scheitert Appwrite nach dem Bezahlen, kommt 502 mit klarer Meldung statt eines blanken 500.
M2: Wiederaufnahme nur bei passendem bezahltem Betrag; eine Buchung, die schon an einer anderen
bezahlten Rechnung haengt, wird nicht ein zweites Mal vermerkt und bezahlt (409).
M3: Protokollzeile je erfolgreicher, nicht umkehrbarer Zuordnung (Buchung, Rechnung, Nutzer).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

677 lines
31 KiB
JavaScript

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import Stripe from 'stripe'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeRoutesBank.test.js)
const aw = await startAppwriteMock({
schema: { bankTransactions: schemaKeys('bankTransactions'), financeFixedCosts: schemaKeys('financeFixedCosts') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
const { euroDe } = await import('../server/services/finance/format.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(async () => {
mock.restoreAll()
await Promise.all([app.close(), aw.close(), st.close()])
})
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const base = '/api/admin/finanzen'
const TX = () => config.collections.bankTransactions
const stored = (id) => aw.docs(TX()).get(id)
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const payCalls = () => callsTo('POST', /\/pay$/)
const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
const BOOKING = '2026-09-29'
const tx = (id, over = {}) => ({
$id: id,
accountUid: 'konto-1',
status: 'BOOK',
bookingDate: BOOKING,
valueDate: BOOKING,
amountCents: 16879,
currency: 'EUR',
counterpartyName: 'EWV SOFTWARE GMBH',
counterpartyIban: '',
remittance: 'Rechnung WK-0005 vom 28.07.2026',
mcc: '',
entryReference: id,
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
syncedAt: '2026-10-01T09:00:00.000Z',
...over,
})
const rawInvoice = (over = {}) => ({
id: 'in_ewv',
object: 'invoice',
number: 'WK-0005',
status: 'open',
customer: 'cus_ewv',
customer_name: 'EWV Software GmbH',
customer_email: 'ewv@example.invalid',
created: unix('2026-08-01'),
due_date: unix('2026-10-20'),
total: 16879,
amount_due: 16879,
amount_paid: 0,
amount_remaining: 16879,
status_transitions: { paid_at: null },
metadata: {},
lines: { object: 'list', data: [], has_more: false },
...over,
})
const rawPayout = (over = {}) => ({
id: 'po_1',
object: 'payout',
amount: 45000,
arrival_date: unix('2026-09-28'),
status: 'paid',
currency: 'eur',
...over,
})
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
// aw.docs liefert eine Kopie: Dokumente loescht der Test ueber den Server wie die Anwendung
async function reset() {
mock.restoreAll()
invalidateFinanceCache()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
st.payouts.clear()
for (const collection of [TX(), config.collections.financeFixedCosts]) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
}
beforeEach(reset)
const seedTx = (id, over) => aw.seed(TX(), tx(id, over))
const zuordnen = (id, body) => post(`${base}/buchungen/${id}/zuordnen`, body, admin)
const loesen = async (id) => {
const res = await fetch(app.url + `${base}/buchungen/${id}/zuordnung`, { method: 'DELETE', headers: admin.headers })
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null }
}
// ---------- Zugriff ----------
test('zuordnen und loesen: ohne JWT 401', async () => {
seedTx('b1')
assert.equal((await post(`${base}/buchungen/b1/zuordnen`, { category: 'privat' })).status, 401)
const res = await fetch(app.url + `${base}/buchungen/b1/zuordnung`, { method: 'DELETE' })
assert.equal(res.status, 401)
assert.equal(stored('b1').category, '')
})
// ---------- Rechnung ----------
test('Rechnung: Stripe pay (paid_out_of_band) + Metadaten, Buchung wird rechnung', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv', note: ' Überweisung EWV ' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body), ['buchung'])
assert.equal(res.body.buchung.$id, 'b1')
assert.equal(res.body.buchung.category, 'rechnung')
assert.equal(res.body.buchung.matchedInvoiceId, 'in_ewv')
assert.equal(res.body.buchung.matchedInvoiceNumber, 'WK-0005')
assert.equal(res.body.buchung.note, 'Überweisung EWV')
assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id'])
assert.equal(payCalls().length, 1)
assert.equal(payCalls()[0].path, '/v1/invoices/in_ewv/pay')
assert.equal(payCalls()[0].body.paid_out_of_band, 'true')
const updates = callsTo('POST', '/v1/invoices/in_ewv')
assert.equal(updates.length, 1)
assert.equal(updates[0].body['metadata[bankTransactionId]'], 'b1')
assert.equal(updates[0].body['metadata[bankBookingDate]'], BOOKING)
// erst der Vermerk, dann das Bezahlen (der Vermerk ist umkehrbar, das Bezahlen nicht)
assert.ok(st.calls.indexOf(updates[0]) < st.calls.indexOf(payCalls()[0]))
const invoice = st.invoices.get('in_ewv')
assert.equal(invoice.status, 'paid')
assert.equal(invoice.metadata.bankTransactionId, 'b1')
const doc = stored('b1')
assert.equal(doc.category, 'rechnung')
assert.equal(doc.matchedInvoiceId, 'in_ewv')
assert.equal(doc.matchedInvoiceNumber, 'WK-0005')
})
test('Rechnung: Zwischenspeicher wird geleert (die Liste zeigt die Rechnung nicht mehr als offen)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const { listInvoices } = await import('../server/services/finance/stripeFinance.js')
assert.equal((await listInvoices())[0].status, 'offen')
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
const [invoice] = await listInvoices()
assert.equal(invoice.status, 'bezahlt')
assert.equal(invoice.outOfBand, true)
assert.equal(invoice.paidDate, BOOKING)
})
test('Rechnung: zwei parallele Zuordnungen derselben Buchung -> eine 200, eine 409, genau ein pay', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const results = await Promise.allSettled([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b1', { invoiceId: 'in_ewv' })])
const statuses = results.map((r) => r.value.status).sort()
assert.deepEqual(statuses, [200, 409])
const loser = results.map((r) => r.value).find((r) => r.status === 409)
assert.equal(loser.body.error, 'Bereits zugeordnet.')
assert.equal(payCalls().length, 1)
})
test('Rechnung: dieselbe Rechnung fuer zwei Buchungen gleichzeitig -> eine 200, eine 409, genau ein pay', async () => {
seedTx('b1')
seedTx('b2')
st.invoices.set('in_ewv', rawInvoice())
const results = await Promise.all([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b2', { invoiceId: 'in_ewv' })])
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
assert.equal(payCalls().length, 1)
const assigned = ['b1', 'b2'].filter((id) => stored(id).category === 'rechnung')
assert.equal(assigned.length, 1)
})
test('Rechnung: Betrag weicht ab -> 409 mit Betrag, kein pay, Buchung unveraendert', async () => {
seedTx('b1', { amountCents: 10000 })
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, `Betrag weicht vom offenen Betrag ab (${euroDe(16879)}). Teilzahlungen bitte direkt in Stripe erfassen.`)
assert.equal(payCalls().length, 0)
assert.equal(stored('b1').category, '')
assert.equal(st.invoices.get('in_ewv').status, 'open')
})
test('Rechnung: nicht offen (bezahlt) -> 409, kein pay', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879 }))
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.')
assert.equal(payCalls().length, 0)
assert.equal(stored('b1').category, '')
})
test('Rechnung: unbekannte oder ungueltige Rechnungs-ID -> 404', async () => {
seedTx('b1')
assert.equal((await zuordnen('b1', { invoiceId: 'in_gibtsnicht' })).status, 404)
assert.equal((await zuordnen('b1', { invoiceId: '../x' })).status, 404)
assert.equal(payCalls().length, 0)
})
test('Rechnung: nur Eingaenge, eine Ausgangsbuchung -> 400', async () => {
seedTx('b1', { amountCents: -16879 })
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 400)
assert.equal(payCalls().length, 0)
})
test('bereits zugeordnet -> 409 "Bereits zugeordnet." ohne jeden Stripe-Aufruf', async () => {
seedTx('b1', { category: 'privat' })
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Bereits zugeordnet.')
assert.equal(st.calls.length, 0)
})
test('Stripe lehnt pay ab (4xx) -> 409 ohne Stripe-Text, Buchung bleibt offen', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'Geheimer Stripe-Text sk_live_abc' })
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(stored('b1').category, '')
})
test('Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
mock.method(getStripe().invoices, 'retrieve', async () => {
throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' })
})
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 502)
assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.')
assert.ok(!JSON.stringify(res.body).includes('10.0.0.1'))
})
test('Vermerk scheitert (4xx): 409, kein pay, Rechnung bleibt offen, Buchung unveraendert', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'POST', path: '/v1/invoices/in_ewv', status: 400, message: 'Geheimer Vermerk-Text' })
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(payCalls().length, 0)
assert.equal(st.invoices.get('in_ewv').status, 'open')
assert.equal(stored('b1').category, '')
})
test('Vermerk scheitert (Verbindungsfehler): 502, kein pay', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
mock.method(getStripe().invoices, 'update', async () => {
throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' })
})
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 502)
assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.')
assert.equal(payCalls().length, 0)
assert.equal(st.invoices.get('in_ewv').status, 'open')
})
test('pay scheitert nach dem Vermerk: Wiederholung bezahlt genau einmal und vermerkt nicht erneut', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'pay abgelehnt' })
const failed = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(failed.status, 409)
assert.equal(st.invoices.get('in_ewv').status, 'open')
assert.equal(st.invoices.get('in_ewv').metadata.bankTransactionId, 'b1')
assert.equal(stored('b1').category, '')
const retry = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(retry.status, 200)
assert.equal(stored('b1').category, 'rechnung')
assert.equal(st.invoices.get('in_ewv').status, 'paid')
// pay: ein gescheiterter und ein erfolgreicher Aufruf; der Vermerk wurde nur einmal gesetzt
assert.equal(payCalls().length, 2)
assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 1)
})
test('offene Rechnung mit dem Vermerk einer anderen Buchung: Vermerk wird ueberschrieben', async () => {
seedTx('b2')
st.invoices.set('in_ewv', rawInvoice({ metadata: { bankTransactionId: 'b1-alt', bankBookingDate: '2026-09-01' } }))
assert.equal((await zuordnen('b2', { invoiceId: 'in_ewv' })).status, 200)
assert.equal(st.invoices.get('in_ewv').metadata.bankTransactionId, 'b2')
assert.equal(st.invoices.get('in_ewv').metadata.bankBookingDate, BOOKING)
})
test('bezahlte Rechnung mit dem Vermerk einer anderen Buchung -> 409 nicht mehr offen', async () => {
seedTx('b2')
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } }))
const res = await zuordnen('b2', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.')
assert.equal(stored('b2').category, '')
})
test('Appwrite scheitert nach pay: 502 mit klarer Meldung, die Wiederholung schliesst ab ohne zweites pay (Rechnung ohne paid_out_of_band wie bei Stripe) (M1)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const error = mock.method(console, 'error', () => {})
aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' })
const failed = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(failed.status, 502)
assert.equal(failed.body.error, 'In Stripe als bezahlt markiert, aber die Buchung wurde nicht gespeichert. Bitte erneut zuordnen.')
assert.deepEqual(Object.keys(failed.body), ['error'])
assert.ok(!JSON.stringify(failed.body).includes('Appwrite kaputt'))
// die Ursache steht im Protokoll (Art und Status), nie ihre Meldung
assert.equal(error.mock.callCount(), 1)
assert.equal(error.mock.calls[0].arguments[0], '[finanzen] Buchung zuordnen fehlgeschlagen:')
assert.ok(!JSON.stringify(error.mock.calls).includes('Appwrite kaputt'))
mock.restoreAll()
assert.equal(stored('b1').category, '')
assert.equal(st.invoices.get('in_ewv').status, 'paid')
// wie die echte API: an der bezahlten Rechnung steht nichts von paid_out_of_band
assert.equal(st.invoices.get('in_ewv').paid_out_of_band, undefined)
const retry = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(retry.status, 200)
assert.equal(stored('b1').category, 'rechnung')
assert.equal(stored('b1').matchedInvoiceId, 'in_ewv')
assert.equal(stored('b1').matchedInvoiceNumber, 'WK-0005')
assert.equal(payCalls().length, 1)
assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 1)
})
test('Appwrite scheitert beim Zuordnen ohne Rechnung (Kategorie): weiter ein blanker 500 (M1)', async () => {
seedTx('b1')
mock.method(console, 'error', () => {})
aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' })
const failed = await zuordnen('b1', { category: 'privat' })
assert.equal(failed.status, 500)
assert.equal(failed.body.error, 'Interner Fehler.')
})
test('bezahlt und gekennzeichnet, aber in anderer Hoehe bezahlt als die Buchung -> keine Wiederaufnahme, 409 nicht mehr offen (M2)', async () => {
seedTx('b1', { amountCents: 10000 })
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } }))
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.')
assert.equal(stored('b1').category, '')
assert.equal(payCalls().length, 0)
})
test('Buchung gehoert schon zu einer anderen bezahlten Rechnung -> 409, kein Vermerk, kein pay (M2)', async () => {
seedTx('b1')
// in_alt wurde fuer b1 bezahlt, aber das Speichern in Appwrite scheiterte: b1 steht noch ohne Kategorie da
st.invoices.set('in_alt', rawInvoice({ id: 'in_alt', number: 'WK-0004', status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } }))
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Buchung ist bereits einer anderen Rechnung zugeordnet.')
assert.equal(payCalls().length, 0)
assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 0, 'kein Vermerk')
assert.equal(st.invoices.get('in_ewv').status, 'open')
assert.equal(stored('b1').category, '')
// die Pruefung liest frisch von Stripe
assert.ok(callsTo('GET', '/v1/invoices').length >= 1)
// dieselbe Rechnung noch einmal zuordnen (Wiederaufnahme) bleibt moeglich
const resume = await zuordnen('b1', { invoiceId: 'in_alt' })
assert.equal(resume.status, 200, JSON.stringify(resume.body))
assert.equal(stored('b1').matchedInvoiceId, 'in_alt')
assert.equal(payCalls().length, 0)
})
test('Buchung ist nur an einer offenen oder fremden Rechnung vermerkt -> kein Hindernis (M2)', async () => {
seedTx('b1')
seedTx('b2')
// offene Rechnung mit Vermerk von b1 (pay scheiterte frueher) und eine bezahlte mit dem Vermerk von b2
st.invoices.set('in_offen', rawInvoice({ id: 'in_offen', number: 'WK-0003', amount_remaining: 5000, total: 5000, metadata: { bankTransactionId: 'b1' } }))
st.invoices.set('in_b2', rawInvoice({ id: 'in_b2', number: 'WK-0002', status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b2' } }))
st.invoices.set('in_ewv', rawInvoice())
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
assert.equal(payCalls().length, 1)
})
test('Zuordnung zu einer Rechnung schreibt eine Protokollzeile mit Buchung, Rechnungsnummer und Nutzer, ohne Betrag (M3)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const log = mock.method(console, 'log', () => {})
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
assert.deepEqual(log.mock.calls.map((c) => c.arguments), [['[finanzen] Zuordnung b1 -> Rechnung WK-0005 durch kenso@webklar.com']])
assert.ok(!log.mock.calls[0].arguments[0].includes('168'))
})
test('Wiederaufnahme schreibt die Protokollzeile einmal beim Abschluss, nicht beim Scheitern (M3)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
mock.method(console, 'error', () => {})
const log = mock.method(console, 'log', () => {})
aw.failNext({ collection: TX(), method: 'PATCH', status: 500 })
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 502)
assert.equal(log.mock.callCount(), 0)
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
assert.deepEqual(log.mock.calls.map((c) => c.arguments[0]), ['[finanzen] Zuordnung b1 -> Rechnung WK-0005 durch kenso@webklar.com'])
})
test('Auszahlung, Fixkosten und Kategorie schreiben keine Zuordnungs-Protokollzeile (umkehrbar) (M3)', async () => {
seedTx('b1')
const log = mock.method(console, 'log', () => {})
assert.equal((await zuordnen('b1', { category: 'privat' })).status, 200)
assert.equal(log.mock.callCount(), 0)
})
// ---------- Vorgemerkte Buchungen ----------
test('vorgemerkte Buchung: Rechnung und Auszahlung -> 409 vor jedem Stripe-Aufruf', async () => {
seedTx('p1', { status: 'PDNG' })
seedTx('p2', { status: 'PDNG', amountCents: 45000 })
st.invoices.set('in_ewv', rawInvoice())
st.payouts.set('po_1', rawPayout())
const message = 'Die Buchung ist noch vorgemerkt. Bitte zuordnen, wenn sie gebucht ist.'
const invoice = await zuordnen('p1', { invoiceId: 'in_ewv' })
assert.equal(invoice.status, 409)
assert.equal(invoice.body.error, message)
const payout = await zuordnen('p2', { payoutId: 'po_1' })
assert.equal(payout.status, 409)
assert.equal(payout.body.error, message)
assert.equal(st.calls.length, 0)
assert.equal(stored('p1').category, '')
assert.equal(stored('p2').category, '')
})
test('vorgemerkte Buchung: Kategorien und Fixkosten bleiben moeglich', async () => {
seedTx('p1', { status: 'PDNG', amountCents: 700 })
seedTx('p2', { status: 'PDNG', amountCents: -449 })
seedTx('p3', { status: 'PDNG', amountCents: -300 })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
assert.equal((await zuordnen('p1', { category: 'rechnung-extern' })).status, 200)
assert.equal((await zuordnen('p2', { fixedCostId: 'fk1' })).status, 200)
assert.equal((await zuordnen('p3', { category: 'privat' })).status, 200)
assert.equal(st.calls.length, 0)
})
// ---------- Auszahlung ----------
test('Auszahlung: gleicher Betrag -> stripe-auszahlung mit matchedPayoutId', async () => {
seedTx('b1', { amountCents: 45000, counterpartyName: 'STRIPE PAYMENTS EUROPE', remittance: '' })
st.payouts.set('po_1', rawPayout())
const res = await zuordnen('b1', { payoutId: 'po_1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.buchung.category, 'stripe-auszahlung')
assert.equal(res.body.buchung.matchedPayoutId, 'po_1')
assert.equal(stored('b1').matchedPayoutId, 'po_1')
assert.equal(payCalls().length, 0)
})
test('Auszahlung: Betrag weicht ab -> 409; unbekannt -> 404; fehlgeschlagen/storniert -> 409', async () => {
seedTx('b1', { amountCents: 44000 })
st.payouts.set('po_1', rawPayout())
st.payouts.set('po_f', rawPayout({ id: 'po_f', status: 'failed', amount: 44000 }))
st.payouts.set('po_c', rawPayout({ id: 'po_c', status: 'canceled', amount: 44000 }))
const diff = await zuordnen('b1', { payoutId: 'po_1' })
assert.equal(diff.status, 409)
assert.match(diff.body.error, /Betrag weicht von der Auszahlung ab/)
assert.equal((await zuordnen('b1', { payoutId: 'po_gibtsnicht' })).status, 404)
for (const id of ['po_f', 'po_c']) {
const res = await zuordnen('b1', { payoutId: id })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Auszahlung ist fehlgeschlagen oder storniert.')
}
assert.equal(stored('b1').category, '')
})
test('Auszahlung: dieselbe Auszahlung nicht zweimal vergeben', async () => {
seedTx('b1', { amountCents: 45000 })
seedTx('b2', { amountCents: 45000 })
st.payouts.set('po_1', rawPayout())
assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 200)
const second = await zuordnen('b2', { payoutId: 'po_1' })
assert.equal(second.status, 409)
assert.equal(second.body.error, 'Diese Auszahlung ist schon einer Buchung zugeordnet.')
assert.equal(stored('b2').category, '')
})
test('Auszahlung: Ausgangsbuchung -> 400', async () => {
seedTx('b1', { amountCents: -45000 })
st.payouts.set('po_1', rawPayout())
assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 400)
})
// ---------- Fixkosten ----------
test('Fixkosten: Ausgang mit vorhandener Position -> fixkosten mit fixedCostId', async () => {
seedTx('b1', { amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
const res = await zuordnen('b1', { fixedCostId: 'fk1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.buchung.category, 'fixkosten')
assert.equal(res.body.buchung.fixedCostId, 'fk1')
assert.equal(stored('b1').fixedCostId, 'fk1')
})
test('Fixkosten: unbekannte Position -> 400, Eingangsbuchung -> 400', async () => {
seedTx('b1', { amountCents: -449 })
seedTx('b2', { amountCents: 449 })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
const missing = await zuordnen('b1', { fixedCostId: 'fk-gibtsnicht' })
assert.equal(missing.status, 400)
assert.equal(missing.body.error, 'Diese Fixkosten-Position gibt es nicht.')
assert.equal((await zuordnen('b2', { fixedCostId: 'fk1' })).status, 400)
assert.equal(stored('b1').category, '')
assert.equal(stored('b2').category, '')
})
// ---------- Kategorien ----------
test('Kategorien: privat und sonstiges fuer Ein- und Ausgang, rechnung-extern nur fuer Eingang', async () => {
seedTx('in1', { amountCents: 5000 })
seedTx('out1', { amountCents: -5000 })
seedTx('out2', { amountCents: -700 })
seedTx('in2', { amountCents: 700 })
const extern = await zuordnen('in1', { category: 'rechnung-extern', note: 'Rechnung 17 außerhalb von Stripe' })
assert.equal(extern.status, 200)
assert.equal(extern.body.buchung.category, 'rechnung-extern')
assert.equal(stored('in1').note, 'Rechnung 17 außerhalb von Stripe')
const externOut = await zuordnen('out1', { category: 'rechnung-extern' })
assert.equal(externOut.status, 400)
assert.equal(stored('out1').category, '')
assert.equal((await zuordnen('out1', { category: 'privat' })).status, 200)
assert.equal(stored('out1').category, 'privat')
assert.equal((await zuordnen('in2', { category: 'sonstiges' })).status, 200)
assert.equal(stored('in2').category, 'sonstiges')
assert.equal((await zuordnen('out2', { category: 'sonstiges' })).status, 200)
assert.equal(st.calls.length, 0)
})
test('Eingaben: genau ein Ziel, bekannte Kategorie, Notiz hoechstens 500 Zeichen, bekannte Buchung', async () => {
seedTx('b1')
const cases = [
[{}, 400],
[{ invoiceId: 'in_ewv', payoutId: 'po_1' }, 400],
[{ category: 'privat', fixedCostId: 'fk1' }, 400],
// rechnung, stripe-auszahlung und fixkosten entstehen nur ueber ihr Ziel, nicht als nackte Kategorie
[{ category: 'rechnung' }, 400],
[{ category: 'stripe-auszahlung' }, 400],
[{ category: 'fixkosten' }, 400],
[{ category: 'irgendwas' }, 400],
[{ category: 'privat', note: 'x'.repeat(501) }, 400],
[{ category: 'privat', note: 42 }, 400],
[{ invoiceId: 42 }, 400],
]
for (const [body, status] of cases) {
const res = await zuordnen('b1', body)
assert.equal(res.status, status, JSON.stringify(body))
assert.ok(res.body.error)
}
assert.equal(stored('b1').category, '')
assert.equal(st.calls.length, 0)
assert.equal((await zuordnen('b1', { category: 'privat', note: 'x'.repeat(500) })).status, 200)
assert.equal((await zuordnen('gibtsnicht', { category: 'privat' })).status, 404)
assert.equal((await zuordnen('../b1', { category: 'privat' })).status, 404)
})
// ---------- Zuordnung loesen ----------
test('loesen: rechnung -> 409, Stripe und Buchung bleiben unveraendert', async () => {
seedTx('b1', { category: 'rechnung', matchedInvoiceId: 'in_ewv', matchedInvoiceNumber: 'WK-0005' })
const res = await loesen('b1')
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.')
assert.equal(stored('b1').category, 'rechnung')
assert.equal(stored('b1').matchedInvoiceId, 'in_ewv')
assert.equal(st.calls.length, 0)
})
test('loesen: privat, stripe-auszahlung und fixkosten -> alle Zuordnungsfelder leer, Notiz bleibt', async () => {
seedTx('b1', { category: 'privat', note: 'Mittagessen' })
seedTx('b2', { category: 'stripe-auszahlung', matchedPayoutId: 'po_1' })
seedTx('b3', { amountCents: -449, category: 'fixkosten', fixedCostId: 'fk1' })
const res = await loesen('b1')
assert.equal(res.status, 200)
assert.equal(res.body.buchung.category, '')
assert.equal(res.body.buchung.note, 'Mittagessen')
assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id'])
assert.equal((await loesen('b2')).status, 200)
assert.equal((await loesen('b3')).status, 200)
for (const id of ['b1', 'b2', 'b3']) {
const doc = stored(id)
for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId']) {
assert.equal(doc[key], '', `${id}.${key}`)
}
}
// danach laesst sich die Buchung neu zuordnen
assert.equal((await zuordnen('b1', { category: 'sonstiges' })).status, 200)
})
test('loesen: unbekannte Buchung -> 404; nicht zugeordnete Buchung bleibt unveraendert (200)', async () => {
seedTx('b1')
assert.equal((await loesen('gibtsnicht')).status, 404)
const res = await loesen('b1')
assert.equal(res.status, 200)
assert.equal(res.body.buchung.category, '')
})
// ---------- GET /buchungen ----------
test('GET /buchungen: Eingang bekommt einen Vorschlag, Ausgang und zugeordnete nicht', async () => {
seedTx('ewv', { bookingDate: '2026-09-29' })
seedTx('aus', { bookingDate: '2026-09-28', amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' })
seedTx('fertig', { bookingDate: '2026-09-27', category: 'privat' })
st.invoices.set('in_ewv', rawInvoice())
const body = await get(`${base}/buchungen`, admin)
assert.equal(body.stripeFehler, undefined)
const byId = Object.fromEntries(body.buchungen.map((b) => [b.$id, b]))
assert.equal(byId.ewv.vorschlaege.length, 1)
assert.equal(byId.ewv.vorschlaege[0].kind, 'invoice')
assert.equal(byId.ewv.vorschlaege[0].id, 'in_ewv')
assert.equal(byId.ewv.vorschlaege[0].number, 'WK-0005')
assert.equal(byId.ewv.vorschlaege[0].confidence, 'hoch')
assert.deepEqual(byId.aus.vorschlaege, [])
assert.deepEqual(byId.fertig.vorschlaege, [])
})
test('GET /buchungen: Stripe-Aufrufe nur, wenn es einen offenen Eingang gibt', async () => {
seedTx('aus', { amountCents: -449 })
seedTx('fertig', { category: 'privat' })
const body = await get(`${base}/buchungen`, admin)
assert.equal(body.buchungen.length, 2)
assert.equal(body.stripeFehler, undefined)
assert.equal(st.calls.length, 0)
})
for (const path of ['/v1/invoices', '/v1/payouts']) {
test(`GET /buchungen: Stripe-Fehler bei ${path} -> 200 mit stripeFehler und leeren Vorschlaegen`, async () => {
seedTx('ewv')
seedTx('aus', { amountCents: -449 })
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'GET', path, status: 400, message: 'Geheimer Stripe-Text' })
const res = await rawGet(`${base}/buchungen`, admin)
assert.equal(res.status, 200)
assert.equal(res.body.stripeFehler, 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(res.body.buchungen.length, 2)
for (const buchung of res.body.buchungen) assert.deepEqual(buchung.vorschlaege, [])
})
}