297 lines
14 KiB
JavaScript
297 lines
14 KiB
JavaScript
import { test, after, beforeEach, mock } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||
import { startFakeStripe } from './support/fakeStripe.js'
|
||
import { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||
|
||
// Projektkonto (Strang L1): reine Rechenregeln, Stripe lesen, Konten je Kunde und Projekt, Abzeichen "abgerechnet".
|
||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster financeStripeRead.test.js und die Tests von L0).
|
||
// worksheets, workorders, websiteProjects und customers gehoeren dem Ticketsystem und haben im Nachbau kein Schema.
|
||
const aw = await startAppwriteMock({
|
||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||
datetimes: AUFTRAEGE_DATETIMES,
|
||
defaultLimit: 25,
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
|
||
const { config } = await import('../server/config.js')
|
||
const { seedFixtures } = await import('./support/fixtures.js')
|
||
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const { invalidateFinanceCache, listInvoices } = await import('../server/services/finance/stripeFinance.js')
|
||
const { rechneKonto, rechneOhneProjekt, ladeStripeRechnungen, projektkontenFuerKunde, projektkontoDetail, abgerechnetFuerTicket } = await import(
|
||
'../server/services/auftraege/projektkonto.js'
|
||
)
|
||
|
||
const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen']
|
||
async function reset() {
|
||
mock.restoreAll()
|
||
invalidateFinanceCache()
|
||
st.calls.length = 0
|
||
st.invoices.clear()
|
||
aw.resetListCalls()
|
||
config.auftraege.mode = 'on'
|
||
config.auftraege.pilotEmails = []
|
||
for (const name of COLLECTIONS) {
|
||
for (const id of aw.docs(config.collections[name]).keys()) await deleteDocument(config.collections[name], id)
|
||
}
|
||
}
|
||
beforeEach(reset)
|
||
after(() => Promise.all([aw.close(), st.close()]))
|
||
seedFixtures(aw)
|
||
|
||
// ---------- reine Rechenregeln (Task 2) ----------
|
||
|
||
const ab = (over = {}) => ({
|
||
id: 'a1',
|
||
status: 'versendet',
|
||
art: 'festpreis',
|
||
summeCents: 80000,
|
||
stundensatzCents: null,
|
||
schaetzungMinuten: null,
|
||
gesendetAm: '2026-10-06T08:00:00.000Z',
|
||
erstelltAm: '2026-10-06T07:00:00.000Z',
|
||
...over,
|
||
})
|
||
const rg = (id, status, totalCents, paidCents, auftragId = '', art = 'festbetrag') => ({ art, auftragId, invoice: { id, status, totalCents, paidCents } })
|
||
const blatt = (id, minuten, over = {}) => ({ id, ticketId: 't1', woid: '100001', datum: '2026-10-06', minuten, text: '', aboZeit: false, kommentar: false, ...over })
|
||
const NULL_ZEIT = { minuten: 0, aboMinuten: 0, offeneMinuten: 0, satzCents: 10000, wertCents: 0, offenerWertCents: 0 }
|
||
|
||
test('rechneKonto: ohne AB, Rechnungen und Zeit ist alles leer, der Satz ist 100 €', () => {
|
||
assert.deepEqual(rechneKonto({}), {
|
||
vereinbart: { festpreisCents: null, aufwand: null },
|
||
berechnetCents: 0,
|
||
bezahltCents: 0,
|
||
offenCents: null,
|
||
zeit: NULL_ZEIT,
|
||
})
|
||
})
|
||
|
||
test('rechneKonto: vereinbart sind nur Festpreis-ABs mit Status versendet, bestaetigt oder abgerechnet', () => {
|
||
const konto = rechneKonto({
|
||
auftraege: [
|
||
ab({ id: 'a1', summeCents: 80000 }),
|
||
ab({ id: 'a2', status: 'bestaetigt', summeCents: 20000 }),
|
||
ab({ id: 'a3', status: 'abgerechnet', summeCents: 5000 }),
|
||
...['entwurf', 'storniert', 'ersetzt', 'widerrufen'].map((status, i) => ab({ id: `x${i}`, status, summeCents: 99900 })),
|
||
],
|
||
})
|
||
assert.deepEqual(konto.vereinbart, { festpreisCents: 105000, aufwand: null })
|
||
})
|
||
|
||
test('rechneKonto: die jüngste gültige Aufwands-AB bestimmt Satz und Schätzung', () => {
|
||
const aufwand = (over) => ab({ art: 'aufwand', summeCents: null, ...over })
|
||
const konto = rechneKonto({
|
||
auftraege: [
|
||
aufwand({ id: 'a1', stundensatzCents: 9000, schaetzungMinuten: 600, gesendetAm: '2026-10-01T08:00:00.000Z' }),
|
||
aufwand({ id: 'a2', stundensatzCents: 12000, schaetzungMinuten: 300, gesendetAm: '2026-10-05T08:00:00.000Z' }),
|
||
aufwand({ id: 'a3', stundensatzCents: 15000, status: 'entwurf', gesendetAm: null, erstelltAm: '2026-10-06T08:00:00.000Z' }),
|
||
aufwand({ id: 'a4', stundensatzCents: 20000, status: 'storniert', gesendetAm: '2026-10-06T09:00:00.000Z' }),
|
||
],
|
||
blaetter: [blatt('w1', 60)],
|
||
})
|
||
assert.deepEqual(konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 12000, schaetzungMinuten: 300 } })
|
||
assert.equal(konto.offenCents, null)
|
||
assert.equal(konto.zeit.satzCents, 12000)
|
||
assert.equal(konto.zeit.wertCents, 12000)
|
||
})
|
||
|
||
test('rechneKonto: berechnet und bezahlt zählen nur finalisierte Rechnungen, Entwürfe und Stornos nicht', () => {
|
||
const konto = rechneKonto({
|
||
rechnungen: [
|
||
rg('in_1', 'offen', 40000, 0),
|
||
rg('in_2', 'ueberfaellig', 10000, 0),
|
||
rg('in_3', 'bezahlt', 25000, 25000),
|
||
rg('in_4', 'uneinbringlich', 5000, 0),
|
||
rg('in_5', 'entwurf', 99900, 0),
|
||
rg('in_6', 'storniert', 77700, 0),
|
||
],
|
||
})
|
||
assert.equal(konto.berechnetCents, 80000)
|
||
assert.equal(konto.bezahltCents, 25000)
|
||
})
|
||
|
||
test('rechneKonto: Festpreis-AB plus Aufwandsrechnungen – offen rechnet nur mit den Rechnungen zur Festpreis-AB', () => {
|
||
const konto = rechneKonto({
|
||
auftraege: [ab({ id: 'a1', summeCents: 80000 })],
|
||
rechnungen: [
|
||
rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung'),
|
||
rg('in_2', 'offen', 30000, 0, '', 'aufwand'),
|
||
rg('in_3', 'bezahlt', 5000, 5000, '', 'festbetrag'),
|
||
],
|
||
})
|
||
assert.equal(konto.berechnetCents, 75000)
|
||
assert.equal(konto.bezahltCents, 45000)
|
||
assert.equal(konto.offenCents, 40000)
|
||
})
|
||
|
||
test('rechneKonto: offen ist nie negativ, Anzahlung plus Schlussrechnung ergeben genau 0', () => {
|
||
const auftraege = [ab({ id: 'a1', summeCents: 80000 })]
|
||
const anzahlung = rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung')
|
||
assert.equal(rechneKonto({ auftraege, rechnungen: [anzahlung, rg('in_2', 'offen', 40000, 0, 'a1', 'schluss')] }).offenCents, 0)
|
||
const nachtraege = rechneKonto({ auftraege, rechnungen: [anzahlung, rg('in_2', 'offen', 60000, 0, 'a1', 'schluss')] })
|
||
assert.equal(nachtraege.offenCents, 0)
|
||
assert.equal(nachtraege.berechnetCents, 100000)
|
||
})
|
||
|
||
test('rechneKonto: Rechnungen zu einer stornierten AB mindern das Offene der gültigen AB nicht', () => {
|
||
const konto = rechneKonto({
|
||
auftraege: [ab({ id: 'a1', status: 'storniert', summeCents: 80000 }), ab({ id: 'a2', summeCents: 50000 })],
|
||
rechnungen: [rg('in_1', 'offen', 40000, 0, 'a1', 'anzahlung')],
|
||
})
|
||
assert.equal(konto.vereinbart.festpreisCents, 50000)
|
||
assert.equal(konto.berechnetCents, 40000)
|
||
assert.equal(konto.offenCents, 50000)
|
||
})
|
||
|
||
test('rechneKonto: ohne Stripe sind berechnet und bezahlt 0 und offen unbekannt, Vereinbartes und Zeit bleiben', () => {
|
||
const konto = rechneKonto({
|
||
auftraege: [ab({ id: 'a1', summeCents: 80000 })],
|
||
rechnungen: [rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung')],
|
||
blaetter: [blatt('w1', 60)],
|
||
stripeOk: false,
|
||
})
|
||
assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents], [0, 0, null])
|
||
assert.equal(konto.vereinbart.festpreisCents, 80000)
|
||
assert.deepEqual(konto.zeit, { ...NULL_ZEIT, minuten: 60, offeneMinuten: 60, wertCents: 10000, offenerWertCents: 10000 })
|
||
})
|
||
|
||
test('rechneKonto: Zeit erfasst ohne Abo-Zeit, Kommentare und 0 Minuten – Abo getrennt, Berechnetes nicht mehr offen', () => {
|
||
const blaetter = [
|
||
blatt('w1', 60),
|
||
blatt('w2', 30),
|
||
blatt('w3', 45, { aboZeit: true }),
|
||
blatt('w4', 15, { kommentar: true }),
|
||
blatt('w5', 0),
|
||
blatt('w6', 90, { aboZeit: true, kommentar: true }),
|
||
]
|
||
const abgerechnet = new Map([['w1', { invoiceId: 'in_1', rechnungsnummer: 'WEBKLAR-0001' }]])
|
||
assert.deepEqual(rechneKonto({ blaetter, abgerechnet }).zeit, {
|
||
minuten: 90,
|
||
aboMinuten: 45,
|
||
offeneMinuten: 30,
|
||
satzCents: 10000,
|
||
wertCents: 15000,
|
||
offenerWertCents: 5000,
|
||
})
|
||
})
|
||
|
||
test('rechneKonto: der Wert der Zeit ist die Summe der je Berliner Tag gerundeten Werte (wie die Zeilen der Aufwandsrechnung)', () => {
|
||
const mitSatz = (stundensatzCents) => [ab({ art: 'aufwand', summeCents: null, stundensatzCents })]
|
||
const blaetter = [
|
||
blatt('w1', 15, { datum: '2026-10-05' }),
|
||
blatt('w2', 15, { datum: '2026-10-06' }),
|
||
blatt('w3', 15, { datum: '2026-10-06' }),
|
||
blatt('w4', 15, { datum: '2026-10-07' }),
|
||
]
|
||
const abgerechnet = new Map([['w2', { invoiceId: 'in_1', rechnungsnummer: 'WEBKLAR-0001' }]])
|
||
// 99,90 €/Std.: 15 Minuten = 24,975 € → 2498 Cent, 30 Minuten = 49,95 € → 4995 Cent
|
||
const krumm = rechneKonto({ auftraege: mitSatz(9990), blaetter, abgerechnet }).zeit
|
||
// je Tag gerundet: 2498 (05.10.) + 4995 (06.10., zwei Blätter) + 2498 (07.10.) = 9991; je Blatt wären es 9992, über alle 60 Minuten 9990
|
||
assert.equal(krumm.wertCents, 9991)
|
||
// offen (w2 ist berechnet): drei Tage zu je 15 Minuten = 3 × 2498 = 7494; über alle 45 Minuten wären es 7493
|
||
assert.deepEqual([krumm.minuten, krumm.offeneMinuten, krumm.offenerWertCents], [60, 45, 7494])
|
||
// 95,00 €/Std.: jede Viertelstunde geht glatt auf (2375 Cent), Tag für Tag wie insgesamt
|
||
const glatt = rechneKonto({ auftraege: mitSatz(9500), blaetter, abgerechnet }).zeit
|
||
assert.deepEqual([glatt.wertCents, glatt.offenerWertCents], [9500, 7125])
|
||
})
|
||
|
||
test('rechneOhneProjekt: nur finalisierte Rechnungen zählen', () => {
|
||
const inv = (status, totalCents, paidCents) => ({ status, totalCents, paidCents })
|
||
const rechnungen = [
|
||
inv('offen', 12000, 0),
|
||
inv('bezahlt', 8000, 8000),
|
||
inv('entwurf', 5000, 0),
|
||
inv('storniert', 4000, 0),
|
||
inv('uneinbringlich', 1000, 0),
|
||
inv('ueberfaellig', 2000, 500),
|
||
]
|
||
assert.deepEqual(rechneOhneProjekt(rechnungen), { berechnetCents: 23000, bezahltCents: 8500, anzahl: 4 })
|
||
assert.deepEqual(rechneOhneProjekt([]), { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||
})
|
||
|
||
// ---------- Stripe lesen (Task 3) ----------
|
||
|
||
const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
|
||
// Rohe Stripe-Rechnung wie in financeStripeRead.test.js; status in Stripes Schreibweise (draft, open, paid, void, uncollectible)
|
||
const stripeInvoice = (
|
||
id,
|
||
{ status = 'open', total = 0, paid = 0, number = '', kunde = 'c1', created = '2026-10-01', due = '2099-12-31', abo = false } = {}
|
||
) => ({
|
||
id,
|
||
object: 'invoice',
|
||
number: number || null,
|
||
status,
|
||
customer: `cus_${kunde}`,
|
||
customer_name: 'Kunde',
|
||
customer_email: 'k@example.invalid',
|
||
created: noonBerlin(created),
|
||
due_date: noonBerlin(due),
|
||
total,
|
||
amount_due: total,
|
||
amount_paid: paid,
|
||
amount_remaining: total - paid,
|
||
status_transitions: { paid_at: null },
|
||
metadata: { webklarCustomerId: kunde, quelle: 'woms' },
|
||
...(abo ? { parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } } : {}),
|
||
lines: { object: 'list', data: [], has_more: false },
|
||
})
|
||
const pr = (invoiceId) => ({ invoiceId })
|
||
|
||
test('ladeStripeRechnungen: Liste zuerst, Fehlendes einzeln gelesen, Unbekanntes bleibt aussen vor', async () => {
|
||
st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000, number: 'WEBKLAR-0001' }))
|
||
await listInvoices() // füllt den 60-Sekunden-Zwischenspeicher; in_9 fehlt dort
|
||
st.invoices.set('in_9', stripeInvoice('in_9', { status: 'paid', total: 7000, paid: 7000, number: 'WEBKLAR-0009' }))
|
||
st.calls.length = 0
|
||
const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9'), pr('in_404')])
|
||
assert.equal(result.ok, true)
|
||
assert.deepEqual([...result.nachId.keys()].sort(), ['in_1', 'in_9'])
|
||
assert.deepEqual(result.alle.map((invoice) => invoice.id), ['in_1'])
|
||
const gelesen = result.nachId.get('in_9')
|
||
assert.deepEqual([gelesen.status, gelesen.totalCents, gelesen.paidCents], ['bezahlt', 7000, 7000])
|
||
assert.deepEqual(st.calls.map((call) => `${call.method} ${call.path}`).sort(), ['GET /v1/invoices/in_404', 'GET /v1/invoices/in_9'])
|
||
})
|
||
|
||
test('ladeStripeRechnungen: Stripe-Ausfall ergibt ok false und ein Protokoll ohne Fehlertext', async () => {
|
||
const warn = mock.method(console, 'warn', () => {})
|
||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'geheim-kaputt' })
|
||
const result = await ladeStripeRechnungen([pr('in_1')])
|
||
assert.deepEqual([result.ok, result.alle, [...result.nachId.keys()]], [false, [], []])
|
||
assert.equal(warn.mock.callCount(), 1)
|
||
assert.ok(!JSON.stringify(warn.mock.calls[0].arguments).includes('geheim-kaputt'))
|
||
})
|
||
|
||
test('ladeStripeRechnungen: ein anderer Fehler als 404 beim Einzellesen ist ein Ausfall', async () => {
|
||
mock.method(console, 'warn', () => {})
|
||
st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000 }))
|
||
await listInvoices()
|
||
st.invoices.set('in_9', stripeInvoice('in_9', { total: 7000 }))
|
||
st.failNext({ method: 'GET', path: '/v1/invoices/in_9', status: 400, message: 'kaputt' })
|
||
const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9')])
|
||
assert.equal(result.ok, false)
|
||
assert.equal(result.nachId.size, 0)
|
||
})
|
||
|
||
test('ladeStripeRechnungen: viele fehlende Rechnungen werden vollständig gelesen (auch IDs wie die des Stripe-Nachbaus)', async () => {
|
||
await listInvoices()
|
||
const ids = Array.from({ length: 25 }, (_, i) => `in_test_${i + 1}`)
|
||
for (const id of ids) st.invoices.set(id, stripeInvoice(id, { status: 'paid', total: 100, paid: 100 }))
|
||
st.calls.length = 0
|
||
const result = await ladeStripeRechnungen(ids.map(pr))
|
||
assert.equal(result.ok, true)
|
||
assert.equal(result.nachId.size, 25)
|
||
assert.equal(st.calls.filter((call) => call.method === 'GET' && /^\/v1\/invoices\/in_/.test(call.path)).length, 25)
|
||
})
|
||
|
||
test('ladeStripeRechnungen: unmögliche Rechnungs-IDs gehen nie an Stripe', async () => {
|
||
await listInvoices()
|
||
st.calls.length = 0
|
||
// Pfadzeichen, ohne Präfix, kein Text, nur das Präfix, zu lang (V1: 1 bis 33 Zeichen nach „in_“)
|
||
const result = await ladeStripeRechnungen([pr('../customers'), pr('x'), pr(undefined), pr('in_'), pr(`in_${'x'.repeat(34)}`)])
|
||
assert.equal(result.ok, true)
|
||
assert.equal(result.nachId.size, 0)
|
||
assert.equal(st.calls.length, 0)
|
||
})
|