From 8032aa67c67f1f588a74b7a5089cdc789a018a8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:01 +0000 Subject: [PATCH] Projektkonto: Konten je Kunde (Projektwahl, Zeitquelle, Zeitwert je Tag, Ohne Projekt, Schalter, Stripe-Ausfall) Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/projektkonto.js | 128 +++++++++- test/auftraegeProjektkonto.test.js | 290 ++++++++++++++++++++++ 2 files changed, 416 insertions(+), 2 deletions(-) diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js index a344ce4..2a2eab3 100644 --- a/server/services/auftraege/projektkonto.js +++ b/server/services/auftraege/projektkonto.js @@ -1,8 +1,15 @@ -import { STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js' -import { abrechenbar } from './arbeitsblaetter.js' +import { config } from '../../config.js' +import { Query, getDocument, listDocuments } from '../appwriteAdmin.js' import { getStripe } from '../stripeBilling.js' import { berlinDate } from '../finance/dates.js' import { listInvoices, mapInvoice } from '../finance/stripeFinance.js' +import { kindLabel } from '../projectKinds.js' +import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js' +import { AuftragError } from './fehler.js' +import { auftraegeAktiv } from './schalter.js' +import { listAuftraege } from './auftragStore.js' +import { listProjektRechnungen } from './projektRechnungenStore.js' +import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.js' /** * Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen @@ -133,3 +140,120 @@ export async function ladeStripeRechnungen(projektRechnungen) { return { ok: false, alle: [], nachId: new Map() } } } + +/** Dokument lesen; unbekannt -> AuftragError 404 " nicht gefunden." */ +async function ladeDokument(collection, id, was) { + try { + return await getDocument(collection, id) + } catch (err) { + if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`) + throw err + } +} + +const kundeAnsicht = (doc) => ({ + id: doc.$id, + name: String(doc.companyName || '').trim() || String(doc.name || '').trim(), + kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '', +}) + +/** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */ +async function ladeTickets(ids) { + const nachId = new Map() + for (let i = 0; i < ids.length; i += 100) { + const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)]) + for (const ticket of seite) nachId.set(ticket.$id, ticket) + } + return nachId +} + +/** + * Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je + * Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt. + */ +async function ladeZeitDaten(kundenId, ticketIds) { + if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() } + const [tickets, blaetter, abgerechnet] = await Promise.all([ + ladeTickets(ticketIds), + ladeArbeitsblaetter(ticketIds), + abgerechneteArbeitsblaetter(kundenId), + ]) + const nachTicket = new Map() + for (const blatt of blaetter) { + if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, []) + nachTicket.get(blatt.ticketId).push(blatt) + } + return { tickets, nachTicket, abgerechnet } +} + +/** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */ +const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))] + +/** + * Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von + * ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail. + */ +function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) { + const rechnungen = prs + .map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) })) + .filter((eintrag) => eintrag.invoice) + const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? []) + return { + konto: { + projektId: projekt.$id, + name: projekt.projectName || '', + projektTyp: kindLabel(projekt.kind), + tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })), + zeitquelle, + ...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }), + }, + rechnungen, + blaetter, + } +} + +/** + * Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle + * Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus + * VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB). + */ +export async function projektkontenFuerKunde(kundenId) { + const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde') + const aktiv = auftraegeAktiv(kundeDoc) + const [projekte, auftraege, prs] = await Promise.all([ + listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]), + aktiv ? listAuftraege({ kundenId }) : [], + listProjektRechnungen({ kundenId }), + ]) + const stripeDaten = await ladeStripeRechnungen(prs) + + const mitGeld = new Set([ + ...prs.map((eintrag) => eintrag.projektId), + ...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId), + ]) + const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id))) + const eintraege = sichtbar.map((projekt) => { + const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id) + return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) } + }) + const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))]) + const konten = eintraege.map( + ({ projekt, abs, zeitquelle }) => + baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto + ) + + // Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen) + const kandidaten = new Map() + for (const invoice of stripeDaten.alle) { + if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice) + } + for (const eintrag of prs) { + const invoice = stripeDaten.nachId.get(eintrag.invoiceId) + if (invoice) kandidaten.set(invoice.id, invoice) + } + const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id)) + const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId)) + const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id))) + + return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok } +} diff --git a/test/auftraegeProjektkonto.test.js b/test/auftraegeProjektkonto.test.js index e55f547..5bb2ba2 100644 --- a/test/auftraegeProjektkonto.test.js +++ b/test/auftraegeProjektkonto.test.js @@ -294,3 +294,293 @@ test('ladeStripeRechnungen: unmögliche Rechnungs-IDs gehen nie an Stripe', asyn 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.' }) +})