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
})
}