Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f14a6b7b57
commit
2bf67cb48d
3 files changed
+767
-3
No files matched your search
@@ -4,9 +4,10 @@ import { withLocks } from '../../services/keyedLock.js'
|
||||
import { EnableBankingError } from '../../services/finance/enableBanking.js'
|
||||
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
|
||||
import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js'
|
||||
import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js'
|
||||
|
||||
/**
|
||||
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung und Buchungen. Die Handler sind klein und rufen
|
||||
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung. Die Handler sind klein und rufen
|
||||
* die Dienste in server/services/finance auf; weitere Finanz-Routen kommen in dieselbe Datei.
|
||||
*/
|
||||
const router = Router()
|
||||
@@ -113,12 +114,52 @@ router.post('/bank/rueckkehr', async (req, res) => {
|
||||
|
||||
router.get('/buchungen', async (_req, res) => {
|
||||
try {
|
||||
const buchungen = (await listTransactions()).map(publicTransaction)
|
||||
return res.json({ buchungen })
|
||||
// Vorschlaege kommen von Stripe: ein Ausfall dort (stripeFehler) macht die Liste nie kaputt
|
||||
return res.json(await withSuggestions((await listTransactions()).map(publicTransaction)))
|
||||
} catch (err) {
|
||||
logFailure('Buchungen laden', err)
|
||||
return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
|
||||
const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
||||
|
||||
// Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..."
|
||||
const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe')
|
||||
|
||||
/**
|
||||
* Antwort auf einen Fehler beim Zuordnen: AssignError (eigene, anzeigbare Meldung) mit seinem Status; Stripe lehnt
|
||||
* ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500. Meldungen von Stripe, Appwrite
|
||||
* und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite.
|
||||
*/
|
||||
function sendAssignError(res, what, err) {
|
||||
if (err instanceof AssignError) return res.status(err.status).json({ error: err.message })
|
||||
logFailure(what, err)
|
||||
if (isStripeError(err)) {
|
||||
const status = err.statusCode
|
||||
if (status >= 400 && status < 500 && status !== 429) return res.status(409).json({ error: STRIPE_REJECTED_MESSAGE })
|
||||
return res.status(502).json({ error: STRIPE_UNAVAILABLE_MESSAGE })
|
||||
}
|
||||
return res.status(500).json({ error: 'Interner Fehler.' })
|
||||
}
|
||||
|
||||
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 })
|
||||
return res.json({ buchung: publicTransaction(buchung) })
|
||||
} catch (err) {
|
||||
return sendAssignError(res, 'Buchung zuordnen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/buchungen/:id/zuordnung', async (req, res) => {
|
||||
try {
|
||||
return res.json({ buchung: publicTransaction(await unassignTransaction(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendAssignError(res, 'Zuordnung lösen', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,222 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { getStripe } from '../stripeBilling.js'
|
||||
import { getTransaction, listTransactions, updateTransaction } from './bankStore.js'
|
||||
import { euroDe } from './format.js'
|
||||
import { suggestMatches } from './matching.js'
|
||||
import { invalidateFinanceCache, listInvoices, listPayouts } from './stripeFinance.js'
|
||||
|
||||
/**
|
||||
* Bankbuchungen zuordnen: eine Gutschrift zu einer offenen Stripe-Rechnung (die Rechnung wird in Stripe als
|
||||
* 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).
|
||||
*/
|
||||
|
||||
/** Fehler mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route weiter). */
|
||||
export class AssignError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message)
|
||||
this.name = 'AssignError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
// Kategorien, die ohne Rechnung/Auszahlung/Fixkosten gesetzt werden duerfen; rechnung, stripe-auszahlung und
|
||||
// fixkosten entstehen nur ueber ihr Ziel
|
||||
const PLAIN_CATEGORIES = new Set(['rechnung-extern', 'privat', 'sonstiges'])
|
||||
const MAX_NOTE = 500
|
||||
const MAX_ID = 64
|
||||
const STRIPE_INVOICE_ID = /^in_\w+$/
|
||||
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen); alles andere kann es nicht geben
|
||||
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
const UNASSIGNED = { category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '' }
|
||||
|
||||
export const STRIPE_SUGGESTIONS_ERROR = 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.'
|
||||
|
||||
const isIncoming = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents > 0
|
||||
const isOutgoing = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents < 0
|
||||
const present = (value) => value !== undefined && value !== null && value !== ''
|
||||
|
||||
/** Eingabe pruefen (ohne I/O): genau ein Ziel, bekannte Kategorie, Notiz. Liefert { kind, value, note }. */
|
||||
function parseInput(input) {
|
||||
const body = input && typeof input === 'object' ? input : {}
|
||||
const targets = [
|
||||
['invoice', body.invoiceId],
|
||||
['payout', body.payoutId],
|
||||
['fixedCost', body.fixedCostId],
|
||||
['category', body.category],
|
||||
].filter(([, value]) => present(value))
|
||||
if (targets.length !== 1) {
|
||||
throw new AssignError(400, 'Bitte genau ein Ziel angeben: Rechnung, Auszahlung, Fixkosten oder Kategorie.')
|
||||
}
|
||||
const [kind, value] = targets[0]
|
||||
if (typeof value !== 'string' || value.length > MAX_ID) throw new AssignError(400, 'Das Ziel der Zuordnung ist ungültig.')
|
||||
if (kind === 'category' && !PLAIN_CATEGORIES.has(value)) {
|
||||
throw new AssignError(400, 'Unbekannte Kategorie (erlaubt: Rechnung außerhalb von Stripe, privat, sonstiges).')
|
||||
}
|
||||
|
||||
let note
|
||||
if (present(body.note) || body.note === '') {
|
||||
if (typeof body.note !== 'string') throw new AssignError(400, 'Die Notiz muss ein Text sein.')
|
||||
note = body.note.trim()
|
||||
if (note.length > MAX_NOTE) throw new AssignError(400, `Die Notiz darf höchstens ${MAX_NOTE} Zeichen lang sein.`)
|
||||
}
|
||||
return { kind, value, note }
|
||||
}
|
||||
|
||||
function documentId(id) {
|
||||
if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) throw new AssignError(404, 'Buchung nicht gefunden.')
|
||||
return id
|
||||
}
|
||||
|
||||
async function loadTransaction(id) {
|
||||
const tx = await getTransaction(id)
|
||||
if (!tx) throw new AssignError(404, 'Buchung nicht gefunden.')
|
||||
return tx
|
||||
}
|
||||
|
||||
// Gesperrt wird die Buchung; bei einer Rechnung oder Auszahlung auch diese, damit dieselbe Rechnung von zwei
|
||||
// Buchungen gleichzeitig nur einmal bezahlt und dieselbe Auszahlung nur einmal vergeben wird
|
||||
function lockKeys(txId, { kind, value }) {
|
||||
const keys = [`finanzen-buchung:${txId}`]
|
||||
if (kind === 'invoice') keys.push(`finanzen-rechnung:${value}`)
|
||||
if (kind === 'payout') keys.push(`finanzen-auszahlung:${value}`)
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Stripe-Aufruf; "gibt es nicht" (404, resource_missing) wird zu einem AssignError, alles andere fliegt weiter. */
|
||||
async function stripeOrNotFound(call, message) {
|
||||
try {
|
||||
return await call()
|
||||
} catch (err) {
|
||||
if (err?.statusCode === 404 || err?.code === 'resource_missing') throw new AssignError(404, message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInvoice(tx, invoiceId) {
|
||||
if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Rechnung zuordnen.')
|
||||
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
|
||||
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 {
|
||||
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 ?? '')
|
||||
}
|
||||
}
|
||||
return { category: 'rechnung', matchedInvoiceId: invoice.id, matchedInvoiceNumber: invoice.number || '' }
|
||||
} finally {
|
||||
invalidateFinanceCache()
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePayout(tx, payoutId) {
|
||||
if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Auszahlung zuordnen.')
|
||||
// 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.')
|
||||
if (payout.status === 'failed' || payout.status === 'canceled') {
|
||||
throw new AssignError(409, 'Diese Auszahlung ist fehlgeschlagen oder storniert.')
|
||||
}
|
||||
if (payout.amountCents !== tx.amountCents) {
|
||||
throw new AssignError(409, `Betrag weicht von der Auszahlung ab (${euroDe(payout.amountCents)}).`)
|
||||
}
|
||||
const taken = (await listTransactions()).some((other) => other.$id !== tx.$id && other.matchedPayoutId === payoutId)
|
||||
if (taken) throw new AssignError(409, 'Diese Auszahlung ist schon einer Buchung zugeordnet.')
|
||||
return { category: 'stripe-auszahlung', matchedPayoutId: payout.id }
|
||||
}
|
||||
|
||||
async function resolveFixedCost(tx, fixedCostId) {
|
||||
if (!isOutgoing(tx)) throw new AssignError(400, 'Fixkosten lassen sich nur Ausgängen zuordnen.')
|
||||
const missing = new AssignError(400, 'Diese Fixkosten-Position gibt es nicht.')
|
||||
if (!DOCUMENT_ID.test(fixedCostId)) throw missing
|
||||
try {
|
||||
await getDocument(config.collections.financeFixedCosts, fixedCostId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404 || err?.status === 400) throw missing
|
||||
throw err
|
||||
}
|
||||
return { category: 'fixkosten', fixedCostId }
|
||||
}
|
||||
|
||||
function resolveCategory(tx, category) {
|
||||
if (category === 'rechnung-extern' && !isIncoming(tx)) {
|
||||
throw new AssignError(400, 'Eine Rechnung außerhalb von Stripe passt nur zu einem Eingang.')
|
||||
}
|
||||
return { 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.
|
||||
*/
|
||||
export async function assignTransaction(txId, input = {}) {
|
||||
const request = parseInput(input)
|
||||
const id = documentId(txId)
|
||||
return withLocks(lockKeys(id, request), async () => {
|
||||
const tx = await loadTransaction(id)
|
||||
// Vor jedem Stripe-Aufruf: eine zugeordnete Buchung wird nie ein zweites Mal bearbeitet
|
||||
if (tx.category) throw new AssignError(409, 'Bereits zugeordnet.')
|
||||
let fields
|
||||
if (request.kind === 'invoice') fields = await resolveInvoice(tx, request.value)
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuordnung loesen: Kategorie und Verweise werden leer (die Notiz bleibt). Eine Rechnung laesst sich nicht
|
||||
* loesen (409): Stripe hat sie als bezahlt gebucht. Eine nicht zugeordnete Buchung bleibt, wie sie ist.
|
||||
*/
|
||||
export async function unassignTransaction(txId) {
|
||||
const id = documentId(txId)
|
||||
return withLocks([`finanzen-buchung:${id}`], async () => {
|
||||
const tx = await loadTransaction(id)
|
||||
if (tx.category === 'rechnung') {
|
||||
throw new AssignError(409, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.')
|
||||
}
|
||||
if (!tx.category) return tx
|
||||
return updateTransaction(id, UNASSIGNED)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschlaege an die Buchungen haengen: { buchungen: [{ ...buchung, vorschlaege }], stripeFehler? }. Nur
|
||||
* Eingaenge ohne Kategorie bekommen Vorschlaege; gibt es keinen, wird Stripe gar nicht gefragt. Scheitert Stripe,
|
||||
* bleiben alle Vorschlaege leer und stripeFehler nennt den Grund (die Liste selbst scheitert deshalb nie).
|
||||
*/
|
||||
export async function withSuggestions(transactions) {
|
||||
const open = transactions.some((tx) => tx.amountCents > 0 && !tx.category)
|
||||
const plain = () => transactions.map((tx) => ({ ...tx, vorschlaege: [] }))
|
||||
if (!open) return { buchungen: plain() }
|
||||
let stripeData
|
||||
try {
|
||||
const [invoices, payouts] = await Promise.all([listInvoices(), listPayouts()])
|
||||
stripeData = { invoices, payouts }
|
||||
} catch (err) {
|
||||
console.error('[finanzen] Zuordnungsvorschläge laden fehlgeschlagen:', err?.name || 'Error', err?.statusCode ?? '', err?.code ?? '')
|
||||
return { buchungen: plain(), stripeFehler: STRIPE_SUGGESTIONS_ERROR }
|
||||
}
|
||||
return { buchungen: transactions.map((tx) => ({ ...tx, vorschlaege: suggestMatches(tx, stripeData) })) }
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import Stripe from 'stripe'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeRoutesBank.test.js)
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { bankTransactions: schemaKeys('bankTransactions'), financeFixedCosts: schemaKeys('financeFixedCosts') },
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
|
||||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
||||
const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
|
||||
const { getStripe } = await import('../server/services/stripeBilling.js')
|
||||
const { euroDe } = await import('../server/services/finance/format.js')
|
||||
|
||||
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
|
||||
after(async () => {
|
||||
mock.restoreAll()
|
||||
await Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||||
const base = '/api/admin/finanzen'
|
||||
const TX = () => config.collections.bankTransactions
|
||||
const stored = (id) => aw.docs(TX()).get(id)
|
||||
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
|
||||
const payCalls = () => callsTo('POST', /\/pay$/)
|
||||
|
||||
const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
|
||||
const BOOKING = '2026-09-29'
|
||||
|
||||
const tx = (id, over = {}) => ({
|
||||
$id: id,
|
||||
accountUid: 'konto-1',
|
||||
status: 'BOOK',
|
||||
bookingDate: BOOKING,
|
||||
valueDate: BOOKING,
|
||||
amountCents: 16879,
|
||||
currency: 'EUR',
|
||||
counterpartyName: 'EWV SOFTWARE GMBH',
|
||||
counterpartyIban: '',
|
||||
remittance: 'Rechnung WK-0005 vom 28.07.2026',
|
||||
mcc: '',
|
||||
entryReference: id,
|
||||
category: '',
|
||||
matchedInvoiceId: '',
|
||||
matchedInvoiceNumber: '',
|
||||
matchedPayoutId: '',
|
||||
fixedCostId: '',
|
||||
note: '',
|
||||
syncedAt: '2026-10-01T09:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
const rawInvoice = (over = {}) => ({
|
||||
id: 'in_ewv',
|
||||
object: 'invoice',
|
||||
number: 'WK-0005',
|
||||
status: 'open',
|
||||
customer: 'cus_ewv',
|
||||
customer_name: 'EWV Software GmbH',
|
||||
customer_email: 'ewv@example.invalid',
|
||||
created: unix('2026-08-01'),
|
||||
due_date: unix('2026-10-20'),
|
||||
total: 16879,
|
||||
amount_due: 16879,
|
||||
amount_paid: 0,
|
||||
amount_remaining: 16879,
|
||||
status_transitions: { paid_at: null },
|
||||
metadata: {},
|
||||
lines: { object: 'list', data: [], has_more: false },
|
||||
...over,
|
||||
})
|
||||
|
||||
const rawPayout = (over = {}) => ({
|
||||
id: 'po_1',
|
||||
object: 'payout',
|
||||
amount: 45000,
|
||||
arrival_date: unix('2026-09-28'),
|
||||
status: 'paid',
|
||||
currency: 'eur',
|
||||
...over,
|
||||
})
|
||||
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
|
||||
// aw.docs liefert eine Kopie: Dokumente loescht der Test ueber den Server wie die Anwendung
|
||||
async function reset() {
|
||||
mock.restoreAll()
|
||||
invalidateFinanceCache()
|
||||
st.calls.length = 0
|
||||
st.invoices.clear()
|
||||
st.customers.clear()
|
||||
st.payouts.clear()
|
||||
for (const collection of [TX(), config.collections.financeFixedCosts]) {
|
||||
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
||||
}
|
||||
}
|
||||
beforeEach(reset)
|
||||
|
||||
const seedTx = (id, over) => aw.seed(TX(), tx(id, over))
|
||||
const zuordnen = (id, body) => post(`${base}/buchungen/${id}/zuordnen`, body, admin)
|
||||
const loesen = async (id) => {
|
||||
const res = await fetch(app.url + `${base}/buchungen/${id}/zuordnung`, { method: 'DELETE', headers: admin.headers })
|
||||
const text = await res.text()
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null }
|
||||
}
|
||||
|
||||
// ---------- Zugriff ----------
|
||||
|
||||
test('zuordnen und loesen: ohne JWT 401', async () => {
|
||||
seedTx('b1')
|
||||
assert.equal((await post(`${base}/buchungen/b1/zuordnen`, { category: 'privat' })).status, 401)
|
||||
const res = await fetch(app.url + `${base}/buchungen/b1/zuordnung`, { method: 'DELETE' })
|
||||
assert.equal(res.status, 401)
|
||||
assert.equal(stored('b1').category, '')
|
||||
})
|
||||
|
||||
// ---------- Rechnung ----------
|
||||
|
||||
test('Rechnung: Stripe pay (paid_out_of_band) + Metadaten, Buchung wird rechnung', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
|
||||
const res = await zuordnen('b1', { invoiceId: 'in_ewv', note: ' Überweisung EWV ' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body), ['buchung'])
|
||||
assert.equal(res.body.buchung.$id, 'b1')
|
||||
assert.equal(res.body.buchung.category, 'rechnung')
|
||||
assert.equal(res.body.buchung.matchedInvoiceId, 'in_ewv')
|
||||
assert.equal(res.body.buchung.matchedInvoiceNumber, 'WK-0005')
|
||||
assert.equal(res.body.buchung.note, 'Überweisung EWV')
|
||||
assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id'])
|
||||
|
||||
assert.equal(payCalls().length, 1)
|
||||
assert.equal(payCalls()[0].path, '/v1/invoices/in_ewv/pay')
|
||||
assert.equal(payCalls()[0].body.paid_out_of_band, 'true')
|
||||
const updates = callsTo('POST', '/v1/invoices/in_ewv')
|
||||
assert.equal(updates.length, 1)
|
||||
assert.equal(updates[0].body['metadata[bankTransactionId]'], 'b1')
|
||||
assert.equal(updates[0].body['metadata[bankBookingDate]'], BOOKING)
|
||||
|
||||
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')
|
||||
assert.equal(doc.category, 'rechnung')
|
||||
assert.equal(doc.matchedInvoiceId, 'in_ewv')
|
||||
assert.equal(doc.matchedInvoiceNumber, 'WK-0005')
|
||||
})
|
||||
|
||||
test('Rechnung: Zwischenspeicher wird geleert (die Liste zeigt die Rechnung nicht mehr als offen)', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
const { listInvoices } = await import('../server/services/finance/stripeFinance.js')
|
||||
assert.equal((await listInvoices())[0].status, 'offen')
|
||||
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
|
||||
const [invoice] = await listInvoices()
|
||||
assert.equal(invoice.status, 'bezahlt')
|
||||
assert.equal(invoice.outOfBand, true)
|
||||
assert.equal(invoice.paidDate, BOOKING)
|
||||
})
|
||||
|
||||
test('Rechnung: zwei parallele Zuordnungen derselben Buchung -> eine 200, eine 409, genau ein pay', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
const results = await Promise.allSettled([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b1', { invoiceId: 'in_ewv' })])
|
||||
const statuses = results.map((r) => r.value.status).sort()
|
||||
assert.deepEqual(statuses, [200, 409])
|
||||
const loser = results.map((r) => r.value).find((r) => r.status === 409)
|
||||
assert.equal(loser.body.error, 'Bereits zugeordnet.')
|
||||
assert.equal(payCalls().length, 1)
|
||||
})
|
||||
|
||||
test('Rechnung: dieselbe Rechnung fuer zwei Buchungen gleichzeitig -> eine 200, eine 409, genau ein pay', async () => {
|
||||
seedTx('b1')
|
||||
seedTx('b2')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
const results = await Promise.all([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b2', { invoiceId: 'in_ewv' })])
|
||||
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
|
||||
assert.equal(payCalls().length, 1)
|
||||
const assigned = ['b1', 'b2'].filter((id) => stored(id).category === 'rechnung')
|
||||
assert.equal(assigned.length, 1)
|
||||
})
|
||||
|
||||
test('Rechnung: Betrag weicht ab -> 409 mit Betrag, kein pay, Buchung unveraendert', async () => {
|
||||
seedTx('b1', { amountCents: 10000 })
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, `Betrag weicht vom offenen Betrag ab (${euroDe(16879)}). Teilzahlungen bitte direkt in Stripe erfassen.`)
|
||||
assert.equal(payCalls().length, 0)
|
||||
assert.equal(stored('b1').category, '')
|
||||
assert.equal(st.invoices.get('in_ewv').status, 'open')
|
||||
})
|
||||
|
||||
test('Rechnung: nicht offen (bezahlt) -> 409, kein pay', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879 }))
|
||||
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(payCalls().length, 0)
|
||||
assert.equal(stored('b1').category, '')
|
||||
})
|
||||
|
||||
test('Rechnung: unbekannte oder ungueltige Rechnungs-ID -> 404', async () => {
|
||||
seedTx('b1')
|
||||
assert.equal((await zuordnen('b1', { invoiceId: 'in_gibtsnicht' })).status, 404)
|
||||
assert.equal((await zuordnen('b1', { invoiceId: '../x' })).status, 404)
|
||||
assert.equal(payCalls().length, 0)
|
||||
})
|
||||
|
||||
test('Rechnung: nur Eingaenge, eine Ausgangsbuchung -> 400', async () => {
|
||||
seedTx('b1', { amountCents: -16879 })
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
|
||||
assert.equal(res.status, 400)
|
||||
assert.equal(payCalls().length, 0)
|
||||
})
|
||||
|
||||
test('bereits zugeordnet -> 409 "Bereits zugeordnet." ohne jeden Stripe-Aufruf', async () => {
|
||||
seedTx('b1', { category: 'privat' })
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Bereits zugeordnet.')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('Stripe lehnt pay ab (4xx) -> 409 ohne Stripe-Text, Buchung bleibt offen', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'Geheimer Stripe-Text sk_live_abc' })
|
||||
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
|
||||
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(stored('b1').category, '')
|
||||
})
|
||||
|
||||
test('Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
mock.method(getStripe().invoices, 'retrieve', 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.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 () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
st.failNext({ method: 'POST', path: '/v1/invoices/in_ewv', status: 400, message: 'Metadaten abgelehnt' })
|
||||
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')
|
||||
})
|
||||
|
||||
test('Appwrite scheitert nach pay: 500, die zweite Zuordnung setzt dort fort ohne zweites pay', async () => {
|
||||
seedTx('b1')
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
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.ok(!JSON.stringify(failed.body).includes('Appwrite kaputt'))
|
||||
assert.equal(stored('b1').category, '')
|
||||
assert.equal(st.invoices.get('in_ewv').status, 'paid')
|
||||
|
||||
const retry = await zuordnen('b1', { invoiceId: 'in_ewv' })
|
||||
assert.equal(retry.status, 200)
|
||||
assert.equal(stored('b1').category, 'rechnung')
|
||||
assert.equal(stored('b1').matchedInvoiceNumber, 'WK-0005')
|
||||
assert.equal(payCalls().length, 1)
|
||||
})
|
||||
|
||||
// ---------- Auszahlung ----------
|
||||
|
||||
test('Auszahlung: gleicher Betrag -> stripe-auszahlung mit matchedPayoutId', async () => {
|
||||
seedTx('b1', { amountCents: 45000, counterpartyName: 'STRIPE PAYMENTS EUROPE', remittance: '' })
|
||||
st.payouts.set('po_1', rawPayout())
|
||||
const res = await zuordnen('b1', { payoutId: 'po_1' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.buchung.category, 'stripe-auszahlung')
|
||||
assert.equal(res.body.buchung.matchedPayoutId, 'po_1')
|
||||
assert.equal(stored('b1').matchedPayoutId, 'po_1')
|
||||
assert.equal(payCalls().length, 0)
|
||||
})
|
||||
|
||||
test('Auszahlung: Betrag weicht ab -> 409; unbekannt -> 404; fehlgeschlagen/storniert -> 409', async () => {
|
||||
seedTx('b1', { amountCents: 44000 })
|
||||
st.payouts.set('po_1', rawPayout())
|
||||
st.payouts.set('po_f', rawPayout({ id: 'po_f', status: 'failed', amount: 44000 }))
|
||||
st.payouts.set('po_c', rawPayout({ id: 'po_c', status: 'canceled', amount: 44000 }))
|
||||
|
||||
const diff = await zuordnen('b1', { payoutId: 'po_1' })
|
||||
assert.equal(diff.status, 409)
|
||||
assert.match(diff.body.error, /Betrag weicht von der Auszahlung ab/)
|
||||
assert.equal((await zuordnen('b1', { payoutId: 'po_gibtsnicht' })).status, 404)
|
||||
for (const id of ['po_f', 'po_c']) {
|
||||
const res = await zuordnen('b1', { payoutId: id })
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Diese Auszahlung ist fehlgeschlagen oder storniert.')
|
||||
}
|
||||
assert.equal(stored('b1').category, '')
|
||||
})
|
||||
|
||||
test('Auszahlung: dieselbe Auszahlung nicht zweimal vergeben', async () => {
|
||||
seedTx('b1', { amountCents: 45000 })
|
||||
seedTx('b2', { amountCents: 45000 })
|
||||
st.payouts.set('po_1', rawPayout())
|
||||
assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 200)
|
||||
const second = await zuordnen('b2', { payoutId: 'po_1' })
|
||||
assert.equal(second.status, 409)
|
||||
assert.equal(second.body.error, 'Diese Auszahlung ist schon einer Buchung zugeordnet.')
|
||||
assert.equal(stored('b2').category, '')
|
||||
})
|
||||
|
||||
test('Auszahlung: Ausgangsbuchung -> 400', async () => {
|
||||
seedTx('b1', { amountCents: -45000 })
|
||||
st.payouts.set('po_1', rawPayout())
|
||||
assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 400)
|
||||
})
|
||||
|
||||
// ---------- Fixkosten ----------
|
||||
|
||||
test('Fixkosten: Ausgang mit vorhandener Position -> fixkosten mit fixedCostId', async () => {
|
||||
seedTx('b1', { amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' })
|
||||
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
|
||||
const res = await zuordnen('b1', { fixedCostId: 'fk1' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.buchung.category, 'fixkosten')
|
||||
assert.equal(res.body.buchung.fixedCostId, 'fk1')
|
||||
assert.equal(stored('b1').fixedCostId, 'fk1')
|
||||
})
|
||||
|
||||
test('Fixkosten: unbekannte Position -> 400, Eingangsbuchung -> 400', async () => {
|
||||
seedTx('b1', { amountCents: -449 })
|
||||
seedTx('b2', { amountCents: 449 })
|
||||
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
|
||||
const missing = await zuordnen('b1', { fixedCostId: 'fk-gibtsnicht' })
|
||||
assert.equal(missing.status, 400)
|
||||
assert.equal(missing.body.error, 'Diese Fixkosten-Position gibt es nicht.')
|
||||
assert.equal((await zuordnen('b2', { fixedCostId: 'fk1' })).status, 400)
|
||||
assert.equal(stored('b1').category, '')
|
||||
assert.equal(stored('b2').category, '')
|
||||
})
|
||||
|
||||
// ---------- Kategorien ----------
|
||||
|
||||
test('Kategorien: privat und sonstiges fuer Ein- und Ausgang, rechnung-extern nur fuer Eingang', async () => {
|
||||
seedTx('in1', { amountCents: 5000 })
|
||||
seedTx('out1', { amountCents: -5000 })
|
||||
seedTx('out2', { amountCents: -700 })
|
||||
seedTx('in2', { amountCents: 700 })
|
||||
|
||||
const extern = await zuordnen('in1', { category: 'rechnung-extern', note: 'Rechnung 17 außerhalb von Stripe' })
|
||||
assert.equal(extern.status, 200)
|
||||
assert.equal(extern.body.buchung.category, 'rechnung-extern')
|
||||
assert.equal(stored('in1').note, 'Rechnung 17 außerhalb von Stripe')
|
||||
|
||||
const externOut = await zuordnen('out1', { category: 'rechnung-extern' })
|
||||
assert.equal(externOut.status, 400)
|
||||
assert.equal(stored('out1').category, '')
|
||||
|
||||
assert.equal((await zuordnen('out1', { category: 'privat' })).status, 200)
|
||||
assert.equal(stored('out1').category, 'privat')
|
||||
assert.equal((await zuordnen('in2', { category: 'sonstiges' })).status, 200)
|
||||
assert.equal(stored('in2').category, 'sonstiges')
|
||||
assert.equal((await zuordnen('out2', { category: 'sonstiges' })).status, 200)
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('Eingaben: genau ein Ziel, bekannte Kategorie, Notiz hoechstens 500 Zeichen, bekannte Buchung', async () => {
|
||||
seedTx('b1')
|
||||
const cases = [
|
||||
[{}, 400],
|
||||
[{ invoiceId: 'in_ewv', payoutId: 'po_1' }, 400],
|
||||
[{ category: 'privat', fixedCostId: 'fk1' }, 400],
|
||||
// rechnung, stripe-auszahlung und fixkosten entstehen nur ueber ihr Ziel, nicht als nackte Kategorie
|
||||
[{ category: 'rechnung' }, 400],
|
||||
[{ category: 'stripe-auszahlung' }, 400],
|
||||
[{ category: 'fixkosten' }, 400],
|
||||
[{ category: 'irgendwas' }, 400],
|
||||
[{ category: 'privat', note: 'x'.repeat(501) }, 400],
|
||||
[{ category: 'privat', note: 42 }, 400],
|
||||
[{ invoiceId: 42 }, 400],
|
||||
]
|
||||
for (const [body, status] of cases) {
|
||||
const res = await zuordnen('b1', body)
|
||||
assert.equal(res.status, status, JSON.stringify(body))
|
||||
assert.ok(res.body.error)
|
||||
}
|
||||
assert.equal(stored('b1').category, '')
|
||||
assert.equal(st.calls.length, 0)
|
||||
|
||||
assert.equal((await zuordnen('b1', { category: 'privat', note: 'x'.repeat(500) })).status, 200)
|
||||
assert.equal((await zuordnen('gibtsnicht', { category: 'privat' })).status, 404)
|
||||
assert.equal((await zuordnen('../b1', { category: 'privat' })).status, 404)
|
||||
})
|
||||
|
||||
// ---------- Zuordnung loesen ----------
|
||||
|
||||
test('loesen: rechnung -> 409, Stripe und Buchung bleiben unveraendert', async () => {
|
||||
seedTx('b1', { category: 'rechnung', matchedInvoiceId: 'in_ewv', matchedInvoiceNumber: 'WK-0005' })
|
||||
const res = await loesen('b1')
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.')
|
||||
assert.equal(stored('b1').category, 'rechnung')
|
||||
assert.equal(stored('b1').matchedInvoiceId, 'in_ewv')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('loesen: privat, stripe-auszahlung und fixkosten -> alle Zuordnungsfelder leer, Notiz bleibt', async () => {
|
||||
seedTx('b1', { category: 'privat', note: 'Mittagessen' })
|
||||
seedTx('b2', { category: 'stripe-auszahlung', matchedPayoutId: 'po_1' })
|
||||
seedTx('b3', { amountCents: -449, category: 'fixkosten', fixedCostId: 'fk1' })
|
||||
|
||||
const res = await loesen('b1')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.buchung.category, '')
|
||||
assert.equal(res.body.buchung.note, 'Mittagessen')
|
||||
assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id'])
|
||||
|
||||
assert.equal((await loesen('b2')).status, 200)
|
||||
assert.equal((await loesen('b3')).status, 200)
|
||||
for (const id of ['b1', 'b2', 'b3']) {
|
||||
const doc = stored(id)
|
||||
for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId']) {
|
||||
assert.equal(doc[key], '', `${id}.${key}`)
|
||||
}
|
||||
}
|
||||
// danach laesst sich die Buchung neu zuordnen
|
||||
assert.equal((await zuordnen('b1', { category: 'sonstiges' })).status, 200)
|
||||
})
|
||||
|
||||
test('loesen: unbekannte Buchung -> 404; nicht zugeordnete Buchung bleibt unveraendert (200)', async () => {
|
||||
seedTx('b1')
|
||||
assert.equal((await loesen('gibtsnicht')).status, 404)
|
||||
const res = await loesen('b1')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.buchung.category, '')
|
||||
})
|
||||
|
||||
// ---------- GET /buchungen ----------
|
||||
|
||||
test('GET /buchungen: Eingang bekommt einen Vorschlag, Ausgang und zugeordnete nicht', async () => {
|
||||
seedTx('ewv', { bookingDate: '2026-09-29' })
|
||||
seedTx('aus', { bookingDate: '2026-09-28', amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' })
|
||||
seedTx('fertig', { bookingDate: '2026-09-27', category: 'privat' })
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
|
||||
const body = await get(`${base}/buchungen`, admin)
|
||||
assert.equal(body.stripeFehler, undefined)
|
||||
const byId = Object.fromEntries(body.buchungen.map((b) => [b.$id, b]))
|
||||
assert.equal(byId.ewv.vorschlaege.length, 1)
|
||||
assert.equal(byId.ewv.vorschlaege[0].kind, 'invoice')
|
||||
assert.equal(byId.ewv.vorschlaege[0].id, 'in_ewv')
|
||||
assert.equal(byId.ewv.vorschlaege[0].number, 'WK-0005')
|
||||
assert.equal(byId.ewv.vorschlaege[0].confidence, 'hoch')
|
||||
assert.deepEqual(byId.aus.vorschlaege, [])
|
||||
assert.deepEqual(byId.fertig.vorschlaege, [])
|
||||
})
|
||||
|
||||
test('GET /buchungen: Stripe-Aufrufe nur, wenn es einen offenen Eingang gibt', async () => {
|
||||
seedTx('aus', { amountCents: -449 })
|
||||
seedTx('fertig', { category: 'privat' })
|
||||
const body = await get(`${base}/buchungen`, admin)
|
||||
assert.equal(body.buchungen.length, 2)
|
||||
assert.equal(body.stripeFehler, undefined)
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
for (const path of ['/v1/invoices', '/v1/payouts']) {
|
||||
test(`GET /buchungen: Stripe-Fehler bei ${path} -> 200 mit stripeFehler und leeren Vorschlaegen`, async () => {
|
||||
seedTx('ewv')
|
||||
seedTx('aus', { amountCents: -449 })
|
||||
st.invoices.set('in_ewv', rawInvoice())
|
||||
st.failNext({ method: 'GET', path, status: 400, message: 'Geheimer Stripe-Text' })
|
||||
const res = await rawGet(`${base}/buchungen`, admin)
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.stripeFehler, 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.')
|
||||
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
|
||||
assert.equal(res.body.buchungen.length, 2)
|
||||
for (const buchung of res.body.buchungen) assert.deepEqual(buchung.vorschlaege, [])
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user