diff --git a/server/services/finance/assign.js b/server/services/finance/assign.js index 5544fce..46860ed 100644 --- a/server/services/finance/assign.js +++ b/server/services/finance/assign.js @@ -12,7 +12,10 @@ import { invalidateFinanceCache, listInvoices, listPayouts } from './stripeFinan * bezahlt markiert, "paid_out_of_band", und das ist nicht umkehrbar) oder zu einer Stripe-Auszahlung, eine * Belastung zu einer Fixkosten-Position, oder eine Buchung nur mit Kategorie (Rechnung ausserhalb von Stripe, * privat, sonstiges). Zuordnungen laufen je Buchung nacheinander (keyedLock), damit zwei Klicks nie zweimal bei - * Stripe bezahlen. Die Meldungen sind fuer die Oberflaeche bestimmt (AssignError mit status). + * Stripe bezahlen. Rechnung und Auszahlung nur zu gebuchten Eintraegen (nicht zu Vormerkungen). Bei einer Rechnung + * steht erst der Vermerk (Buchungs-ID in den Metadaten) und dann das Bezahlen, damit eine Wiederholung nach einem + * Fehler an Appwrite die Zuordnung ohne zweites Bezahlen abschliesst. Die Meldungen sind fuer die Oberflaeche + * bestimmt (AssignError mit status). */ /** Fehler mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route weiter). */ @@ -34,6 +37,8 @@ const STRIPE_INVOICE_ID = /^in_\w+$/ const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ const UNASSIGNED = { category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '' } +const PENDING_MESSAGE = 'Die Buchung ist noch vorgemerkt. Bitte zuordnen, wenn sie gebucht ist.' + export const STRIPE_SUGGESTIONS_ERROR = 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.' const isIncoming = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents > 0 @@ -97,28 +102,34 @@ async function stripeOrNotFound(call, message) { } } +/** Rechnungen und Auszahlungen nur zu gebuchten Eintraegen: eine Vormerkung kann bis zur Buchung wieder verschwinden. */ +function requireBooked(tx) { + if (tx.status !== 'BOOK') throw new AssignError(409, PENDING_MESSAGE) +} + async function resolveInvoice(tx, invoiceId) { if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Rechnung zuordnen.') + requireBooked(tx) if (!STRIPE_INVOICE_ID.test(invoiceId)) throw new AssignError(404, 'Rechnung nicht gefunden.') const stripe = getStripe() try { const invoice = await stripeOrNotFound(() => stripe.invoices.retrieve(invoiceId), 'Rechnung nicht gefunden.') - // Eine fruehere Zuordnung kann nach dem Bezahlen an Appwrite gescheitert sein: dann ist die Rechnung schon als - // bezahlt mit genau dieser Buchung gekennzeichnet und es bleibt nur, die Buchung zu speichern - const resumed = - invoice.status === 'paid' && invoice.paid_out_of_band === true && invoice.metadata?.bankTransactionId === tx.$id + // 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 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.`) } - await stripe.invoices.pay(invoice.id, { paid_out_of_band: true }) - // Die Rechnung ist jetzt bezahlt (nicht umkehrbar): scheitert nur der Vermerk, bleibt die Zuordnung gueltig - try { + // 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 || '' } }) - } catch (err) { - console.error('[finanzen] Vermerk an der Stripe-Rechnung fehlgeschlagen:', err?.name || 'Error', err?.statusCode ?? '', err?.code ?? '') } + await stripe.invoices.pay(invoice.id, { paid_out_of_band: true }) } return { category: 'rechnung', matchedInvoiceId: invoice.id, matchedInvoiceNumber: invoice.number || '' } } finally { @@ -128,6 +139,7 @@ async function resolveInvoice(tx, invoiceId) { async function resolvePayout(tx, payoutId) { if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Auszahlung zuordnen.') + requireBooked(tx) // Frisch von Stripe (nicht aus dem 60-s-Zwischenspeicher): die Zuordnung darf nicht auf einem alten Stand beruhen const payout = (await listPayouts({ force: true })).find((item) => item.id === payoutId) if (!payout) throw new AssignError(404, 'Auszahlung nicht gefunden.') diff --git a/test/financeAssign.test.js b/test/financeAssign.test.js index f990691..e7e121e 100644 --- a/test/financeAssign.test.js +++ b/test/financeAssign.test.js @@ -147,9 +147,11 @@ test('Rechnung: Stripe pay (paid_out_of_band) + Metadaten, Buchung wird rechnung 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.paid_out_of_band, true) assert.equal(invoice.metadata.bankTransactionId, 'b1') const doc = stored('b1') @@ -260,17 +262,69 @@ test('Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => { assert.ok(!JSON.stringify(res.body).includes('10.0.0.1')) }) -test('Metadaten-Update scheitert nach pay: Buchung wird trotzdem zugeordnet (Rechnung ist schon bezahlt)', async () => { +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: 'Metadaten abgelehnt' }) + 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, 200) - assert.equal(stored('b1').category, 'rechnung') - assert.equal(st.invoices.get('in_ewv').status, 'paid') + 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('Appwrite scheitert nach pay: 500, die zweite Zuordnung setzt dort fort ohne zweites pay', async () => { +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: 500, die Wiederholung schliesst ab ohne zweites pay (Rechnung ohne paid_out_of_band wie bei Stripe)', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' }) @@ -280,12 +334,46 @@ test('Appwrite scheitert nach pay: 500, die zweite Zuordnung setzt dort fort ohn assert.ok(!JSON.stringify(failed.body).includes('Appwrite kaputt')) 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) +}) + +// ---------- 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 ---------- diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js index d1e440f..1b8e4c0 100644 --- a/test/financeStripeWrite.test.js +++ b/test/financeStripeWrite.test.js @@ -458,7 +458,9 @@ test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/v assert.equal(paid.status, 'paid') assert.equal(paid.amount_paid, 750) assert.equal(paid.amount_remaining, 0) - assert.equal(paid.paid_out_of_band, true) + // wie die echte API: die Rechnung traegt paid_out_of_band nicht; der Aufruf (calls) hat es gesendet + assert.equal(paid.paid_out_of_band, undefined) + assert.equal(st.calls.findLast((c) => c.path === `/v1/invoices/${draft.id}/pay`).body.paid_out_of_band, 'true') assert.ok(paid.status_transitions.paid_at) await refused(stripe.invoices.voidInvoice(draft.id), /open/) await refused(stripe.invoices.pay(draft.id), /open/) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index a6050b9..34a20d1 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -22,7 +22,8 @@ import http from 'node:http' * days_until_due), POST /v1/invoiceitems (mit invoice=... als Zeile mit quantity und unit_amount_decimal, * Summe = Menge mal Einzelpreis; nur an Entwuerfe), POST /v1/invoices/:id (Metadaten zusammenfuehren, leerer * Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit), - * .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band) und + * .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API + * liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). */ @@ -404,7 +405,6 @@ export async function startFakeStripe() { ...invoice, status: 'paid', paid: true, - paid_out_of_band: data.paid_out_of_band === 'true', amount_paid: invoice.total, amount_remaining: 0, status_transitions: { ...invoice.status_transitions, paid_at: at },