diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 99b2445..43d66a9 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -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) diff --git a/server/services/finance/assign.js b/server/services/finance/assign.js index 5d12dd3..e4325c6 100644 --- a/server/services/finance/assign.js +++ b/server/services/finance/assign.js @@ -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 }) } diff --git a/test/financeAssign.test.js b/test/financeAssign.test.js index e7e121e..756c008 100644 --- a/test/financeAssign.test.js +++ b/test/financeAssign.test.js @@ -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 () => {