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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent daa451094a
commit 83ee9d5a25
3 files changed
+118 -11

No files matched your search

+1 -1
View File
@@ -168,7 +168,7 @@ function sendFinanceError(res, what, err) {
router.post('/buchungen/:id/zuordnen', async (req, res) => {
const { invoiceId, payoutId, fixedCostId, category, note } = req.body || {}
try {
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note })
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note }, { by: req.appwriteUser?.email })
return res.json({ buchung: publicDoc(buchung) })
} catch (err) {
return sendFinanceError(res, 'Buchung zuordnen', err)
+27 -7
View File
@@ -116,14 +116,18 @@ async function resolveInvoice(tx, invoiceId) {
// Der Vermerk steht vor dem Bezahlen an der Rechnung: er verraet einer Wiederholung, dass die Rechnung zu dieser
// Buchung gehoert (Stripe liefert paid_out_of_band nicht an der Rechnung zurueck)
const linked = invoice.metadata?.bankTransactionId === tx.$id
// Schon bezahlt und mit dieser Buchung gekennzeichnet: ein frueherer Versuch scheiterte erst danach (Appwrite),
// es bleibt nur, die Buchung zu speichern
const resumed = invoice.status === 'paid' && linked
// Schon bezahlt und mit dieser Buchung gekennzeichnet (und in genau dieser Hoehe bezahlt): ein frueherer Versuch
// scheiterte erst danach (Appwrite), es bleibt nur, die Buchung zu speichern
const resumed = invoice.status === 'paid' && linked && invoice.amount_paid === tx.amountCents
if (!resumed) {
if (invoice.status !== 'open') throw new AssignError(409, 'Diese Rechnung ist nicht mehr offen.')
if (invoice.amount_remaining !== tx.amountCents) {
throw new AssignError(409, `Betrag weicht vom offenen Betrag ab (${euroDe(invoice.amount_remaining)}). Teilzahlungen bitte direkt in Stripe erfassen.`)
}
// Gehoert die Buchung schon zu einer anderen, bezahlten Rechnung (deren Speichern in Appwrite scheiterte), wuerde sie
// sonst ein zweites Mal bezahlt verbucht. Frisch von Stripe, nicht aus dem Zwischenspeicher.
const claimed = (await listInvoices({ force: true })).some((other) => other.id !== invoice.id && other.bankTransactionId === tx.$id && other.status === 'bezahlt')
if (claimed) throw new AssignError(409, 'Diese Buchung ist bereits einer anderen Rechnung zugeordnet.')
// Erst der Vermerk (umkehrbar), dann das Bezahlen (nicht umkehrbar): scheitert der Vermerk, bleibt die Rechnung offen
if (!linked) {
await stripe.invoices.update(invoice.id, { metadata: { bankTransactionId: tx.$id, bankBookingDate: tx.bookingDate || '' } })
@@ -171,10 +175,12 @@ function resolveCategory(tx, category) {
* Buchung zuordnen. Genau eines von invoiceId, payoutId, fixedCostId oder category (rechnung-extern, privat,
* sonstiges); note ist optional (hoechstens 500 Zeichen). Liefert die gespeicherte Buchung. Fehler (AssignError):
* 400 bei falscher Eingabe oder falscher Richtung, 404 bei unbekannter Buchung, Rechnung oder Auszahlung, 409 wenn
* die Buchung schon zugeordnet ist, die Rechnung nicht (mehr) offen ist oder der Betrag nicht passt. Stripe-Fehler
* fliegen unveraendert weiter.
* die Buchung schon zugeordnet ist, die Rechnung nicht (mehr) offen ist, der Betrag nicht passt oder die Buchung schon
* zu einer anderen bezahlten Rechnung gehoert; 502, wenn die Rechnung in Stripe bezahlt wurde, die Buchung aber nicht
* gespeichert werden konnte (erneut zuordnen schliesst ab). Stripe-Fehler fliegen unveraendert weiter. by = Mail des
* Nutzers fuer die Protokollzeile bei einer Rechnung.
*/
export async function assignTransaction(txId, input = {}) {
export async function assignTransaction(txId, input = {}, { by = '' } = {}) {
const request = parseInput(input)
const id = documentId(txId)
return withLocks(lockKeys(id, request), async () => {
@@ -186,7 +192,21 @@ export async function assignTransaction(txId, input = {}) {
else if (request.kind === 'payout') fields = await resolvePayout(tx, request.value)
else if (request.kind === 'fixedCost') fields = await resolveFixedCost(tx, request.value)
else fields = resolveCategory(tx, request.value)
return updateTransaction(id, request.note === undefined ? fields : { ...fields, note: request.note })
let saved
try {
saved = await updateTransaction(id, request.note === undefined ? fields : { ...fields, note: request.note })
} catch (cause) {
// Bei einer Rechnung ist sie in Stripe schon bezahlt (nicht umkehrbar): die Wiederholung schliesst die Zuordnung ab
if (request.kind !== 'invoice') throw cause
const err = new AssignError(502, 'In Stripe als bezahlt markiert, aber die Buchung wurde nicht gespeichert. Bitte erneut zuordnen.')
err.cause = cause
throw err
}
// Nicht umkehrbar (Stripe hat die Rechnung bezahlt gebucht): wer, welche Buchung, welche Rechnung; ohne Betraege
if (request.kind === 'invoice') {
console.log(`[finanzen] Zuordnung ${id} -> Rechnung ${fields.matchedInvoiceNumber || fields.matchedInvoiceId} durch ${by || 'unbekannt'}`)
}
return saved
})
}
+90 -3
View File
@@ -324,14 +324,21 @@ test('bezahlte Rechnung mit dem Vermerk einer anderen Buchung -> 409 nicht mehr
assert.equal(stored('b2').category, '')
})
test('Appwrite scheitert nach pay: 500, die Wiederholung schliesst ab ohne zweites pay (Rechnung ohne paid_out_of_band wie bei Stripe)', async () => {
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, 500)
assert.equal(failed.body.error, 'Interner Fehler.')
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
@@ -346,6 +353,86 @@ test('Appwrite scheitert nach pay: 500, die Wiederholung schliesst ab ohne zweit
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 () => {