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 financeAssign.test.js) const aw = await startAppwriteMock({ schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions'), financeSettings: schemaKeys('financeSettings'), financeFixedCosts: schemaKeys('financeFixedCosts'), subscriptions: schemaKeys('subscriptions'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { seedFixtures, customers } = await import('./support/fixtures.js') const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') const { getStripe } = await import('../server/services/stripeBilling.js') const { addDays, berlinDate } = await import('../server/services/finance/dates.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { buildOverview } = await import('../server/services/finance/overview.js') const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) after(async () => { mock.restoreAll() await Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) const admin = { headers: { Authorization: 'Bearer jwt-admin' } } const staff = { headers: { Authorization: 'Bearer jwt-staff' } } const kunde = { headers: { Authorization: 'Bearer jwt-kunde' } } const base = '/api/admin/finanzen' const COLLECTIONS = () => [ config.collections.bankConnections, config.collections.bankTransactions, config.collections.financeSettings, config.collections.financeFixedCosts, config.collections.subscriptions, ] const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) const TODAY = berlinDate() const YEAR = TODAY.slice(0, 4) const at = (days) => addDays(TODAY, days) const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000) async function reset() { mock.restoreAll() invalidateFinanceCache() resetInvoiceCreateGuardForTests() st.calls.length = 0 st.invoices.clear() st.customers.clear() st.payouts.clear() st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } }) st.customers.set('cus_c2', { id: 'cus_c2', object: 'customer', name: 'Kunde Zwei', email: 'k2@example.invalid', metadata: { webklarCustomerId: 'c2' } }) for (const collection of COLLECTIONS()) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } // Kunde c2 hat einen Firmennamen: er geht dem Namen vor aw.seed('customers', { ...customers.c2, companyName: 'Zwei GmbH' }) } beforeEach(reset) // ---------- Testdaten ---------- const FULL_BANK_KEYS = [ 'status', 'ibanMasked', 'validUntil', 'lastSyncAt', 'lastSyncOk', 'lastError', 'balanceBookedCents', 'balanceAvailableCents', 'balanceDate', 'latestBookingDate', ] const seedConnection = (over = {}) => aw.seed(config.collections.bankConnections, { $id: 'finom', status: 'aktiv', sessionId: 'sess-geheim-4711', accountUid: 'konto-4711', iban: 'DE00000000000000009313', ibanMasked: 'DE00 •••• 9313', validUntil: '2099-03-29T00:00:00.000Z', lastSyncAt: new Date().toISOString(), lastSyncOk: true, lastError: '', balanceBookedCents: 10893, // der Kontostand der Übersicht ist der gebuchte, nicht der verfügbare balanceAvailableCents: 9999, balanceDate: TODAY, latestBookingDate: `${YEAR}-01-09`, ...over, }) const tx = (id, over = {}) => ({ $id: id, accountUid: 'konto-4711', status: 'BOOK', bookingDate: `${YEAR}-01-09`, valueDate: `${YEAR}-01-09`, amountCents: -449, currency: 'EUR', counterpartyName: 'PORKBUN.COM', counterpartyIban: '', remittance: '', mcc: '', entryReference: id, category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '', note: '', syncedAt: new Date().toISOString(), ...over, }) // Die neun Buchungen des echten Abrufs (Betraege wie in financeBankSync.test.js), alle im laufenden Jahr: Einnahmen // 16.879, Ausgaben 5.986 -> Gewinn 10.893, Ruecklage 30 % = 3.268 const seedNine = () => { const rows = [ tx('b1', { bookingDate: `${YEAR}-01-05`, amountCents: 16879, counterpartyName: 'KUNDE EINS GMBH', remittance: 'Rechnung WK-0005 vom 28.07.2026' }), tx('b2', { bookingDate: `${YEAR}-01-06`, amountCents: -4358, counterpartyName: 'OPENROUTER, INC', mcc: '5734' }), tx('b3', { bookingDate: `${YEAR}-01-07`, amountCents: -131, counterpartyName: 'PNL Fintech B.V.' }), tx('b4', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }), tx('b5', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }), tx('b6', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }), tx('b7', { amountCents: -449 }), tx('b8', { amountCents: -449 }), tx('b9', { amountCents: -449 }), ] for (const row of rows) aw.seed(config.collections.bankTransactions, row) } const seedSettings = (over = {}) => aw.seed(config.collections.financeSettings, { $id: 'main', minBufferCents: 2000, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', ...over, }) // Fixkosten der naechsten 30 Tage: 449 + 1.000 = 1.449; die inaktive Position zaehlt nicht const seedCosts = () => { const costs = config.collections.financeFixedCosts aw.seed(costs, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: at(10), counterpartyMatch: 'porkbun', active: true }) aw.seed(costs, { $id: 'fk2', name: 'Hetzner', amountCents: 1000, interval: 'quartal', nextDate: at(5), counterpartyMatch: 'hetzner', active: true }) aw.seed(costs, { $id: 'fk3', name: 'Alt', amountCents: 2500, interval: 'monat', nextDate: at(3), counterpartyMatch: '', active: false }) } const seedSub = (id, over = {}) => aw.seed(config.collections.subscriptions, { $id: id, customerId: 'c1', status: 'active', productKey: 'hosting', planKey: 'hosting_monat', priceAmount: 2000, currentPeriodEnd: new Date(`${at(20)}T12:00:00+02:00`).toISOString(), cancelAtPeriodEnd: false, cancelAt: null, ...over, }) const rawInvoice = (over = {}) => ({ id: 'in_open', object: 'invoice', number: 'WK-0005', status: 'open', customer: 'cus_c1', customer_name: 'Kunde Eins', customer_email: 'k@example.invalid', created: unix(at(-5)), due_date: unix(at(2)), total: 16879, amount_due: 16879, amount_paid: 0, amount_remaining: 16879, status_transitions: { paid_at: null }, metadata: { webklarCustomerId: 'c1' }, lines: { object: 'list', data: [], has_more: false }, ...over, }) const putInvoice = (over) => { const invoice = rawInvoice(over) st.invoices.set(invoice.id, invoice) return invoice } // Eine offene Projekt-, eine ueberfaellige und eine bezahlte Rechnung, dazu ein junger Entwurf function seedStripe() { putInvoice() putInvoice({ id: 'in_over', number: 'WK-0006', customer: 'cus_c2', customer_name: 'Kunde Zwei', created: unix(at(-30)), due_date: unix(at(-10)), total: 5000, amount_due: 5000, amount_remaining: 5000, metadata: { webklarCustomerId: 'c2' }, }) putInvoice({ id: 'in_paid', number: 'WK-0004', status: 'paid', created: unix(at(-20)), due_date: unix(at(-6)), total: 8000, amount_due: 0, amount_paid: 8000, amount_remaining: 0, status_transitions: { paid_at: unix(TODAY) }, }) putInvoice({ id: 'in_draft', number: null, status: 'draft', created: unix(TODAY), due_date: null, total: 3000, amount_due: 3000, amount_remaining: 3000 }) } function seedAll() { seedConnection() seedNine() seedSettings() seedCosts() seedSub('sub_a', { customerId: 'c1', status: 'active', priceAmount: 2000, productKey: 'hosting' }) seedSub('sub_b', { customerId: 'c2', status: 'past_due', priceAmount: 5000, productKey: 'wartung', planKey: 'wartung_monat' }) seedStripe() } const uebersicht = () => get(`${base}/uebersicht`, admin) const ART = (body) => body.aufgaben.map((t) => t.art) const request = async (method, path, { body, headers } = {}) => { const res = await fetch(app.url + path, { method, headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await res.text() return { status: res.status, body: text ? JSON.parse(text) : null } } const del = (path) => request('DELETE', path, admin) const apiError = (message, extra = {}) => new Stripe.errors.StripeAPIError({ message, type: 'api_error', statusCode: 500, ...extra }) /** Stripe liefert weder Rechnungen noch Auszahlungen (wie bei einem Ausfall). */ function stripeDown() { const error = apiError('Interner Stripe-Fehler sk_live_geheim') mock.method(getStripe().invoices, 'list', () => ({ autoPagingToArray: () => Promise.reject(error) })) mock.method(getStripe().payouts, 'list', () => Promise.reject(error)) } // ---------- Zugriff ---------- test('alle neuen Routen: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403', async () => { putInvoice({ id: 'in_draft', status: 'draft', number: null }) const calls = [ ['GET', `${base}/uebersicht`], ['GET', `${base}/rechnungen`], ['POST', `${base}/rechnungen`, { customerId: 'c1', items: [{ description: 'x', quantity: 1, unitAmountCents: 100 }] }], ['POST', `${base}/rechnungen/in_draft/senden`, {}], ['POST', `${base}/rechnungen/in_draft/stornieren`, {}], ['DELETE', `${base}/rechnungen/in_draft`], ] for (const [method, path, body] of calls) { assert.equal((await request(method, path, { body })).status, 401, `${method} ${path} ohne JWT`) assert.equal((await request(method, path, { body, ...staff })).status, 403, `${method} ${path} Mitarbeiter`) assert.equal((await request(method, path, { body, ...kunde })).status, 403, `${method} ${path} Kunde`) } assert.equal(st.invoices.size, 1) assert.equal(st.invoices.get('in_draft').status, 'draft') assert.equal(callsTo('POST', '/v1/invoices').length, 0) }) // ---------- GET /uebersicht ---------- test('GET /uebersicht: Kopf, Abos, Altersgruppen und Aufgaben aus den Testdaten', async () => { seedAll() const body = await uebersicht() assert.deepEqual(Object.keys(body), ['stand', 'kopf', 'aufgaben', 'vorschau', 'steuer', 'abos', 'rechnungen']) // Kopf: 10.893 - Ruecklage 3.268 - Fixkosten 1.449 - Puffer 2.000 = 4.176 assert.equal(body.kopf.kontostandCents, 10893) assert.equal(body.kopf.verfuegbarCents, 4176) assert.deepEqual(body.kopf.aufschluesselung, { kontostandCents: 10893, steuerRuecklageCents: 3268, fixkosten30Cents: 1449, mindestpufferCents: 2000 }) // sicher: 10.893 - 1.000 (in 5 Tagen) - 449 (in 10 Tagen); erwartet: die Rechnung in 2 Tagen hebt den Stand, der Tiefpunkt ist heute assert.deepEqual(body.kopf.tiefsterSicher, { amountCents: 9444, date: at(10) }) assert.deepEqual(body.kopf.tiefsterErwartet, { amountCents: 10893, date: TODAY }) assert.deepEqual(body.kopf.tiefsterSicher, body.vorschau.minSicher) assert.deepEqual(body.kopf.tiefsterErwartet, body.vorschau.minErwartet) // Vorschau: Fixkosten (ohne die inaktive Position), offene und ueberfaellige Rechnung, Abo-Verlaengerung assert.deepEqual( body.vorschau.posten.map((p) => [p.label, p.amountCents, p.sicherheit]), [ ['Rechnung WK-0005 · Kunde Eins', 16879, 'erwartet'], ['Hetzner', -1000, 'sicher'], ['Porkbun', -449, 'sicher'], ['Rechnung WK-0006 · Kunde Zwei (überfällig, angenommen)', 5000, 'erwartet'], ['Abo-Verlängerung', 2000, 'erwartet'], ] ) assert.equal(body.vorschau.fixkosten30Cents, 1449) assert.equal(typeof body.vorschau.annahme, 'string') // Steuer assert.equal(body.steuer.jahr, Number(YEAR)) assert.equal(body.steuer.ruecklageCents, 3268) assert.equal(body.steuer.gewinnCents, 10893) assert.equal(body.steuer.kleinunternehmer.jahrCents, 8000) // Abos: aktiv 2.000 + past_due 5.000 = 7.000; gefaehrdet 5.000; ein zahlender Kunde (nur active zaehlt) assert.equal(body.abos.monatlichCents, 7000) assert.equal(body.abos.gefaehrdetCents, 5000) assert.equal(body.abos.zahlendeKunden, 1) assert.deepEqual(body.abos.liste, [ { customerId: 'c2', kunde: 'Zwei GmbH', produkt: 'wartung', status: 'past_due', betragCents: 5000, naechsteAbbuchung: null }, { customerId: 'c1', kunde: 'Kunde Eins', produkt: 'hosting', status: 'active', betragCents: 2000, naechsteAbbuchung: at(20) }, ]) // Altersgruppen: die offene (in 2 Tagen faellig) ist nicht faellig, die ueberfaellige 10 Tage im Verzug assert.deepEqual(body.rechnungen.altersgruppen, [ { key: 'nicht-faellig', anzahl: 1, summeCents: 16879 }, { key: '1-30', anzahl: 1, summeCents: 5000 }, { key: '31-60', anzahl: 0, summeCents: 0 }, { key: 'ueber-60', anzahl: 0, summeCents: 0 }, ]) assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 1, summeCents: 3000 }) // Aufgaben: ueberfaellige Rechnung, fehlgeschlagene Abo-Zahlung (mit Kundenname) und die Zuordnung mit Vorschlag const arts = ART(body) assert.ok(arts.includes('ueberfaellig')) assert.ok(arts.includes('abo-zahlung')) const abo = body.aufgaben.find((t) => t.art === 'abo-zahlung') assert.ok(abo.text.startsWith('Zwei GmbH: '), abo.text) assert.deepEqual(abo.aktion, { typ: 'kunde-oeffnen', customerId: 'c2' }) const zuordnung = body.aufgaben.filter((t) => t.art === 'zuordnung') assert.equal(zuordnung.length, 1) assert.ok(zuordnung[0].text.endsWith('Vorschlag: Rechnung WK-0005'), zuordnung[0].text) assert.equal(zuordnung[0].aktion.transactionId, 'b1') assert.ok(!arts.includes('bank-zugang') && !arts.includes('bank-abruf')) }) test('GET /uebersicht: Statuszeile mit Bank (nur die oeffentlichen Felder) und Stripe', async () => { seedAll() const before = Date.now() const body = await uebersicht() assert.deepEqual(Object.keys(body.stand), ['bank', 'stripe']) assert.deepEqual(Object.keys(body.stand.bank), FULL_BANK_KEYS) assert.equal(body.stand.bank.status, 'aktiv') assert.equal(body.stand.bank.balanceBookedCents, 10893) assert.equal(body.stand.bank.balanceAvailableCents, 9999) assert.equal(body.stand.bank.ibanMasked, 'DE00 •••• 9313') const raw = JSON.stringify(body) for (const secret of ['sess-geheim-4711', 'konto-4711', 'DE00000000000000009313']) assert.ok(!raw.includes(secret), `${secret} darf nicht an die Seite`) assert.equal(body.stand.stripe.ok, true) assert.equal(body.stand.stripe.fehler, undefined) const fetchedAt = Date.parse(body.stand.stripe.abgerufenUm) assert.ok(fetchedAt >= before - 1000 && fetchedAt <= Date.now() + 1000) }) test('GET /uebersicht: Stripe wird je Art nur einmal gefragt (Vorschlaege teilen den Zwischenspeicher)', async () => { seedAll() await uebersicht() assert.equal(callsTo('GET', '/v1/invoices').length, 1) assert.equal(callsTo('GET', '/v1/payouts').length, 1) }) test('GET /uebersicht: Stripe-Auszahlungen unterwegs zaehlen zur sicheren Linie', async () => { seedConnection() st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 45000, arrival_date: unix(at(3)), status: 'in_transit', currency: 'eur' }) const body = await uebersicht() assert.deepEqual(body.vorschau.posten, [{ date: at(3), label: 'Stripe-Auszahlung', amountCents: 45000, sicherheit: 'sicher' }]) assert.equal(body.vorschau.endeSicherCents, 55893) }) test('GET /uebersicht: leere Daten -> 200, flache Vorschau, nur Bank-Aufgaben', async () => { seedConnection({ validUntil: `${YEAR}-12-31T00:00:00.000Z` }) const res = await rawGet(`${base}/uebersicht`, admin) assert.equal(res.status, 200, JSON.stringify(res.body)) const body = res.body assert.deepEqual(body.vorschau.posten, []) assert.deepEqual(body.vorschau.minSicher, { amountCents: 10893, date: TODAY }) assert.deepEqual(body.vorschau.minErwartet, { amountCents: 10893, date: TODAY }) assert.equal(body.vorschau.endeSicherCents, 10893) assert.equal(body.vorschau.endeErwartetCents, 10893) // keine Einstellungen gespeichert: Standardwerte (Puffer 0), keine Ruecklage ohne Gewinn assert.equal(body.kopf.kontostandCents, 10893) assert.equal(body.kopf.verfuegbarCents, 10893) assert.deepEqual(body.abos, { monatlichCents: 0, zahlendeKunden: 0, gefaehrdetCents: 0, liste: [] }) assert.deepEqual(body.rechnungen, { altersgruppen: [ { key: 'nicht-faellig', anzahl: 0, summeCents: 0 }, { key: '1-30', anzahl: 0, summeCents: 0 }, { key: '31-60', anzahl: 0, summeCents: 0 }, { key: 'ueber-60', anzahl: 0, summeCents: 0 }, ], entwuerfe: { anzahl: 0, summeCents: 0 }, }) for (const art of ART(body)) assert.ok(art.startsWith('bank-'), `unerwartete Aufgabe ${art}`) assert.equal(body.stand.stripe.ok, true) }) test('GET /uebersicht: ohne Bankzugang -> Aufgabe "Bankkonto verbinden", Kontostand unbekannt (null)', async () => { const res = await rawGet(`${base}/uebersicht`, admin) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.stand.bank, null) assert.equal(res.body.kopf.kontostandCents, null) assert.equal(res.body.kopf.verfuegbarCents, null) assert.equal(res.body.kopf.tiefsterSicher, null) assert.equal(res.body.kopf.tiefsterErwartet, null) assert.deepEqual(res.body.kopf.aufschluesselung, { kontostandCents: null, steuerRuecklageCents: 0, fixkosten30Cents: 0, mindestpufferCents: 0 }) assert.deepEqual(ART(res.body), ['bank-zugang']) }) test('GET /uebersicht: Stripe 500 -> 200, Bankteil, Kopf und Aufgaben aus Bank und Abos, Stripe-Block leer', async () => { seedAll() stripeDown() const res = await rawGet(`${base}/uebersicht`, admin) assert.equal(res.status, 200, JSON.stringify(res.body)) const body = res.body assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null }) assert.ok(!JSON.stringify(body).includes('sk_live_geheim')) assert.equal(body.stand.bank.balanceBookedCents, 10893) // Kopf ohne Stripe-Posten: Fixkosten und Abo-Verlaengerung (Appwrite) bleiben, Rechnungen fehlen assert.equal(body.kopf.kontostandCents, 10893) assert.equal(body.kopf.verfuegbarCents, 4176) assert.deepEqual(body.vorschau.posten.map((p) => p.label), ['Hetzner', 'Porkbun', 'Abo-Verlängerung']) // Kleinunternehmer-Zaehler ohne Stripe-Zahltage waere zu niedrig: unbekannt statt 0 (M8) assert.equal(body.steuer.kleinunternehmer.jahrCents, null) assert.equal(body.steuer.kleinunternehmer.anteilFolgejahr, null) assert.equal(body.steuer.kleinunternehmer.grenzeFolgejahrCents, 2500000) assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0]) assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 0, summeCents: 0 }) // Aufgaben aus Bank- und Abodaten; die Zuordnung hat ohne Stripe keinen Vorschlag const arts = ART(body) assert.ok(arts.includes('abo-zahlung')) assert.ok(!arts.includes('ueberfaellig')) const zuordnung = body.aufgaben.find((t) => t.art === 'zuordnung') assert.ok(zuordnung) assert.ok(!zuordnung.text.includes('Vorschlag')) assert.equal(body.abos.monatlichCents, 7000) }) test('buildOverview: antwortet Stripe nicht binnen der Wartezeit, rechnet die Uebersicht ohne Stripe weiter (I2)', async () => { seedAll() const error = mock.method(console, 'error', () => {}) st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 800 }) const started = Date.now() const body = await buildOverview({ stripeWaitMs: 100 }) assert.ok(Date.now() - started < 600, `Uebersicht brauchte ${Date.now() - started} ms`) assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null }) assert.equal(body.stand.bank.balanceBookedCents, 10893) assert.equal(body.kopf.kontostandCents, 10893) assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0]) assert.ok(ART(body).includes('abo-zahlung')) assert.equal(error.mock.callCount(), 1) assert.match(error.mock.calls[0].arguments[0], /Stripe laden fehlgeschlagen: keine Antwort nach 100 ms/) // spaeter antwortet Stripe wieder: die naechste Uebersicht ist vollstaendig await new Promise((resolve) => setTimeout(resolve, 800)) invalidateFinanceCache() const later = await buildOverview({ stripeWaitMs: 5000 }) assert.equal(later.stand.stripe.ok, true) }) test('buildOverview: Stripe antwortet rechtzeitig -> ok, die Wartezeit haelt den Prozess nicht auf (I2)', async () => { seedAll() const body = await buildOverview({ stripeWaitMs: 5000 }) assert.equal(body.stand.stripe.ok, true) }) test('GET /uebersicht: ohne Stripe kein Kleinunternehmer-Zähler und keine Warnung daraus, mit Stripe schon (M8)', async () => { seedConnection() seedSettings() // 19.000 EUR "Rechnung ausserhalb von Stripe" im laufenden Jahr = 76 % der Grenze aw.seed(config.collections.bankTransactions, tx('x1', { amountCents: 1_900_000, category: 'rechnung-extern', counterpartyName: 'GROSSKUNDE AG' })) const ok = await uebersicht() assert.equal(ok.steuer.kleinunternehmer.jahrCents, 1_900_000) assert.equal(ok.steuer.kleinunternehmer.anteilFolgejahr, 0.76) assert.equal(ok.aufgaben.filter((t) => t.art === 'kleinunternehmer').length, 1) invalidateFinanceCache() stripeDown() const down = await uebersicht() assert.equal(down.stand.stripe.ok, false) assert.equal(down.steuer.kleinunternehmer.jahrCents, null) assert.equal(down.steuer.kleinunternehmer.anteilFolgejahr, null) assert.deepEqual(down.aufgaben.filter((t) => t.art === 'kleinunternehmer'), []) // Gewinn und Ruecklage kommen aus der Bank und bleiben assert.equal(down.steuer.einnahmenCents, 1_900_000) assert.equal(down.steuer.ruecklageCents, ok.steuer.ruecklageCents) }) test('GET /uebersicht: Verbindungsdokument ohne Sitzung -> wie keine Verbindung, Aufgabe "Bankkonto verbinden" (M12)', async () => { aw.seed(config.collections.bankConnections, { $id: 'finom', authState: 'abc123', authStartedAt: new Date().toISOString() }) const body = await uebersicht() assert.equal(body.stand.bank, null) assert.equal(body.kopf.kontostandCents, null) assert.equal(body.kopf.verfuegbarCents, null) assert.deepEqual(ART(body), ['bank-zugang']) assert.equal(body.aufgaben[0].titel, 'Bankkonto verbinden') // mit Sitzung (auch bei einer neuen Freigabe in Arbeit) bleibt es ein Zugang await deleteDocument(config.collections.bankConnections, 'finom') seedConnection({ authState: 'neu', authStartedAt: new Date().toISOString() }) const connected = await uebersicht() assert.equal(connected.stand.bank.status, 'aktiv') assert.ok(!ART(connected).includes('bank-zugang')) }) test('GET /uebersicht: Buchungen "privat" und "fixkosten" zählen nicht für Fixkosten-Vorschläge (M13)', async () => { seedConnection() const hetzner = (id, days, over = {}) => tx(id, { bookingDate: at(days), amountCents: -1000, counterpartyName: 'HETZNER ONLINE GMBH', ...over }) const seedThree = (prefix, over = {}) => [-58, -28, 0].forEach((days, i) => aw.seed(config.collections.bankTransactions, hetzner(`${prefix}${i}`, days, over))) seedThree('open') const suggested = (await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag') assert.equal(suggested.length, 1) assert.match(suggested[0].text, /HETZNER ONLINE GMBH/) for (const id of ['open0', 'open1', 'open2']) await deleteDocument(config.collections.bankTransactions, id) seedThree('privat', { category: 'privat' }) assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), []) for (const id of ['privat0', 'privat1', 'privat2']) await deleteDocument(config.collections.bankTransactions, id) seedThree('fix', { category: 'fixkosten', fixedCostId: 'fk1' }) assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), []) for (const id of ['fix0', 'fix1', 'fix2']) await deleteDocument(config.collections.bankTransactions, id) // zwei private und eine offene: ohne den Filter waeren es drei gleiche Abbuchungen aw.seed(config.collections.bankTransactions, hetzner('m0', -58, { category: 'privat' })) aw.seed(config.collections.bankTransactions, hetzner('m1', -28, { category: 'privat' })) aw.seed(config.collections.bankTransactions, hetzner('m2', 0)) assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), []) }) test('GET /uebersicht: Vorjahresumsatz über 25.000 € aus den Einstellungen wird eine hohe Aufgabe (M14)', async () => { seedConnection() seedSettings({ previousYearRevenueCents: 2_600_000 }) const body = await uebersicht() const todo = body.aufgaben.find((t) => t.titel === 'Vorjahresumsatz über 25.000 €') assert.ok(todo, JSON.stringify(body.aufgaben)) assert.equal(todo.art, 'kleinunternehmer') assert.equal(todo.dringlichkeit, 'hoch') assert.match(todo.text, /26\.000,00/) // hoch steht vor den mittleren Bank-Aufgaben assert.equal(body.aufgaben[0].dringlichkeit, 'hoch') await deleteDocument(config.collections.financeSettings, 'main') seedSettings({ previousYearRevenueCents: 2_500_000 }) assert.ok(!(await uebersicht()).aufgaben.some((t) => t.titel === 'Vorjahresumsatz über 25.000 €')) }) test('GET /uebersicht: Abos nicht ladbar -> 200 mit abos.fehler, der Rest bleibt, nichts aus Abos in Vorschau und Aufgaben (M15)', async () => { seedAll() seedSub('sub_c', { customerId: 'c1', status: 'past_due' }) const error = mock.method(console, 'error', () => {}) aw.failNext({ collection: config.collections.subscriptions, method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' }) const res = await rawGet(`${base}/uebersicht`, admin) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(res.body.abos, { monatlichCents: null, zahlendeKunden: null, gefaehrdetCents: null, liste: [], fehler: 'Abos konnten nicht geladen werden.', }) assert.ok(!JSON.stringify(res.body).includes('10.0.0.9')) assert.equal(res.body.stand.bank.balanceBookedCents, 10893) assert.equal(res.body.stand.stripe.ok, true) assert.ok(!ART(res.body).includes('abo-zahlung')) assert.ok(!res.body.vorschau.posten.some((p) => p.label === 'Abo-Verlängerung')) assert.ok(res.body.vorschau.posten.some((p) => p.label === 'Porkbun'), 'Fixkosten bleiben') assert.ok(ART(res.body).includes('ueberfaellig')) assert.equal(error.mock.callCount(), 1) assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Abos laden fehlgeschlagen:', 'Error', 500, 500]) // der naechste Aufruf laedt wieder und hat kein fehler-Feld mock.restoreAll() const again = await uebersicht() assert.equal(again.abos.fehler, undefined) assert.equal(again.abos.liste.length, 3) }) test('buildOverview: ESt-Vorauszahlung bleibt ein sicherer Posten, ist aber nicht in fixkosten30Cents und verfuegbarCents (M6)', async () => { seedConnection() seedSettings({ estPrepaymentCents: 50_000, minBufferCents: 0 }) aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-11-25', counterpartyMatch: '', active: true }) const body = await buildOverview({ now: new Date('2026-11-20T10:00:00Z') }) assert.deepEqual(body.vorschau.posten.map((p) => [p.date, p.label, p.amountCents]), [ ['2026-11-25', 'Hetzner', -449], ['2026-12-10', 'ESt-Vorauszahlung', -50_000], ]) assert.equal(body.vorschau.fixkosten30Cents, 449) assert.equal(body.kopf.aufschluesselung.fixkosten30Cents, 449) // 10.893 - Ruecklage 0 - Fixkosten 449 - Puffer 0: die ESt ist Sache der Ruecklage, nicht noch einmal abgezogen assert.equal(body.kopf.verfuegbarCents, 10893 - 449) assert.equal(body.vorschau.endeSicherCents, 10893 - 449 - 50_000) }) test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => { seedAll() st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' }) const body = await uebersicht() assert.equal(body.stand.stripe.ok, false) assert.equal(body.stand.stripe.fehler, 'Stripe ist gerade nicht erreichbar.') assert.ok(!JSON.stringify(body).includes('Geheimer')) assert.ok(ART(body).includes('ueberfaellig')) assert.equal(body.rechnungen.altersgruppen[1].anzahl, 1) // ohne vollstaendige Stripe-Daten gibt es keine Zuordnungsvorschlaege assert.ok(!body.aufgaben.find((t) => t.art === 'zuordnung').text.includes('Vorschlag')) }) test('GET /uebersicht: Altersgruppen an den Grenzen 30/31 und 60/61 Tage Verzug, Summen nach offenem Betrag', async () => { seedConnection() const late = (id, days, remaining) => putInvoice({ id, number: id.toUpperCase(), created: unix(at(-days - 14)), due_date: unix(at(-days)), total: remaining + 100, amount_due: remaining + 100, amount_remaining: remaining, metadata: {}, }) late('in_a', 1, 100) late('in_b', 30, 200) late('in_c', 31, 400) late('in_d', 60, 800) late('in_e', 61, 1600) putInvoice({ id: 'in_f', number: 'IN_F', due_date: unix(TODAY), total: 50, amount_due: 50, amount_remaining: 50, metadata: {} }) // bezahlt, storniert und Entwurf zaehlen nicht zu den Altersgruppen putInvoice({ id: 'in_g', status: 'paid', amount_remaining: 0, amount_paid: 999, due_date: unix(at(-90)), metadata: {} }) putInvoice({ id: 'in_h', status: 'void', due_date: unix(at(-90)), metadata: {} }) const body = await uebersicht() assert.deepEqual(body.rechnungen.altersgruppen, [ { key: 'nicht-faellig', anzahl: 1, summeCents: 50 }, { key: '1-30', anzahl: 2, summeCents: 300 }, { key: '31-60', anzahl: 2, summeCents: 1200 }, { key: 'ueber-60', anzahl: 1, summeCents: 1600 }, ]) }) test('GET /uebersicht: Abo eines unbekannten Kunden und Fixkosten-Verweis auf eine geloeschte Position bringen nichts zu Fall', async () => { seedConnection() seedSub('sub_g', { customerId: 'geist', status: 'past_due', priceAmount: 700 }) aw.seed(config.collections.bankTransactions, tx('b1', { category: 'fixkosten', fixedCostId: 'geloescht', amountCents: -449 })) const res = await rawGet(`${base}/uebersicht`, admin) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(res.body.abos.liste, [{ customerId: 'geist', kunde: '', produkt: 'hosting', status: 'past_due', betragCents: 700, naechsteAbbuchung: null }]) const abo = res.body.aufgaben.find((t) => t.art === 'abo-zahlung') assert.ok(abo) assert.ok(!abo.text.startsWith(': ')) }) test('GET /uebersicht: Abo-Liste - Status, Produkt, kuendigende Abos ohne naechste Abbuchung, beendete Abos fehlen', async () => { seedConnection() const end = new Date(`${at(10)}T12:00:00+02:00`).toISOString() seedSub('s1', { customerId: 'c1', status: 'active', productKey: 'hosting' }) seedSub('s2', { customerId: 'c2', status: 'active', productKey: undefined, planKey: 'wartung_monat', priceAmount: 1000, currentPeriodEnd: end, cancelAtPeriodEnd: true, cancelAt: null }) seedSub('s3', { customerId: 'c3', status: 'active', priceAmount: 300, currentPeriodEnd: end, cancelAtPeriodEnd: true, cancelAt: new Date(`${at(10)}T12:00:00+02:00`).toISOString() }) seedSub('s4', { customerId: 'c4', status: 'canceled', priceAmount: 9999 }) seedSub('s5', { customerId: 'c1', status: 'unpaid', priceAmount: 400 }) const body = await uebersicht() const byId = Object.fromEntries(body.abos.liste.map((a) => [`${a.customerId}:${a.status}`, a])) assert.equal(body.abos.liste.length, 4) assert.equal(byId['c1:active'].naechsteAbbuchung, at(20)) // Stripe bucht zum Periodenende nichts mehr ab: keine naechste Abbuchung; das Abo zaehlt bis dahin zur Summe assert.equal(byId['c2:active'].naechsteAbbuchung, null) assert.equal(byId['c2:active'].produkt, 'wartung_monat') assert.equal(byId['c3:active'].naechsteAbbuchung, null) assert.equal(byId['c1:unpaid'].betragCents, 400) assert.ok(!('c4:canceled' in byId)) // active 2000 + 1000 + 300; unpaid und canceled zaehlen nicht assert.equal(body.abos.monatlichCents, 3300) assert.equal(body.abos.gefaehrdetCents, 0) assert.equal(body.abos.zahlendeKunden, 3) assert.ok(ART(body).includes('abo-zahlung')) }) test('GET /uebersicht: ueber 100.000 Euro Einnahmen -> Kleinunternehmer-Aufgabe mit dem engeren Text', async () => { seedConnection() putInvoice({ id: 'in_big', status: 'paid', total: 10100000, amount_due: 0, amount_paid: 10100000, amount_remaining: 0, status_transitions: { paid_at: unix(TODAY) }, metadata: {} }) const body = await uebersicht() const klein = body.aufgaben.find((t) => t.art === 'kleinunternehmer') assert.ok(klein) assert.equal(klein.dringlichkeit, 'hoch') assert.ok(klein.text.includes('nicht mehr – auch im laufenden Jahr'), klein.text) }) test('buildOverview: ein Zeitpunkt (now) bestimmt "heute"', async () => { seedConnection() const overview = await buildOverview({ now: new Date('2031-05-17T08:00:00Z') }) assert.equal(overview.vorschau.minSicher.date, '2031-05-17') assert.equal(overview.steuer.jahr, 2031) }) test('GET /uebersicht: Appwrite scheitert -> 500 ohne Appwrite-Meldung', async () => { seedConnection() aw.failNext({ collection: config.collections.bankTransactions, method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' }) const res = await rawGet(`${base}/uebersicht`, admin) assert.equal(res.status, 500) assert.equal(res.body.error, 'Die Übersicht konnte nicht geladen werden.') assert.ok(!JSON.stringify(res.body).includes('10.0.0.9')) }) // ---------- GET /rechnungen ---------- test('GET /rechnungen: Standard ist alle, neueste zuerst; Statusfilter', async () => { seedStripe() const all = await get(`${base}/rechnungen`, admin) assert.deepEqual(Object.keys(all), ['rechnungen']) assert.deepEqual(all.rechnungen.map((r) => r.id), ['in_draft', 'in_open', 'in_paid', 'in_over']) assert.deepEqual((await get(`${base}/rechnungen?status=alle`, admin)).rechnungen.map((r) => r.id), ['in_draft', 'in_open', 'in_paid', 'in_over']) const byStatus = async (status) => (await get(`${base}/rechnungen?status=${status}`, admin)).rechnungen.map((r) => r.id) assert.deepEqual(await byStatus('ueberfaellig'), ['in_over']) assert.deepEqual(await byStatus('offen'), ['in_open']) assert.deepEqual(await byStatus('bezahlt'), ['in_paid']) assert.deepEqual(await byStatus('entwurf'), ['in_draft']) const [over] = (await get(`${base}/rechnungen?status=ueberfaellig`, admin)).rechnungen assert.equal(over.status, 'ueberfaellig') assert.equal(over.overdueDays, 10) assert.equal(over.number, 'WK-0006') assert.equal(over.remainingCents, 5000) }) test('GET /rechnungen: kunde filtert auf webklarCustomerId, auch zusammen mit dem Status', async () => { seedStripe() const ids = async (query) => (await get(`${base}/rechnungen?${query}`, admin)).rechnungen.map((r) => r.id) assert.deepEqual(await ids('kunde=c1'), ['in_draft', 'in_open', 'in_paid']) assert.deepEqual(await ids('kunde=c2'), ['in_over']) assert.deepEqual(await ids('kunde=c1&status=bezahlt'), ['in_paid']) assert.deepEqual(await ids('kunde=c9'), []) }) test('GET /rechnungen: unbekannter Status oder mehrfache Parameter -> 400', async () => { seedStripe() for (const query of ['status=irgendwas', 'status=offen&status=bezahlt', 'kunde=a&kunde=b']) { const res = await rawGet(`${base}/rechnungen?${query}`, admin) assert.equal(res.status, 400, query) assert.equal(typeof res.body.error, 'string') } assert.equal(callsTo('GET', '/v1/invoices').length, 0) }) test('GET /rechnungen: Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => { stripeDown() const res = await rawGet(`${base}/rechnungen`, admin) assert.equal(res.status, 502) assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.') assert.ok(!JSON.stringify(res.body).includes('sk_live_geheim')) }) // ---------- POST /rechnungen ---------- const ITEMS = [ { description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 }, { description: 'Domain', quantity: 1, unitAmountCents: 1200 }, ] const postInvoice = (body) => post(`${base}/rechnungen`, body, admin) test('POST /rechnungen: Entwurf mit Standard-Zahlungsziel 14 Tage, createdBy aus dem Admin-Konto', async () => { const res = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'Danke' }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(Object.keys(res.body), ['rechnung']) assert.equal(res.body.rechnung.status, 'entwurf') assert.equal(res.body.rechnung.totalCents, 11200) assert.equal(res.body.rechnung.webklarCustomerId, 'c1') const [create] = callsTo('POST', '/v1/invoices') assert.equal(create.body.days_until_due, '14') assert.equal(create.body['metadata[erstelltVon]'], 'kenso@webklar.com') assert.equal(create.body.description, 'Danke') assert.equal(callsTo('POST', /\/finalize$/).length, 0) assert.equal(callsTo('POST', /\/send$/).length, 0) }) test('POST /rechnungen: daysUntilDue wird uebernommen, null zaehlt als fehlend', async () => { assert.equal((await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: 30 })).status, 200) assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '30') assert.equal((await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: null })).status, 200) assert.equal(callsTo('POST', '/v1/invoices')[1].body.days_until_due, '14') const bad = await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: 61 }) assert.equal(bad.status, 400) assert.equal(bad.body.error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.') }) for (const senden of [true, 'true']) { test(`POST /rechnungen: senden ${JSON.stringify(senden)} -> festgeschrieben und gesendet (offen)`, async () => { const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.rechnung.status, 'offen') assert.match(res.body.rechnung.number, /^WK-\d{4}$/) assert.equal(callsTo('POST', /\/finalize$/).length, 1) assert.equal(callsTo('POST', /\/send$/).length, 1) }) } for (const senden of [false, 'false', 'ja', 1, undefined, null]) { test(`POST /rechnungen: senden ${JSON.stringify(senden)} bleibt ein Entwurf`, async () => { const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.rechnung.status, 'entwurf') assert.equal(callsTo('POST', /\/send$/).length, 0) }) } test('POST /rechnungen: ungueltige Eingaben -> 400 mit Meldung, nichts bei Stripe', async () => { aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' }) const cases = [ [{ customerId: 'c1', items: [] }, 'Eine Rechnung braucht 1 bis 20 Positionen.'], [{ customerId: 'c1' }, 'Eine Rechnung braucht 1 bis 20 Positionen.'], [{ customerId: 'c1', items: [{ description: 'x', quantity: 0, unitAmountCents: 100 }] }, 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.'], [{ customerId: 'lead1', items: ITEMS }, 'Rechnungen nur für feste Kunden.'], [{ customerId: 'gibtsnicht', items: ITEMS }, 'Kunde nicht gefunden.'], [{ items: ITEMS }, 'Kunde nicht gefunden.'], ] for (const [body, message] of cases) { const res = await postInvoice(body) assert.equal(res.status, 400, JSON.stringify(body)) assert.equal(res.body.error, message) } assert.equal(callsTo('POST', '/v1/invoices').length, 0) assert.equal(st.invoices.size, 0) }) test('POST /rechnungen: ohne Body -> 400, kein Absturz', async () => { const res = await request('POST', `${base}/rechnungen`, { ...admin }) assert.equal(res.status, 400) }) test('POST /rechnungen: Senden scheitert nach dem Festschreiben -> 502 mit invoiceId und Rechnungsnummer', async () => { st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' }) const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true }) assert.equal(res.status, 502) assert.match(res.body.invoiceId, /^in_/) assert.match(res.body.invoiceNumber, /^WK-\d{4}$/) assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`) assert.ok(!JSON.stringify(res.body).includes('Geheimer')) assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber']) // die Rechnung ist offen und laesst sich erneut senden assert.equal(st.invoices.get(res.body.invoiceId).status, 'open') const again = await post(`${base}/rechnungen/${res.body.invoiceId}/senden`, {}, admin) assert.equal(again.status, 200, JSON.stringify(again.body)) assert.equal(again.body.rechnung.status, 'offen') }) test('POST /rechnungen: idempotencyKey geht als Idempotency-Key an Stripe, ein ungueltiger -> 400 (I3)', async () => { const res = await postInvoice({ customerId: 'c1', items: ITEMS, idempotencyKey: 'woms-formular-4711' }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(callsTo('POST', '/v1/invoices')[0].idempotencyKey, 'woms-formular-4711') for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 7]) { const bad = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'anders', idempotencyKey }) assert.equal(bad.status, 400, String(idempotencyKey)) assert.equal(bad.body.error, 'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).') } assert.equal(callsTo('POST', '/v1/invoices').length, 1) }) test('POST /rechnungen: derselbe Inhalt gleich noch einmal -> 409 "wird gerade schon erstellt", keine zweite Rechnung (I3)', async () => { const body = { customerId: 'c1', items: ITEMS, senden: true } const results = await Promise.all([postInvoice(body), postInvoice(body)]) assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]) const conflict = results.find((r) => r.status === 409) assert.deepEqual(conflict.body, { error: 'Diese Rechnung wird gerade schon erstellt.' }) assert.equal(st.invoices.size, 1) assert.equal(callsTo('POST', /\/send$/).length, 1) // auch einen Moment spaeter (Nutzer klickt noch einmal), aber nicht bei anderem Inhalt assert.equal((await postInvoice(body)).status, 409) assert.equal((await postInvoice({ ...body, memo: 'anderer Vermerk' })).status, 200) assert.equal(st.invoices.size, 2) }) test('POST /rechnungen: Rechnung nach dem Anlegen nicht lesbar -> 502 mit invoiceId und Nummer, Ursache im Protokoll (I3, M11)', async () => { const error = mock.method(console, 'error', () => {}) st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Geheimer Lesetext sk_live_abc' }) const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true }) assert.equal(res.status, 502) assert.match(res.body.invoiceId, /^in_/) assert.match(res.body.invoiceNumber, /^WK-\d{4}$/) assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`) assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber']) assert.ok(!JSON.stringify(res.body).includes('Geheimer')) assert.equal(st.invoices.get(res.body.invoiceId).status, 'open') assert.equal(error.mock.callCount(), 1) assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, '']) }) test('POST /rechnungen: Senden scheitert (502) -> Art, Status und Code der Stripe-Ursache im Protokoll, nie deren Text (M11)', async () => { const error = mock.method(console, 'error', () => {}) st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' }) const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true }) assert.equal(res.status, 502) assert.equal(error.mock.callCount(), 1) assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, '']) assert.ok(!JSON.stringify(error.mock.calls).includes('Geheimer')) }) test('POST /rechnungen: eine Anlage ohne Rechnung bei Stripe (400) schreibt keine zweite Ursache ins Protokoll (M11)', async () => { const error = mock.method(console, 'error', () => {}) assert.equal((await postInvoice({ customerId: 'gibtsnicht', items: ITEMS })).status, 400) assert.equal(error.mock.callCount(), 0) }) test('POST /rechnungen: Stripe lehnt ab (4xx) -> 409, ueberspringt Stripe-Text; Stripe nicht erreichbar -> 502', async () => { st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'Geheimer Stripe-Text' }) const rejected = await postInvoice({ customerId: 'c1', items: ITEMS }) assert.equal(rejected.status, 409) assert.equal(rejected.body.error, 'Stripe hat die Änderung abgelehnt.') assert.ok(!JSON.stringify(rejected.body).includes('Geheimer')) mock.method(getStripe().invoices, 'create', async () => { throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' }) }) const down = await postInvoice({ customerId: 'c1', items: ITEMS }) assert.equal(down.status, 502) assert.equal(down.body.error, 'Stripe ist gerade nicht erreichbar.') assert.ok(!JSON.stringify(down.body).includes('10.0.0.1')) }) test('POST /rechnungen: Appwrite scheitert (Kunde laden) -> 500 ohne Appwrite-Meldung', async () => { aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' }) const res = await postInvoice({ customerId: 'c1', items: ITEMS }) assert.equal(res.status, 500) assert.equal(res.body.error, 'Interner Fehler.') assert.ok(!JSON.stringify(res.body).includes('10.0.0.9')) }) // ---------- senden, stornieren, loeschen ---------- const senden = (id) => post(`${base}/rechnungen/${id}/senden`, {}, admin) const stornieren = (id) => post(`${base}/rechnungen/${id}/stornieren`, {}, admin) test('POST /rechnungen/:id/senden: Entwurf -> offen; offene Rechnung wird erneut gesendet', async () => { const created = await postInvoice({ customerId: 'c1', items: ITEMS }) const id = created.body.rechnung.id const sent = await senden(id) assert.equal(sent.status, 200, JSON.stringify(sent.body)) assert.deepEqual(Object.keys(sent.body), ['rechnung']) assert.equal(sent.body.rechnung.status, 'offen') assert.equal(callsTo('POST', /\/finalize$/).length, 1) assert.equal(callsTo('POST', /\/send$/).length, 1) const again = await senden(id) assert.equal(again.status, 200) assert.equal(again.body.rechnung.status, 'offen') assert.equal(callsTo('POST', /\/finalize$/).length, 1) assert.equal(callsTo('POST', /\/send$/).length, 2) }) test('POST /rechnungen/:id/senden: bezahlt -> 409, Abo-Rechnung -> 409, unbekannt oder ungueltige ID -> 404', async () => { putInvoice({ id: 'in_paid', status: 'paid', amount_paid: 100, amount_remaining: 0 }) putInvoice({ id: 'in_abo', status: 'draft', number: null, subscription: 'sub_a' }) const paid = await senden('in_paid') assert.equal(paid.status, 409) assert.equal(paid.body.error, 'Diese Rechnung kann nicht mehr gesendet werden.') const abo = await senden('in_abo') assert.equal(abo.status, 409) assert.equal(abo.body.error, 'Abo-Rechnungen verwaltet Stripe.') assert.equal((await senden('in_gibtsnicht')).status, 404) assert.equal((await senden('keine-id')).status, 404) assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0) }) test('POST /rechnungen/:id/senden: Kunde ohne E-Mail-Adresse -> 400', async () => { // Entwurf ohne webklarCustomerId (z. B. aus dem Stripe-Dashboard): es gibt keine WOMS-E-Mail, die Stripe nachgetragen werden koennte putInvoice({ id: 'in_nomail', status: 'draft', number: null, customer_email: null, customer: 'cus_ohne', metadata: {} }) st.customers.set('cus_ohne', { id: 'cus_ohne', object: 'customer', name: 'Ohne', email: null, metadata: {} }) const res = await senden('in_nomail') assert.equal(res.status, 400) assert.equal(res.body.error, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.') }) const ADDRESS_INCOMPLETE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' const BIG_ITEMS = [{ description: 'Großauftrag', quantity: 1, unitAmountCents: 30_000 }] test('POST /rechnungen: senden bei unvollstaendiger Anschrift und Summe ueber 250 € -> 409 mit fester Meldung, nichts bei Stripe; Entwurf geht', async () => { // c1 hat in den Testdaten keine Anschrift const blocked = await postInvoice({ customerId: 'c1', items: BIG_ITEMS, senden: true }) assert.equal(blocked.status, 409) assert.deepEqual(blocked.body, { error: ADDRESS_INCOMPLETE }) assert.equal(st.calls.length, 0) const draft = await postInvoice({ customerId: 'c1', items: BIG_ITEMS }) assert.equal(draft.status, 200, JSON.stringify(draft.body)) assert.equal(draft.body.rechnung.status, 'entwurf') }) test('POST /rechnungen/:id/senden: Entwurf ueber 250 € ohne Anschrift -> 409; mit ergaenzter Anschrift wird der Stripe-Kunde vor dem Finalisieren abgeglichen', async () => { const draft = (await postInvoice({ customerId: 'c1', items: BIG_ITEMS })).body.rechnung.id st.calls.length = 0 const blocked = await senden(draft) assert.equal(blocked.status, 409) assert.deepEqual(blocked.body, { error: ADDRESS_INCOMPLETE }) assert.equal(st.calls.filter((c) => c.method !== 'GET').length, 0) aw.seed('customers', { ...customers.c1, street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin' }) st.calls.length = 0 const sent = await senden(draft) assert.equal(sent.status, 200, JSON.stringify(sent.body)) assert.equal(sent.body.rechnung.status, 'offen') assert.deepEqual( st.calls.filter((c) => c.method !== 'GET').map((c) => c.path.replace(draft, ':id')), ['/v1/customers/cus_c1', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'] ) assert.equal(st.customers.get('cus_c1').address.line1, 'Hauptstr. 1') aw.seed('customers', customers.c1) }) test('POST /rechnungen/:id/senden: scheitert der Abgleich des Stripe-Kunden -> 502 mit fester Meldung, nicht finalisiert', async () => { const draft = (await postInvoice({ customerId: 'c1', items: ITEMS })).body.rechnung.id st.calls.length = 0 st.failNext({ method: 'POST', path: '/v1/customers/cus_c1', status: 400, message: 'geheime Stripe-Meldung' }) const res = await senden(draft) assert.equal(res.status, 502) assert.deepEqual(res.body, { error: 'Kundendaten konnten nicht an Stripe übertragen werden.' }) assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0) assert.equal(st.invoices.get(draft).status, 'draft') }) test('POST /rechnungen/:id/stornieren: offene Rechnung -> storniert; Entwurf und bezahlte -> 409', async () => { putInvoice({ id: 'in_open' }) putInvoice({ id: 'in_draft', status: 'draft', number: null }) putInvoice({ id: 'in_paid', status: 'paid', amount_paid: 100, amount_remaining: 0 }) const ok = await stornieren('in_open') assert.equal(ok.status, 200, JSON.stringify(ok.body)) assert.deepEqual(Object.keys(ok.body), ['rechnung']) assert.equal(ok.body.rechnung.status, 'storniert') assert.equal(st.invoices.get('in_open').status, 'void') for (const id of ['in_draft', 'in_paid']) { const res = await stornieren(id) assert.equal(res.status, 409, id) assert.equal(res.body.error, 'Nur offene Rechnungen können storniert werden.') } assert.equal((await stornieren('in_gibtsnicht')).status, 404) }) test('DELETE /rechnungen/:id: Entwurf wird geloescht, offene Rechnung -> 409', async () => { const created = await postInvoice({ customerId: 'c1', items: ITEMS }) const draftId = created.body.rechnung.id const ok = await del(`${base}/rechnungen/${draftId}`) assert.equal(ok.status, 200, JSON.stringify(ok.body)) assert.deepEqual(ok.body, { ok: true }) assert.ok(!st.invoices.has(draftId)) putInvoice({ id: 'in_open' }) const open = await del(`${base}/rechnungen/in_open`) assert.equal(open.status, 409) assert.equal(open.body.error, 'Nur Entwürfe können gelöscht werden.') assert.ok(st.invoices.has('in_open')) putInvoice({ id: 'in_abo', status: 'draft', number: null, subscription: 'sub_a' }) assert.equal((await del(`${base}/rechnungen/in_abo`)).status, 409) assert.equal((await del(`${base}/rechnungen/in_gibtsnicht`)).status, 404) }) test('DELETE /rechnungen/:id: Stripe lehnt ab -> 409 ohne Stripe-Text', async () => { putInvoice({ id: 'in_draft', status: 'draft', number: null }) st.failNext({ method: 'DELETE', path: '/v1/invoices/in_draft', status: 400, message: 'Geheimer Stripe-Text' }) const res = await del(`${base}/rechnungen/in_draft`) assert.equal(res.status, 409) assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.') assert.ok(!JSON.stringify(res.body).includes('Geheimer')) }) test('Rechnungsaenderungen leeren den Zwischenspeicher: Liste und Uebersicht zeigen den neuen Stand', async () => { seedConnection() putInvoice({ id: 'in_open' }) assert.equal((await uebersicht()).rechnungen.altersgruppen[0].anzahl, 1) assert.equal((await stornieren('in_open')).status, 200) assert.equal((await uebersicht()).rechnungen.altersgruppen[0].anzahl, 0) assert.deepEqual((await get(`${base}/rechnungen?status=offen`, admin)).rechnungen, []) })