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:
1 parent
2bf67cb48d
commit
6e43d54fe8
4 files changed
+122
-20
No files matched your search
@@ -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 ----------
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user