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

688 lines
35 KiB
JavaScript
Raw Permalink 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)
})
// ---------- Projektkonten eines Kunden (Task 4) ----------
const seedTicket = (id, woid, customerId = 'c1') =>
aw.seed(config.collections.workorders, { $id: id, woid, customerId, topic: `Ticket ${woid}`, status: 'open' })
const seedProjekt = (id, over = {}) =>
aw.seed(config.collections.websiteProjects, {
$id: id,
customerId: 'c1',
projectName: `Projekt ${id}`,
ticketId: '',
kind: 'website',
archived: false,
...over,
})
let blattNr = 0
const seedBlatt = (id, ticketId, minuten, over = {}) =>
aw.seed(config.collections.worksheets, {
$id: id,
wsid: String(100000 + (blattNr += 1)),
woid: ticketId === 't2' ? '100002' : '100001',
workorderId: ticketId,
employeeId: 'e1',
employeeName: 'Kenso Grimm',
employeeShort: 'KNSO',
serviceType: 'Remote',
totalTime: minuten,
supporters: '',
startDate: '06.10.2026',
endDate: '06.10.2026',
startTime: '10:00',
endTime: '11:00',
details: 'Startseite überarbeitet',
isComment: false,
countsToPlan: false,
...over,
})
const seedPr = (invoiceId, { arbeitsblattIds = [], ...over } = {}) =>
aw.seed(config.collections.projektRechnungen, {
$id: invoiceId,
invoiceId,
kundenId: 'c1',
projektId: 'p1',
auftragId: '',
art: 'festbetrag',
betragCents: 0,
arbeitsblattIdsJson: JSON.stringify(arbeitsblattIds),
rechnungsnummer: '',
aktiv: true,
erstelltVon: 'kenso@webklar.com',
erstelltAm: '2026-10-01T10:00:00.000Z',
...over,
})
const seedAuftrag = (id, over = {}) =>
aw.seed(config.collections.auftraege, {
$id: id,
nummer: 'AB-2026-0001',
jahr: 2026,
laufnummer: 1,
status: 'versendet',
kundenId: 'c1',
kundeJson: JSON.stringify({
name: 'Kunde Eins', firma: '', strasse: 'Hauptstraße 1', plz: '67722', ort: 'Winnweiler', land: 'DE',
email: 'k@example.invalid', kundenart: 'unternehmen', kundennummer: '',
}),
projektId: 'p1',
titel: 'Website',
zeitquelleJson: '["t1"]',
art: 'festpreis',
positionenJson: JSON.stringify([{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }]),
stundensatzCents: null,
schaetzungMinuten: null,
summeCents: 80000,
zahlungsplanJson: JSON.stringify({ art: 'nach_abnahme', prozent: null, text: '' }),
zahlungszielTage: 14,
auftragVom: '2026-10-05',
auftragWeg: 'telefon',
startAm: '',
fertigBis: '',
texteFassung: '2026-10-06',
agbFassung: '2026-09-30',
pdfDatei: `c1/${id}.pdf`,
pdfSha256: 'a'.repeat(64),
gesendetAm: '2026-10-06T08:00:00.000Z',
gesendetAn: 'k@example.invalid',
mailStatus: 'gesendet',
mailFehler: '',
tokenHash: 'b'.repeat(64),
tokenGueltigBis: '2026-12-05T08:00:00.000Z',
bestaetigtAm: null,
bestaetigtUeber: '',
bestaetigtPdfSha256: '',
fruehbeginn: false,
fruehbeginnFassung: '',
ersetztId: '',
ersetztDurchId: '',
storniertAm: null,
widerrufenAm: null,
erstelltVon: 'kenso@webklar.com',
erstelltAm: '2026-10-06T07:00:00.000Z',
geaendertAm: '2026-10-06T08:00:00.000Z',
...over,
})
test('Projekte des Kunden: neueste zuerst, archivierte nur mit Geldbezug, Vorlagen und fremde Projekte nie', async () => {
seedProjekt('p1', { $createdAt: '2026-09-01T10:00:00.000Z' })
seedProjekt('p2', { $createdAt: '2026-09-02T10:00:00.000Z' })
seedProjekt('p3', { $createdAt: '2026-09-03T10:00:00.000Z', archived: true }) // aktive Projektrechnung -> sichtbar
seedProjekt('p4', { $createdAt: '2026-09-04T10:00:00.000Z', archived: true }) // ohne alles -> weg
seedProjekt('p5', { $createdAt: '2026-09-05T10:00:00.000Z', archived: true }) // nur Entwurfs-AB -> weg
seedProjekt('p6', { $createdAt: '2026-09-06T10:00:00.000Z', archived: true }) // gültige AB -> sichtbar
seedProjekt('p7', { $createdAt: '2026-09-07T10:00:00.000Z', isTemplate: true }) // Vorlage -> weg
seedProjekt('p8', { $createdAt: '2026-09-08T10:00:00.000Z', customerId: 'c2' }) // fremder Kunde -> weg
seedPr('in_1', { projektId: 'p3' })
st.invoices.set('in_1', stripeInvoice('in_1', { total: 1000, number: 'WEBKLAR-0001' }))
seedAuftrag('a5', { projektId: 'p5', status: 'entwurf', nummer: '' })
seedAuftrag('a6', { projektId: 'p6', status: 'versendet' })
const result = await projektkontenFuerKunde('c1')
assert.deepEqual(result.konten.map((konto) => konto.projektId), ['p6', 'p3', 'p2', 'p1'])
})
test('Projekt ohne Ticket: leere Zeitquelle und Zeit 0, Rechnungen und AB zählen trotzdem', async () => {
seedProjekt('p1', { ticketId: '', kind: 'webapp' })
seedAuftrag('a1', { projektId: 'p1', summeCents: 50000, zeitquelleJson: '[]' })
seedPr('in_1', { projektId: 'p1', auftragId: 'a1', art: 'anzahlung' })
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'paid', total: 25000, paid: 25000, number: 'WEBKLAR-0001' }))
const result = await projektkontenFuerKunde('c1')
assert.equal(result.konten.length, 1)
const [konto] = result.konten
assert.deepEqual(Object.keys(konto).sort(), [
'berechnetCents', 'bezahltCents', 'name', 'offenCents', 'projektId', 'projektTyp', 'tickets', 'vereinbart', 'zeit', 'zeitquelle',
])
assert.deepEqual([konto.name, konto.projektTyp], ['Projekt p1', 'Web-App'])
assert.deepEqual([konto.tickets, konto.zeitquelle], [[], []])
assert.deepEqual(konto.zeit, { minuten: 0, aboMinuten: 0, offeneMinuten: 0, satzCents: 10000, wertCents: 0, offenerWertCents: 0 })
assert.deepEqual([konto.vereinbart.festpreisCents, konto.berechnetCents, konto.bezahltCents, konto.offenCents], [50000, 25000, 25000, 25000])
assert.equal(aw.listCalls(config.collections.worksheets).length, 0)
})
test('Ticket mit mehreren Projekten: Zeit erfasst zählt in beiden Konten, offen nur einmal', async () => {
seedTicket('t1', '100001')
seedProjekt('p1', { ticketId: 't1', $createdAt: '2026-09-01T10:00:00.000Z' })
seedProjekt('p2', { ticketId: 't1', $createdAt: '2026-09-02T10:00:00.000Z' })
seedBlatt('w1', 't1', 60)
seedBlatt('w2', 't1', 30)
// p1 hat w1 schon in einer Aufwandsrechnung (Entwurf): das Blatt ist abgerechnet, die Rechnung zählt noch nicht als berechnet
seedPr('in_1', { projektId: 'p1', art: 'aufwand', arbeitsblattIds: ['w1'] })
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 10000 }))
const { konten } = await projektkontenFuerKunde('c1')
const nach = Object.fromEntries(konten.map((konto) => [konto.projektId, konto]))
const erwartet = { minuten: 90, aboMinuten: 0, offeneMinuten: 30, satzCents: 10000, wertCents: 15000, offenerWertCents: 5000 }
assert.deepEqual(nach.p1.zeit, erwartet)
assert.deepEqual(nach.p2.zeit, erwartet)
assert.deepEqual([nach.p1.berechnetCents, nach.p2.berechnetCents], [0, 0])
assert.deepEqual(nach.p2.tickets, [{ id: 't1', woid: '100001' }])
})
test('Zeitquelle: die gültige AB bestimmt die Tickets, ein Entwurf nicht', async () => {
seedTicket('t1', '100001')
seedTicket('t2', '100002')
seedProjekt('p1', { ticketId: 't1' })
seedBlatt('w1', 't1', 60)
seedBlatt('w2', 't2', 45)
seedAuftrag('a1', { projektId: 'p1', status: 'entwurf', zeitquelleJson: '["t2"]' })
let { konten } = await projektkontenFuerKunde('c1')
assert.deepEqual(konten[0].zeitquelle, ['t1'])
assert.equal(konten[0].zeit.minuten, 60)
await updateDocument(config.collections.auftraege, 'a1', { status: 'versendet' })
;({ konten } = await projektkontenFuerKunde('c1'))
assert.deepEqual(konten[0].zeitquelle, ['t2'])
assert.deepEqual(konten[0].tickets, [{ id: 't2', woid: '100002' }])
assert.equal(konten[0].zeit.minuten, 45)
})
test('Entwürfe, Stornos und inaktive Projektrechnungen zählen nicht, Blätter in Entwürfen sind trotzdem nicht mehr offen', async () => {
seedTicket('t1', '100001')
seedProjekt('p1', { ticketId: 't1' })
seedBlatt('w1', 't1', 60)
seedBlatt('w2', 't1', 30)
seedPr('in_1', { art: 'aufwand', arbeitsblattIds: ['w1'] }) // Entwurf
seedPr('in_2') // bei Stripe storniert
seedPr('in_3', { aktiv: false }) // zurückgenommen (inaktiv)
seedPr('in_4') // offen
seedPr('in_5') // überfällig
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 10000 }))
st.invoices.set('in_2', stripeInvoice('in_2', { status: 'void', total: 7000, number: 'WEBKLAR-0002' }))
st.invoices.set('in_3', stripeInvoice('in_3', { status: 'void', total: 9000, number: 'WEBKLAR-0003' }))
st.invoices.set('in_4', stripeInvoice('in_4', { status: 'open', total: 3000, number: 'WEBKLAR-0004' }))
st.invoices.set('in_5', stripeInvoice('in_5', { status: 'open', total: 2000, number: 'WEBKLAR-0005', due: '2020-01-01' }))
const result = await projektkontenFuerKunde('c1')
const [konto] = result.konten
assert.deepEqual([konto.berechnetCents, konto.bezahltCents], [5000, 0])
assert.deepEqual([konto.zeit.minuten, konto.zeit.offeneMinuten], [90, 30])
assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
assert.equal(result.stripeOk, true)
})
test('Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden ohne Zuordnung, ohne Abo, Entwürfe, Stornos und fremde Kunden', async () => {
seedProjekt('p1')
seedPr('in_16', { projektId: 'p1' })
st.invoices.set('in_10', stripeInvoice('in_10', { status: 'open', total: 12000, number: 'WEBKLAR-0010' }))
st.invoices.set('in_11', stripeInvoice('in_11', { status: 'paid', total: 8000, paid: 8000, number: 'WEBKLAR-0011' }))
st.invoices.set('in_12', stripeInvoice('in_12', { status: 'draft', total: 5000 }))
st.invoices.set('in_13', stripeInvoice('in_13', { status: 'void', total: 4000, number: 'WEBKLAR-0013' }))
st.invoices.set('in_14', stripeInvoice('in_14', { status: 'paid', total: 2000, paid: 2000, number: 'WEBKLAR-0014', abo: true }))
st.invoices.set('in_15', stripeInvoice('in_15', { status: 'open', total: 7000, number: 'WEBKLAR-0015', kunde: 'c2' }))
st.invoices.set('in_16', stripeInvoice('in_16', { status: 'open', total: 3000, number: 'WEBKLAR-0016' }))
const result = await projektkontenFuerKunde('c1')
assert.deepEqual(result.ohneProjekt, { berechnetCents: 20000, bezahltCents: 8000, anzahl: 2 })
assert.equal(result.konten[0].berechnetCents, 3000)
})
test('Ohne Projekt fängt auch Projektrechnungen auf, deren Projekt es nicht mehr gibt', async () => {
seedPr('in_20', { projektId: 'geloescht' })
st.invoices.set('in_20', stripeInvoice('in_20', { status: 'open', total: 6000, number: 'WEBKLAR-0020' }))
const result = await projektkontenFuerKunde('c1')
assert.deepEqual(result.konten, [])
assert.deepEqual(result.ohneProjekt, { berechnetCents: 6000, bezahltCents: 0, anzahl: 1 })
})
test('Eine Rechnung, die der Zwischenspeicher nicht kennt, wird einzeln gelesen und zählt', async () => {
seedProjekt('p1')
seedPr('in_1', { projektId: 'p1' })
await listInvoices() // Zwischenspeicher (noch ohne in_1) füllen
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'paid', total: 50000, paid: 50000, number: 'WEBKLAR-0001' }))
const result = await projektkontenFuerKunde('c1')
assert.deepEqual([result.stripeOk, result.konten[0].berechnetCents, result.konten[0].bezahltCents], [true, 50000, 50000])
assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
})
test('Stripe-Ausfall: stripeOk false, Beträge 0, Zeit und Vereinbartes bleiben', async () => {
mock.method(console, 'warn', () => {})
seedTicket('t1', '100001')
seedProjekt('p1', { ticketId: 't1' })
seedBlatt('w1', 't1', 60)
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
seedPr('in_1', { projektId: 'p1', auftragId: 'a1', art: 'anzahlung' })
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' })
const result = await projektkontenFuerKunde('c1')
assert.equal(result.stripeOk, false)
assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
const [konto] = result.konten
assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents, konto.vereinbart.festpreisCents], [0, 0, null, 80000])
assert.deepEqual(konto.zeit, { minuten: 60, aboMinuten: 0, offeneMinuten: 60, satzCents: 10000, wertCents: 10000, offenerWertCents: 10000 })
})
test('Zeitwert im Konto: je Berliner Tag gerundet, zum Satz der Aufwands-AB; der offene Wert ebenso', async () => {
seedTicket('t1', '100001')
seedProjekt('p1', { ticketId: 't1' })
seedBlatt('w1', 't1', 15, { startDate: '05.10.2026', endDate: '05.10.2026' })
seedBlatt('w2', 't1', 15, { startDate: '06.10.2026', endDate: '06.10.2026' })
seedBlatt('w3', 't1', 15, { startDate: '06.10.2026', endDate: '06.10.2026' })
seedBlatt('w4', 't1', 15, { startDate: '07.10.2026', endDate: '07.10.2026' })
seedAuftrag('a1', { projektId: 'p1', art: 'aufwand', summeCents: null, positionenJson: '[]', stundensatzCents: 9990, schaetzungMinuten: 300 })
seedPr('in_1', { projektId: 'p1', art: 'aufwand', arbeitsblattIds: ['w2'] })
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 2498 }))
const { konten } = await projektkontenFuerKunde('c1')
const [konto] = konten
assert.deepEqual(konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 9990, schaetzungMinuten: 300 } })
assert.equal(konto.offenCents, null)
// 99,90 €/Std.: 15 Minuten = 2498 Cent, 30 Minuten = 4995 Cent. Je Tag: 2498 + 4995 (zwei Blätter am 06.10.) + 2498 = 9991
// (je Blatt wären es 9992, über alle 60 Minuten 9990). Offen ohne w2: drei Tage zu 15 Minuten = 3 × 2498 = 7494 (über 45 Minuten 7493)
assert.deepEqual(konto.zeit, { minuten: 60, aboMinuten: 0, offeneMinuten: 45, satzCents: 9990, wertCents: 9991, offenerWertCents: 7494 })
})
test('Schalter aus: ABs werden nicht gelesen, pilot nur für Pilotkunden', async () => {
seedProjekt('p1')
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
config.auftraege.mode = 'off'
aw.resetListCalls()
let result = await projektkontenFuerKunde('c1')
assert.equal(result.auftraegeAktiv, false)
assert.equal(result.konten[0].vereinbart.festpreisCents, null)
assert.equal(aw.listCalls(config.collections.auftraege).length, 0)
config.auftraege.mode = 'pilot'
config.auftraege.pilotEmails = ['k@example.invalid']
result = await projektkontenFuerKunde('c1')
assert.equal(result.auftraegeAktiv, true)
assert.equal(result.konten[0].vereinbart.festpreisCents, 80000)
assert.equal((await projektkontenFuerKunde('c2')).auftraegeAktiv, false)
})
test('kunde: Firmenname vor Name, Kundenart nur aus der Liste, unbekannter Kunde -> 404', async () => {
aw.seed(config.collections.customers, { $id: 'c5', name: 'Hans Müller', companyName: 'Müller GmbH', email: 'h@example.invalid', kundenart: 'unternehmen' })
aw.seed(config.collections.customers, { $id: 'c6', name: 'Max Muster', companyName: '', email: 'm@example.invalid', kundenart: 'komisch' })
assert.deepEqual((await projektkontenFuerKunde('c5')).kunde, { id: 'c5', name: 'Müller GmbH', kundenart: 'unternehmen' })
assert.deepEqual((await projektkontenFuerKunde('c6')).kunde, { id: 'c6', name: 'Max Muster', kundenart: '' })
assert.deepEqual((await projektkontenFuerKunde('c1')).kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' })
await assert.rejects(projektkontenFuerKunde('gibtsnicht'), { status: 404, message: 'Kunde nicht gefunden.' })
})
// ---------- Projektkonto-Detail (Task 5) ----------
test('Detail: Konto, ABs, Rechnungen (neueste zuerst, Entwürfe dabei, Inaktive nicht) und offene Arbeitsblätter', async () => {
seedTicket('t1', '100001')
seedProjekt('p1', { ticketId: 't1' })
seedBlatt('w1', 't1', 60) // in Rechnung in_2
seedBlatt('w2', 't1', 30)
seedBlatt('w3', 't1', 45, { countsToPlan: true }) // Abo-Zeit
seedBlatt('w4', 't1', 15, { isComment: true }) // Kommentar
seedBlatt('w5', 't1', 15, { startDate: '05.10.2026', endDate: '05.10.2026' })
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
seedAuftrag('a2', { projektId: 'p1', status: 'entwurf', nummer: '', laufnummer: null, gesendetAm: null })
seedPr('in_1', { auftragId: 'a1', art: 'anzahlung', rechnungsnummer: 'WEBKLAR-0001' })
seedPr('in_2', { art: 'aufwand', arbeitsblattIds: ['w1'], rechnungsnummer: 'WEBKLAR-0002' })
seedPr('in_3', { aktiv: false })
seedPr('in_4', { art: 'festbetrag' })
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'open', total: 40000, number: 'WEBKLAR-0001', created: '2026-10-01' }))
st.invoices.set('in_2', stripeInvoice('in_2', { status: 'paid', total: 10000, paid: 10000, number: 'WEBKLAR-0002', created: '2026-10-03' }))
st.invoices.set('in_3', stripeInvoice('in_3', { status: 'void', total: 5000, number: 'WEBKLAR-0003', created: '2026-10-02' }))
st.invoices.set('in_4', stripeInvoice('in_4', { status: 'draft', total: 5000, created: '2026-10-04' }))
const detail = await projektkontoDetail('p1')
assert.deepEqual(Object.keys(detail).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk'])
assert.equal(detail.stripeOk, true)
const { konto } = detail
assert.equal(konto.projektId, 'p1')
assert.deepEqual(konto.vereinbart, { festpreisCents: 80000, aufwand: null })
assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents], [50000, 10000, 40000])
assert.deepEqual(konto.zeit, { minuten: 105, aboMinuten: 45, offeneMinuten: 45, satzCents: 10000, wertCents: 17500, offenerWertCents: 7500 })
assert.deepEqual(detail.rechnungen, [
{ id: 'in_4', number: '', status: 'entwurf', createdDate: '2026-10-04', totalCents: 5000, paidCents: 0, art: 'festbetrag', auftragId: '' },
{ id: 'in_2', number: 'WEBKLAR-0002', status: 'bezahlt', createdDate: '2026-10-03', totalCents: 10000, paidCents: 10000, art: 'aufwand', auftragId: '' },
{ id: 'in_1', number: 'WEBKLAR-0001', status: 'offen', createdDate: '2026-10-01', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1' },
])
// sortiert nach Datum (w5 am 05.10., w2 am 06.10.); Abo-Zeit, Kommentar und Berechnetes fehlen
assert.deepEqual(detail.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w5', 'w2'])
assert.deepEqual(detail.auftraege.map((auftrag) => [auftrag.id, auftrag.status]).sort(), [['a1', 'versendet'], ['a2', 'entwurf']])
for (const auftrag of detail.auftraege) {
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.ok(!(feld in auftrag), `${feld} darf nicht in der Antwort stehen`)
}
})
test('Detail: unbekanntes Projekt und Projekt ohne Kunde -> 404', async () => {
seedProjekt('p9', { customerId: '' })
await assert.rejects(projektkontoDetail('gibtsnicht'), { status: 404, message: 'Projekt nicht gefunden.' })
await assert.rejects(projektkontoDetail('p9'), { status: 404, message: 'Projekt nicht gefunden.' })
})
test('Detail bei Stripe-Ausfall: stripeOk false, keine Rechnungen, Beträge 0, Arbeitsblätter und ABs bleiben', async () => {
mock.method(console, 'warn', () => {})
seedTicket('t1', '100001')
seedProjekt('p1', { ticketId: 't1' })
seedBlatt('w1', 't1', 60)
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
seedPr('in_1', { auftragId: 'a1', art: 'anzahlung' })
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' })
const detail = await projektkontoDetail('p1')
assert.equal(detail.stripeOk, false)
assert.deepEqual(detail.rechnungen, [])
assert.deepEqual([detail.konto.berechnetCents, detail.konto.bezahltCents, detail.konto.offenCents], [0, 0, null])
assert.deepEqual(detail.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1'])
assert.deepEqual(detail.auftraege.map((auftrag) => auftrag.id), ['a1'])
})
test('Detail bei ausgeschaltetem Schalter: keine ABs, Vereinbart leer, ABs werden nicht gelesen', async () => {
seedProjekt('p1')
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
config.auftraege.mode = 'off'
aw.resetListCalls()
const detail = await projektkontoDetail('p1')
assert.deepEqual(detail.auftraege, [])
assert.equal(detail.konto.vereinbart.festpreisCents, null)
assert.equal(aw.listCalls(config.collections.auftraege).length, 0)
})
// ---------- Abzeichen "abgerechnet" (Task 6) ----------
test('abgerechnetFuerTicket: Rechnungsnummer oder Entwurf, nur Blätter dieses Tickets, nur aktive Projektrechnungen des Kunden', async () => {
seedTicket('t1', '100001')
seedTicket('t2', '100002')
seedBlatt('w1', 't1', 60)
seedBlatt('w2', 't1', 30)
seedBlatt('w3', 't1', 15)
seedBlatt('w9', 't2', 45)
seedPr('in_1', { rechnungsnummer: 'WEBKLAR-0007', arbeitsblattIds: ['w1'] })
seedPr('in_2', { rechnungsnummer: '', arbeitsblattIds: ['w2', 'w9'] }) // Entwurf, ein Blatt gehört zu t2
seedPr('in_3', { rechnungsnummer: 'WEBKLAR-0003', arbeitsblattIds: ['w3'], aktiv: false }) // zurückgenommen
seedPr('in_4', { kundenId: 'c2', rechnungsnummer: 'WEBKLAR-0004', arbeitsblattIds: ['w1'] }) // anderer Kunde
assert.deepEqual(await abgerechnetFuerTicket('t1'), { w1: 'WEBKLAR-0007', w2: 'Entwurf' })
assert.deepEqual(await abgerechnetFuerTicket('t2'), { w9: 'Entwurf' })
})
test('abgerechnetFuerTicket: unbekanntes Ticket, Ticket ohne Kunde oder nichts abgerechnet -> leer', async () => {
seedTicket('t1', '100001')
seedTicket('t3', '100003', '')
seedBlatt('w1', 't1', 60)
assert.deepEqual(await abgerechnetFuerTicket('gibtsnicht'), {})
assert.deepEqual(await abgerechnetFuerTicket('t3'), {})
assert.deepEqual(await abgerechnetFuerTicket('t1'), {})
})