diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js index 2a2eab3..ea67e51 100644 --- a/server/services/auftraege/projektkonto.js +++ b/server/services/auftraege/projektkonto.js @@ -7,6 +7,7 @@ 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' @@ -257,3 +258,45 @@ export async function projektkontenFuerKunde(kundenId) { 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, + } +} diff --git a/test/auftraegeProjektkonto.test.js b/test/auftraegeProjektkonto.test.js index 5bb2ba2..4914fc2 100644 --- a/test/auftraegeProjektkonto.test.js +++ b/test/auftraegeProjektkonto.test.js @@ -584,3 +584,78 @@ test('kunde: Firmenname vor Name, Kundenart nur aus der Liste, unbekannter Kunde 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) +})