M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten. M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute. M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus. M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung. M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege. M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe. M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1050 lines
51 KiB
JavaScript
1050 lines
51 KiB
JavaScript
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 () => {
|
||
putInvoice({ id: 'in_nomail', status: 'draft', number: null, customer_email: null, customer: 'cus_ohne' })
|
||
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.')
|
||
})
|
||
|
||
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, [])
|
||
})
|