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>
677 lines
31 KiB
JavaScript
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, [])
|
|
})
|
|
}
|