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:
1 parent
daa451094a
commit
83ee9d5a25
3 files changed
+118
-11
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user