Finanzen: Zuordnung zu Rechnungen erst vermerken, dann bezahlen; Vormerkungen nicht zuordnen

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 2bf67cb48d
commit 6e43d54fe8
4 files changed
+122 -20

No files matched your search

+95 -7
View File
@@ -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 ----------
+3 -1
View File
@@ -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/)
+2 -2
View File
@@ -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 },