diff --git a/server/routes/admin/projektkonten.js b/server/routes/admin/projektkonten.js index 2c51ca8..ee7351d 100644 --- a/server/routes/admin/projektkonten.js +++ b/server/routes/admin/projektkonten.js @@ -1,11 +1,38 @@ /** - * Gerüst von Strang L0: Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router). - * L1 ergänzt GET / (?kunde=) und GET /:projektId. + * Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router). + * GET /?kunde= Projektkonten eines Kunden samt Zeile "Ohne Projekt" + * GET /:projektId Konto, ABs, Rechnungen und offene Arbeitsblaetter eines Projekts + * Nur lesen (Strang L1). */ import { Router } from 'express' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { sendeFehler } from '../../services/auftraege/fehler.js' +import { projektkontenFuerKunde, projektkontoDetail } from '../../services/auftraege/projektkonto.js' const router = Router() router.use(requireAppwriteStaff) +// Appwrite-UID: erst pruefen, dann an Appwrite geben +const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +router.get('/', async (req, res) => { + const kunde = req.query.kunde + if (typeof kunde !== 'string' || !KENNUNG.test(kunde)) return res.status(400).json({ error: 'Kunde fehlt oder ist ungültig.' }) + try { + res.json(await projektkontenFuerKunde(kunde)) + } catch (err) { + sendeFehler(res, 'Projektkonten laden', err) + } +}) + +router.get('/:projektId', async (req, res) => { + const { projektId } = req.params + if (!KENNUNG.test(projektId)) return res.status(404).json({ error: 'Projekt nicht gefunden.' }) + try { + res.json(await projektkontoDetail(projektId)) + } catch (err) { + sendeFehler(res, 'Projektkonto laden', err) + } +}) + export default router diff --git a/server/routes/team/arbeitsblaetter.js b/server/routes/team/arbeitsblaetter.js index 675a291..0da67dd 100644 --- a/server/routes/team/arbeitsblaetter.js +++ b/server/routes/team/arbeitsblaetter.js @@ -1,9 +1,25 @@ /** - * Gerüst von Strang L0: Arbeitsblätter fürs Ticketsystem. Mount: /api/team/arbeitsblaetter, in team/index.js hinter - * requireStaffJwt (jeder Mitarbeiter, daher kein eigener Schutz). L1 ergänzt GET /abgerechnet (?ticketId=). + * Arbeitsblaetter fuers Ticketsystem. Mount: /api/team/arbeitsblaetter, in team/index.js hinter requireStaffJwt (jeder + * Mitarbeiter, daher kein eigener Schutz). + * GET /abgerechnet?ticketId= { abgerechnet: { : '' | 'Entwurf' } }, ohne Betraege */ import { Router } from 'express' +import { sendeFehler } from '../../services/auftraege/fehler.js' +import { abgerechnetFuerTicket } from '../../services/auftraege/projektkonto.js' const router = Router() +// Appwrite-UID: erst pruefen, dann an Appwrite geben +const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +router.get('/abgerechnet', async (req, res) => { + const ticketId = req.query.ticketId + if (typeof ticketId !== 'string' || !KENNUNG.test(ticketId)) return res.status(400).json({ error: 'ticketId fehlt oder ist ungültig.' }) + try { + res.json({ abgerechnet: await abgerechnetFuerTicket(ticketId) }) + } catch (err) { + sendeFehler(res, 'Abgerechnete Arbeitsblätter laden', err) + } +}) + export default router diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js new file mode 100644 index 0000000..542048e --- /dev/null +++ b/server/services/auftraege/projektkonto.js @@ -0,0 +1,325 @@ +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 { auftragFuerTeam } from './auftragSicht.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 + * dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben. + */ + +// Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie +const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']) + +const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0) +const zeitpunkt = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0 +const juengsteZuerst = (a, b) => zeitpunkt(b) - zeitpunkt(a) || String(b.id).localeCompare(String(a.id)) +const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60) +// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung +// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander +const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0) +const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten) + +/** + * Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit + * der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der + * Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe); + * stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null). + */ +export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) { + const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)) + const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis') + const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id)) + const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null + const aufwandAb = gueltig.filter((auftrag) => auftrag.art === 'aufwand').sort(juengsteZuerst)[0] ?? null + const aufwand = aufwandAb + ? { + stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS, + schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null, + } + : null + const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS + + let berechnetCents = 0 + let bezahltCents = 0 + let aufFestpreisCents = 0 + if (stripeOk) { + for (const { invoice, auftragId } of rechnungen) { + if (!FINALISIERT.has(invoice.status)) continue + berechnetCents += invoice.totalCents + bezahltCents += invoice.paidCents + // nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene + if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents + } + } + const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents) + + let minuten = 0 + let aboMinuten = 0 + let offeneMinuten = 0 + const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete) + const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag '' + for (const blatt of blaetter) { + if (abrechenbar(blatt)) { + minuten += blatt.minuten + addiere(minutenJeTag, blatt.datum, blatt.minuten) + if (!abgerechnet.has(blatt.id)) { + offeneMinuten += blatt.minuten + addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten) + } + } else if (blatt.aboZeit && !blatt.kommentar) { + aboMinuten += blatt.minuten + } + } + + return { + vereinbart: { festpreisCents, aufwand }, + berechnetCents, + bezahltCents, + offenCents, + zeit: { + minuten, + aboMinuten, + offeneMinuten, + satzCents, + wertCents: tageswert(minutenJeTag, satzCents), + offenerWertCents: tageswert(offeneMinutenJeTag, satzCents), + }, + } +} + +/** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */ +export function rechneOhneProjekt(rechnungen) { + const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status)) + return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length } +} + +// Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js +// haelt seine Optionen privat, daher hier eine eigene Konstante +const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 } +const EINZELN_PARALLEL = 10 +// Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_) passen auch +const STRIPE_RECHNUNG = /^in_\w{1,33}$/ + +/** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */ +async function leseEinzeln(invoiceId) { + try { + const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN) + return mapInvoice(raw, { today: berlinDate() }) + } catch (err) { + if (err?.statusCode === 404 || err?.code === 'resource_missing') return null + throw err + } +} + +/** + * Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden- + * Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig. + * Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status. + */ +export async function ladeStripeRechnungen(projektRechnungen) { + try { + const alle = await listInvoices() + const nachId = new Map(alle.map((invoice) => [invoice.id, invoice])) + const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id)) + for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) { + const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln)) + for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice) + } + return { ok: true, alle, nachId } + } catch (err) { + console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '') + 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 } +} + +const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id) + +/** + * Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht + * abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false. + */ +export async function projektkontoDetail(projektId) { + const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt') + if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.') + const kundenId = projekt.customerId + const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde') + const aktiv = auftraegeAktiv(kundeDoc) + const [abs, prs] = await Promise.all([ + aktiv ? listAuftraege({ kundenId, projektId }) : [], + listProjektRechnungen({ kundenId, projektId }), + ]) + const stripeDaten = await ladeStripeRechnungen(prs) + const zeitquelle = zeitquelleVon(projekt, abs) + const zeit = await ladeZeitDaten(kundenId, zeitquelle) + const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) + return { + konto, + auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)), + rechnungen: rechnungen + .map(({ art, auftragId, invoice }) => ({ + id: invoice.id, + number: invoice.number, + status: invoice.status, + createdDate: invoice.createdDate, + totalCents: invoice.totalCents, + paidCents: invoice.paidCents, + art, + auftragId, + })) + .sort(neuesteZuerst), + offeneArbeitsblaetter: blaetter + .filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id)) + .sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)), + stripeOk: stripeDaten.ok, + } +} + +/** + * Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer + * oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden. + */ +export async function abgerechnetFuerTicket(ticketId) { + let ticket + try { + ticket = await getDocument(config.collections.workorders, ticketId) + } catch (err) { + if (err?.status === 404) return {} + throw err + } + if (!ticket.customerId) return {} + const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId) + if (abgerechnet.size === 0) return {} + const ergebnis = {} + for (const blatt of await ladeArbeitsblaetter([ticketId])) { + const eintrag = abgerechnet.get(blatt.id) + if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf' + } + return ergebnis +} diff --git a/test/auftraegeProjektkonto.test.js b/test/auftraegeProjektkonto.test.js new file mode 100644 index 0000000..f80244d --- /dev/null +++ b/test/auftraegeProjektkonto.test.js @@ -0,0 +1,687 @@ +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'), {}) +}) diff --git a/test/auftraegeRoutesProjektkonten.test.js b/test/auftraegeRoutesProjektkonten.test.js new file mode 100644 index 0000000..d20cb4e --- /dev/null +++ b/test/auftraegeRoutesProjektkonten.test.js @@ -0,0 +1,172 @@ +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' + +// Routen des Projektkontos (Strang L1): Admin (requireAppwriteStaff) und Team (requireStaffJwt). Muster: financeRoutesBank.test.js +// (JWT-Köpfe des Appwrite-Nachbaus), shopTeam.test.js (Team-Eingang) und den Gerüsttest von L0 (auftraegeGeruest.test.js). +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 { startTestApp, rawGet } = await import('./support/testApp.js') +const { default: projektkontenRoutes } = await import('../server/routes/admin/projektkonten.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') + +const app = await startTestApp({ '/api/admin/finanzen/projektkonten': projektkontenRoutes, '/api/team': teamRoutes }) +after(() => 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 ohneLabel = { headers: { Authorization: 'Bearer jwt-kunde' } } +const BASIS = '/api/admin/finanzen/projektkonten' + +const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen'] +async function reset() { + mock.restoreAll() + invalidateFinanceCache() + st.calls.length = 0 + st.invoices.clear() + 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) + +const blatt = (id, minuten) => ({ + $id: id, wsid: `10000${id.slice(1)}`, woid: '100001', workorderId: 't1', employeeId: 'e1', employeeName: 'Kenso Grimm', employeeShort: 'KNSO', + serviceType: 'Remote', totalTime: minuten, supporters: '', startDate: '06.10.2026', endDate: '06.10.2026', details: 'Startseite', + isComment: false, countsToPlan: false, +}) +function seedBasis() { + aw.seed(config.collections.workorders, { $id: 't1', woid: '100001', customerId: 'c1', topic: 'Website', status: 'open' }) + aw.seed(config.collections.websiteProjects, { $id: 'p1', customerId: 'c1', projectName: 'Website Müller', ticketId: 't1', kind: 'website', archived: false }) + aw.seed(config.collections.worksheets, blatt('w1', 60)) + aw.seed(config.collections.worksheets, blatt('w2', 30)) +} + +test('Projektkonto-Routen: ohne JWT 401, ohne Admin-Label 403', async () => { + for (const path of [`${BASIS}?kunde=c1`, `${BASIS}/p1`]) { + assert.equal((await rawGet(path)).status, 401, `${path} ohne JWT`) + assert.equal((await rawGet(path, { headers: { Authorization: 'Bearer jwt-unbekannt' } })).status, 401, `${path} mit falschem JWT`) + assert.equal((await rawGet(path, staff)).status, 403, `${path} als Mitarbeiter ohne Admin-Label`) + assert.equal((await rawGet(path, ohneLabel)).status, 403, `${path} als Konto ohne Label`) + } +}) + +test('GET ?kunde=: ohne, leer, doppelt oder mit unmöglicher Kennung -> 400', async () => { + for (const query of ['', '?kunde=', '?kunde=c1&kunde=c2', '?kunde=../x', `?kunde=${'c'.repeat(37)}`, '?kunde[a]=b']) { + const res = await rawGet(`${BASIS}${query}`, admin) + assert.equal(res.status, 400, query) + assert.deepEqual(res.body, { error: 'Kunde fehlt oder ist ungültig.' }, query) + } +}) + +test('GET ?kunde= -> 404', async () => { + const res = await rawGet(`${BASIS}?kunde=gibtsnicht`, admin) + assert.equal(res.status, 404) + assert.deepEqual(res.body, { error: 'Kunde nicht gefunden.' }) +}) + +test('GET ?kunde=c1: Projektkonten mit Zeit, Antwort mit genau den vereinbarten Schlüsseln', async () => { + seedBasis() + const res = await rawGet(`${BASIS}?kunde=c1`, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['auftraegeAktiv', 'konten', 'kunde', 'ohneProjekt', 'stripeOk']) + assert.deepEqual(res.body.kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' }) + assert.deepEqual(res.body.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) + assert.equal(res.body.stripeOk, true) + assert.equal(res.body.auftraegeAktiv, true) + assert.deepEqual(res.body.konten.map((konto) => [konto.projektId, konto.name, konto.projektTyp, konto.zeit.minuten]), [['p1', 'Website Müller', 'Website', 90]]) + assert.deepEqual(res.body.konten[0].tickets, [{ id: 't1', woid: '100001' }]) +}) + +test('GET /:projektId: Konto, ABs, Rechnungen und offene Arbeitsblätter; unbekannt und unmöglich -> 404', async () => { + seedBasis() + const res = await rawGet(`${BASIS}/p1`, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk']) + assert.equal(res.body.konto.projektId, 'p1') + assert.deepEqual(res.body.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1', 'w2']) + for (const id of ['gibtsnicht', '%20x']) { + const fehler = await rawGet(`${BASIS}/${id}`, admin) + assert.equal(fehler.status, 404, id) + assert.deepEqual(fehler.body, { error: 'Projekt nicht gefunden.' }, id) + } +}) + +test('Stripe-Ausfall: beide Routen antworten 200 mit stripeOk false', async () => { + mock.method(console, 'warn', () => {}) + seedBasis() + st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) + const liste = await rawGet(`${BASIS}?kunde=c1`, admin) + assert.equal(liste.status, 200) + assert.equal(liste.body.stripeOk, false) + assert.equal(liste.body.konten[0].zeit.minuten, 90) + st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) + const detail = await rawGet(`${BASIS}/p1`, admin) + assert.equal(detail.status, 200) + assert.equal(detail.body.stripeOk, false) +}) + +test('Fehler in Appwrite: 500 mit fester Meldung, ohne Text von Appwrite', async () => { + mock.method(console, 'error', () => {}) + seedBasis() + aw.failNext({ collection: config.collections.websiteProjects, method: 'GET', status: 500, message: 'geheim-appwrite' }) + const res = await rawGet(`${BASIS}?kunde=c1`, admin) + assert.equal(res.status, 500) + assert.deepEqual(res.body, { error: 'Interner Fehler.' }) +}) + +// ---------- Team-Route (Task 8) ---------- + +const ABGERECHNET = '/api/team/arbeitsblaetter/abgerechnet' +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, + }) + +test('Abzeichen-Route: ohne JWT 401, Konto ohne Label 403, Mitarbeiter und Admin dürfen', async () => { + const path = `${ABGERECHNET}?ticketId=t1` + assert.equal((await rawGet(path)).status, 401) + assert.equal((await rawGet(path, ohneLabel)).status, 403) + assert.equal((await rawGet(path, staff)).status, 200) + assert.equal((await rawGet(path, admin)).status, 200) +}) + +test('Abzeichen-Route: ticketId fehlt, ist leer, doppelt oder unmöglich -> 400', async () => { + for (const query of ['', '?ticketId=', '?ticketId=t1&ticketId=t2', '?ticketId=../x', `?ticketId=${'t'.repeat(37)}`, '?ticketId[a]=b']) { + const res = await rawGet(`${ABGERECHNET}${query}`, staff) + assert.equal(res.status, 400, query) + assert.deepEqual(res.body, { error: 'ticketId fehlt oder ist ungültig.' }, query) + } +}) + +test('Abzeichen-Route: { abgerechnet: { Blatt: Rechnungsnummer | Entwurf } } ohne Beträge, unbekanntes Ticket leer', async () => { + seedBasis() + seedPr('in_1', { rechnungsnummer: 'WEBKLAR-0007', arbeitsblattIds: ['w1'] }) + seedPr('in_2', { rechnungsnummer: '', arbeitsblattIds: ['w2'] }) + const res = await rawGet(`${ABGERECHNET}?ticketId=t1`, staff) + assert.equal(res.status, 200) + assert.deepEqual(res.body, { abgerechnet: { w1: 'WEBKLAR-0007', w2: 'Entwurf' } }) + assert.ok(!/cents|betrag|in_1|in_2/i.test(JSON.stringify(res.body)), 'weder Beträge noch Rechnungs-IDs') + const unbekannt = await rawGet(`${ABGERECHNET}?ticketId=gibtsnicht`, staff) + assert.equal(unbekannt.status, 200) + assert.deepEqual(unbekannt.body, { abgerechnet: {} }) +})