Projektkonto: Konten je Kunde (Projektwahl, Zeitquelle, Zeitwert je Tag, Ohne Projekt, Schalter, Stripe-Ausfall)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10679ae726
commit
8032aa67c6
2 files changed
+416
-2
No files matched your search
@@ -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.' })
|
||||
})
|
||||
Reference in new issue
Block a user