Files
Webklar-Kundenbereich/test/auftraegeProjektkonto.test.js
T

297 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})