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
@@ -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.')
|
||||
|
||||
@@ -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