Files
Webklar-Kundenbereich/test/auftraegeAblauf.test.js
T

1140 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Durchlauf über alle neuen Endpunkte von „Aufträge und Rechnungen“ (Strang Z, Task Z1.3).
// Echter Server-Code (Router, Dienste, pdfkit); Nachbauten für Appwrite, Stripe und SMTP, nichts verlässt den Rechner.
// Aufruf: cd <Worktree> && free -m && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js
// Jeder Test hat eigene Kunden und Projekte; die AB-Nummern laufen über alle Tests (D15 prüft sie am Ende).
// Der Vertrag entscheidet (Übersicht V1–V8, Spec). Was der Vertrag offen lässt, steht als „Annahme“ im Kommentar.
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { inspect } from 'node:util'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js'
// --- Umgebung ------------------------------------------------------------------------------------------------
// Ablage der AB-PDFs in einem Wegwerf-Ordner und Schalter an (Vorgabe wäre off): config.js liest beides beim ersten Import.
const ABLAGE = fs.mkdtempSync(path.join(os.tmpdir(), 'auftraege-ablauf-'))
process.env.AUFTRAEGE_UPLOAD_DIR = ABLAGE
process.env.AUFTRAEGE_ENABLED = 'on'
// Alles, was dieser Prozess ins Protokoll schreibt, wird gesammelt: D16 prüft, dass kein Token und keine Client-Adresse darin steht.
const PROTOKOLL = []
for (const stufe of ['log', 'info', 'warn', 'error']) {
const original = console[stufe].bind(console)
console[stufe] = (...teile) => {
PROTOKOLL.push(teile.map((teil) => (typeof teil === 'string' ? teil : inspect(teil, { depth: 5 }))).join(' '))
original(...teile)
}
}
const felder = (liste) => liste.map((attribut) => attribut.key)
const datumsfelder = (liste) => liste.filter((attribut) => attribut.type === 'datetime').map((attribut) => attribut.key)
const aw = await startAppwriteMock({
schema: {
auftraege: felder(AUFTRAEGE_COLLECTIONS.auftraege.attributes),
projektRechnungen: felder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes),
financeSettings: [...new Set([...schemaKeys('financeSettings'), ...felder(AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings)])],
},
datetimes: {
auftraege: datumsfelder(AUFTRAEGE_COLLECTIONS.auftraege.attributes),
projektRechnungen: datumsfelder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes),
},
defaultLimit: 25, // wie Appwrite: ohne limit() kommen nur 25 Dokumente zurück (die Termine-Tests prüfen es ebenso)
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send, sessionCookie } = await import('./support/testApp.js')
const { kundenansichtCookie } = await import('./support/fixtures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { customerStripeInvoice } = await import('../server/services/customerInvoices.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { addDays, berlinDate, formatDe } = await import('../server/services/finance/dates.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { AGB_FASSUNG, SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js')
const { formatNummer } = await import('../server/services/auftraege/nummern.js')
const { hashToken } = await import('../server/services/auftraege/token.js')
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { default: adminProjektkontenRoutes } = await import('../server/routes/admin/projektkonten.js')
const { default: adminAuftraegeRoutes } = await import('../server/routes/admin/auftraege.js')
const { default: adminFinanzenRoutes } = await import('../server/routes/admin/finanzen.js')
const { default: publicAuftragRoutes } = await import('../server/routes/auftrag.js')
const { default: kundeAuftraegeRoutes } = await import('../server/routes/auftraegeKunde.js')
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
// Eingebunden wie in server/index.js (V5 „Einbindung“): Wächter der Kundenansicht zuerst, spezifische Admin-Pfade vor dem Finanzen-Router
const app = await startTestApp({
'/': kundenansichtGuard,
'/api/admin/finanzen/projektkonten': adminProjektkontenRoutes,
'/api/admin/finanzen/auftraege': adminAuftraegeRoutes,
'/api/admin/finanzen': adminFinanzenRoutes,
'/api/auftrag': publicAuftragRoutes,
'/api/auftraege': kundeAuftraegeRoutes,
'/api/abo': aboRoutes,
'/api/team': teamRoutes,
})
const mails = mailSink()
setMailTransportForTests(mails)
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close(), st.close()])
// nur der selbst angelegte Wegwerf-Ordner, nie ein Pfad von außen
if (ABLAGE.startsWith(`${os.tmpdir()}${path.sep}auftraege-ablauf-`)) fs.rmSync(ABLAGE, { recursive: true, force: true })
})
beforeEach(() => {
resetInvoiceCreateGuardForTests() // 30-Sekunden-Schutz gegen gleiche Rechnungen
invalidateFinanceCache() // Stripe-Zwischenspeicher (60 s)
resetPublicRateLimit() // Widerrufsseite: 10 Erklärungen je Stunde und Adresse
})
// --- Hilfen --------------------------------------------------------------------------------------------------
const ADMIN = { Authorization: 'Bearer jwt-admin' }
const STAFF = { Authorization: 'Bearer jwt-staff' } // Mitarbeiter ohne Admin-Label
const KV = 'a'.repeat(32) // Kennung der Kundenansicht (Kopfzeile X-Kundenansicht)
const UNBEKANNT = 'Dieser Link ist ungültig oder abgelaufen.'
const HEUTE = berlinDate()
const JAHR = Number(HEUTE.slice(0, 4))
const AB = () => config.collections.auftraege
const PR = () => config.collections.projektRechnungen
const ip = (n) => ({ 'x-forwarded-for': `203.0.113.${n}` }) // je Test eine eigene Client-Adresse (Ratenbegrenzung je Adresse)
const sha256 = (daten) => crypto.createHash('sha256').update(daten).digest('hex')
const pause = (ms) => new Promise((fertig) => setTimeout(fertig, ms))
const kundeCookie = (id) => sessionCookie({ customerId: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example` })
const admin = (methode, pfad, body, headers = {}) => send(methode, `/api/admin/finanzen${pfad}`, { body, headers: { ...ADMIN, ...headers } })
function erwarte(res, status, was, meldung) {
assert.equal(res.status, status, `${was}: erwartet ${status}, bekam ${res.status} ${JSON.stringify(res.body)}`)
if (meldung instanceof RegExp) assert.match(String(res.body?.error ?? ''), meldung, `${was}: Meldung`)
else if (typeof meldung === 'string') assert.equal(res.body?.error, meldung, `${was}: Meldung`)
return res.body
}
// Annahme (nicht im Vertrag): Erfolg heißt 200 oder 201
function erfolg(res, was) {
assert.ok(res.status === 200 || res.status === 201, `${was}: erwartet 200 oder 201, bekam ${res.status} ${JSON.stringify(res.body)}`)
return res.body
}
async function holePdf(pfad, { headers = {}, cookie } = {}) {
const res = await fetch(app.url + pfad, { headers: { ...(cookie ? { cookie } : {}), ...headers } })
return { status: res.status, typ: res.headers.get('content-type') || '', bytes: Buffer.from(await res.arrayBuffer()) }
}
const istPdf = (bytes) => bytes.subarray(0, 5).toString('latin1') === '%PDF-' && bytes.length > 1500 && bytes.toString('latin1', Math.max(0, bytes.length - 64)).includes('%%EOF')
const seiten = (bytes) => (bytes.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length
const pdfDateien = () => (fs.existsSync(ABLAGE) ? fs.readdirSync(ABLAGE, { recursive: true }).filter((name) => String(name).endsWith('.pdf')) : [])
const adressen = (feld) => (feld || []).map((eintrag) => eintrag.address)
const findeMail = (vorher, muster) => mails.sent.slice(vorher).find((mail) => muster.test(mail.subject))
async function warteBis(pruefung, { ms = 5000, was = 'Bedingung' } = {}) {
const ende = Date.now() + ms
for (;;) {
const wert = await pruefung()
if (wert) return wert
if (Date.now() > ende) assert.fail(`Zeitüberschreitung beim Warten auf: ${was}`)
await pause(25)
}
}
const GESEHEN = new Set() // alle Token aus Mails und Fehlversuchen: D16 sucht sie in Dokumenten und im Protokoll
const TOKEN_IM_LINK = /\/auftrag\.html#t=([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/ // V6: K31 verlinkt auftrag.html#t=<Token> (Fragment: landet in keinem Server- oder Proxy-Protokoll)
function tokenAus(mail) {
const treffer = String(mail.html).match(TOKEN_IM_LINK) || String(mail.text).match(TOKEN_IM_LINK)
assert.ok(treffer, `kein Bestätigungslink in der Mail "${mail.subject}"`)
GESEHEN.add(treffer[1])
return treffer[1]
}
// V6: K31 geht OHNE BCC an den Kunden (die Mail trägt den Bestätigungslink); das Team bekommt eine eigene Kopie ohne Link (Block teamkopie),
// auch bei Erinnerungen. k31Mails trennt beide.
const kundenAdresse = (kundenId) => `${kundenId}@kunde.example`
function k31Mails(vorher, kundenId) {
const alle = mails.sent.slice(vorher).filter((mail) => /Auftragsbestätigung AB-\d{4}-\d{4}/.test(mail.subject))
const anKunde = alle.filter((mail) => adressen(mail.to).includes(kundenAdresse(kundenId)))
return { anKunde, teamkopien: alle.filter((mail) => !anKunde.includes(mail)) }
}
function pruefeTeamkopie(kundenMail, token, teamkopien, was) {
assert.deepEqual(adressen(kundenMail.bcc), [], `${was}: K31 an den Kunden hat keine BCC-Kopie (V6)`)
assert.ok(teamkopien.length >= 1, `${was}: das Team bekommt eine Kopie der K31`)
const empfaenger = teamkopien.flatMap((mail) => adressen(mail.to))
for (const adresse of config.chatNotify.emails) assert.ok(empfaenger.includes(adresse), `${was}: Teamkopie an ${adresse}`)
for (const kopie of teamkopien) {
const inhalt = `${kopie.html}\n${kopie.text}`
assert.equal(inhalt.includes(token), false, `${was}: die Teamkopie enthält den Token`)
assert.equal(/auftrag\.html[?#]/.test(inhalt), false, `${was}: die Teamkopie enthält einen Bestätigungslink`)
assert.match(kopie.text, /Kopie für das Team/, `${was}: Hinweis „Kopie für das Team“ statt des Knopfes`)
}
}
const stripeAufrufe = (ab, methode, pfad) => st.calls.slice(ab).filter((aufruf) => aufruf.method === methode && (pfad instanceof RegExp ? pfad.test(aufruf.path) : aufruf.path === pfad))
const zeilen = (rechnungId) => st.invoices.get(rechnungId).lines.data
function bezahlen(rechnungId) {
const roh = st.invoices.get(rechnungId)
assert.ok(roh, `Rechnung ${rechnungId} fehlt im Stripe-Nachbau`)
st.invoices.set(rechnungId, {
...roh, status: 'paid', paid: true, amount_paid: roh.total, amount_remaining: 0,
status_transitions: { ...roh.status_transitions, paid_at: Math.floor(Date.now() / 1000) },
})
invalidateFinanceCache()
}
async function rechnung(body, was) {
return erfolg(await admin('POST', '/rechnungen', { daysUntilDue: 14, senden: false, ...body }), was).rechnung
}
async function konto(kundenId, projektId) {
const body = erwarte(await admin('GET', `/projektkonten?kunde=${kundenId}`), 200, `Projektkonten von ${kundenId}`)
const eintrag = body.konten.find((k) => k.projektId === projektId)
assert.ok(eintrag, `Projekt ${projektId} fehlt in den Projektkonten von ${kundenId}`)
return { body, konto: eintrag }
}
const AB_BASIS = {
titel: 'Neue Website', art: 'festpreis', projektId: '',
positionen: [{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 }],
stundensatzCents: null, schaetzungMinuten: null,
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
auftragVom: addDays(HEUTE, -1), auftragWeg: 'telefon', startAm: addDays(HEUTE, 7), fertigBis: addDays(HEUTE, 40), zeitquelle: [],
}
async function entwurf(kundenId, ueberschreibe = {}) {
const auftrag = erfolg(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId, ...ueberschreibe }), `AB-Entwurf für ${kundenId}`).auftrag
assert.equal(auftrag.status, 'entwurf')
assert.equal(auftrag.nummer, '', 'Entwürfe haben keine Nummer')
return auftrag
}
const lies = async (id) => erwarte(await admin('GET', `/auftraege/${id}`), 200, `AB ${id} lesen`).auftrag
async function sende(id, was = 'AB senden') {
const vorher = mails.sent.length
const body = erwarte(await admin('POST', `/auftraege/${id}/senden`), 200, was)
assert.equal(body.mailFehler, false, `${was}: Mail nicht zugestellt`)
const { anKunde, teamkopien } = k31Mails(vorher, body.auftrag.kundenId)
assert.equal(anKunde.length, 1, `${was}: genau eine K31 an den Kunden (${mails.sent.slice(vorher).map((m) => `${m.subject} an ${adressen(m.to)}`).join(' | ')})`)
const token = tokenAus(anKunde[0])
pruefeTeamkopie(anKunde[0], token, teamkopien, was)
return { auftrag: body.auftrag, mail: anKunde[0], token }
}
const ansicht = (token, n) => send('GET', `/api/auftrag/${token}`, { headers: ip(n) })
const bestaetigen = (token, n, body = { bestaetigt: true, fruehbeginn: false }, extra = {}) =>
send('POST', `/api/auftrag/${token}/bestaetigen`, { body, headers: { ...ip(n), ...(extra.headers || {}) }, cookie: extra.cookie })
const vergebeneNummern = () => [...aw.docs(AB()).values()].filter((d) => d.nummer).map((d) => d.nummer).sort()
// --- Testdaten -----------------------------------------------------------------------------------------------
const ADRESSE = { street: 'Hauptstraße 1', postalCode: '10115', city: 'Berlin', country: 'Deutschland' }
const kundendoc = (id, ueberschreibe = {}) => ({
$id: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example`, code: `K-${id}`,
customerStatus: 'active', status: 'active', portalAccessEnabled: true, kundenart: 'unternehmen', ...ADRESSE, ...ueberschreibe,
})
const KUNDEN = {
kfirma: kundendoc('kfirma', { companyName: 'Beispiel GmbH', name: 'Anna Beispiel' }),
kprivat: kundendoc('kprivat', { kundenart: 'privat', name: 'Petra Privat' }),
kfremd: kundendoc('kfremd', { companyName: 'Fremd AG', name: 'Frank Fremd' }),
kluecke: kundendoc('kluecke', { kundenart: '', name: 'Lena Lücke' }),
}
for (const doc of Object.values(KUNDEN)) aw.seed('customers', doc)
const setzeKunde = (id, patch) => aw.seed('customers', { ...KUNDEN[id], ...patch })
function legeProjektAn(id, kundenId, ticketId, woid, name) {
aw.seed('workorders', { $id: ticketId, woid, customerId: kundenId, topic: name, status: 'Open', type: 'Webpage' })
aw.seed('websiteProjects', { $id: id, customerId: kundenId, ticketId, projectName: name, projectType: 'website', status: 'active' })
}
legeProjektAn('pfest', 'kfirma', 'tkfest', '100201', 'Website Beispiel')
legeProjektAn('paufw', 'kfirma', 'tkaufw', '100202', 'Online-Shop Beispiel')
legeProjektAn('pab', 'kfirma', 'tkab', '100203', 'Relaunch Größe & Maße')
legeProjektAn('pstorno', 'kfirma', 'tkstorno', '100204', 'Landingpage Beispiel')
legeProjektAn('pzuordnen', 'kfirma', 'tkzuordnen', '100205', 'Projekt zum Zuordnen')
legeProjektAn('pfremd', 'kfremd', 'tkfremd', '100299', 'Fremdes Projekt')
const blatt = (id, ticketId, ueberschreibe) => ({
$id: id, workorderId: ticketId, woid: ticketId === 'tkaufw' ? '100202' : '100299', serviceType: 'Remote', employeeName: 'Justin Klein',
totalTime: 60, startDate: '06.10.2026', endDate: '06.10.2026', details: 'Arbeit', isComment: false, countsToPlan: false, ...ueberschreibe,
})
const ANDREJ = [{ employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' }]
// Zeit des Projekts „Online-Shop“: 06.10. = 60 + 75 min, 07.10. = 50 → 60 min, 08.10. = 40 + 30 Unterstützung → 75 min; zusammen 270 min = 4,5 Std.
aw.seed('worksheets', blatt('wsA', 'tkaufw', { totalTime: 60, details: 'Startseite' }))
aw.seed('worksheets', blatt('wsB', 'tkaufw', { totalTime: 75, details: 'Kontaktformular' }))
aw.seed('worksheets', blatt('wsC', 'tkaufw', { totalTime: 50, startDate: '07.10.2026', endDate: '07.10.2026', details: 'Impressum und Datenschutz' }))
aw.seed('worksheets', blatt('wsD', 'tkaufw', { totalTime: 40, startDate: '08.10.2026', endDate: '08.10.2026', details: 'Farbkonzept', supporters: JSON.stringify(ANDREJ) }))
// nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten; dazu ein Blatt eines anderen Kunden
aw.seed('worksheets', blatt('wsAbo', 'tkaufw', { totalTime: 90, countsToPlan: true, details: 'Wartung im Abo' }))
aw.seed('worksheets', blatt('wsKomm', 'tkaufw', { totalTime: 30, isComment: true, details: 'Interner Hinweis' }))
aw.seed('worksheets', blatt('wsNull', 'tkaufw', { totalTime: 0, details: 'Telefonat ohne Zeit' }))
aw.seed('worksheets', blatt('wsFremd', 'tkfremd', { totalTime: 60, details: 'Arbeit beim anderen Kunden' }))
// --- D0: Einbindung, Seiten und Zugriffsschutz ---------------------------------------------------------------
test('D0 Einbindung: server/index.js hängt die neuen Router ein (Admin-Pfade vor dem Finanzen-Router), das Team-Gerüst hängt hinter requireStaffJwt', () => {
const index = fs.readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
const stelle = (pfad, was) => {
const treffer = new RegExp(`app\\.use\\(\\s*['"]${pfad}['"]\\s*,\\s*\\w+\\s*\\)`).exec(index)
assert.ok(treffer, `server/index.js: ${was} (${pfad}) fehlt`)
return treffer.index
}
const finanzen = stelle('/api/admin/finanzen', 'Finanzen-Router')
assert.ok(stelle('/api/admin/finanzen/projektkonten', 'Projektkonten') < finanzen, 'Projektkonten vor dem Finanzen-Router')
assert.ok(stelle('/api/admin/finanzen/auftraege', 'Aufträge (Admin)') < finanzen, 'Aufträge vor dem Finanzen-Router')
stelle('/api/auftrag', 'öffentliche Token-Routen')
stelle('/api/auftraege', 'Kundenrouten')
const team = fs.readFileSync(new URL('../server/routes/team/index.js', import.meta.url), 'utf8')
const jwt = team.indexOf('router.use(requireStaffJwt)')
assert.ok(jwt >= 0 && team.indexOf("router.use('/arbeitsblaetter'") > jwt, "Team: router.use('/arbeitsblaetter', …) steht hinter requireStaffJwt")
})
test('D0 Seiten: die Bestätigungsseite ist noindex, die Widerrufsseite kennt den Projektauftrag', () => {
const auftrag = fs.readFileSync(new URL('../public/auftrag.html', import.meta.url), 'utf8')
assert.match(auftrag, /<meta[^>]*(?:name=["']robots["'][^>]*noindex|noindex[^>]*name=["']robots["'])/i, 'auftrag.html trägt noindex')
const widerruf = fs.readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8')
assert.match(widerruf, /value=["']projekt["']/, 'Vertragsart mit order=projekt')
assert.match(widerruf, /Projektauftrag/, 'Beschriftung „Projektauftrag (Auftragsbestätigung)“')
})
test('D0 Zugriff: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403, Kunden-Cookie ersetzt kein JWT, unbekannte Token einheitlich 404', async () => {
const adminRouten = [
['GET', '/projektkonten?kunde=kfirma'], ['GET', '/projektkonten/pfest'], ['GET', '/auftraege?kunde=kfirma'], ['POST', '/auftraege'],
['GET', '/auftraege/x'], ['PATCH', '/auftraege/x'], ['DELETE', '/auftraege/x'], ['GET', '/auftraege/x/pdf'], ['POST', '/auftraege/x/senden'],
['POST', '/auftraege/x/erinnern'], ['POST', '/auftraege/x/neue-fassung'], ['POST', '/auftraege/x/stornieren'], ['POST', '/auftraege/x/projekt'], ['POST', '/rechnungen/vorschau'],
]
for (const [methode, pfad] of adminRouten) {
const body = methode === 'GET' || methode === 'DELETE' ? undefined : {}
const was = `${methode} ${pfad}`
erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body }), 401, `${was} ohne JWT`)
erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, cookie: kundeCookie('kfirma') }), 401, `${was} mit Kunden-Cookie`)
erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, headers: STAFF }), 403, `${was} als Mitarbeiter ohne Admin-Label`)
}
const abgerechnet = '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw'
erwarte(await send('GET', abgerechnet), 401, 'Team-Route ohne JWT')
erwarte(await send('GET', abgerechnet, { cookie: kundeCookie('kfirma') }), 401, 'Team-Route mit Kunden-Cookie')
erwarte(await send('GET', abgerechnet, { headers: { Authorization: 'Bearer jwt-kunde' } }), 403, 'Team-Route mit Kundenkonto ohne Label')
assert.deepEqual(Object.keys(erwarte(await send('GET', abgerechnet, { headers: STAFF }), 200, 'Team-Route als Mitarbeiter')), ['abgerechnet'])
for (const [methode, pfad] of [['GET', '/api/auftraege'], ['GET', '/api/auftraege/x/pdf'], ['POST', '/api/auftraege/x/bestaetigen']]) {
erwarte(await send(methode, pfad, { body: methode === 'POST' ? { bestaetigt: true } : undefined }), 401, `${methode} ${pfad} ohne Sitzung`)
}
for (const token of ['x', 'kurz', 'A'.repeat(43), 'A'.repeat(44), `${'A'.repeat(42)}%2F`]) {
erwarte(await ansicht(token, 1), 404, `Ansicht mit Token ${token}`, UNBEKANNT)
erwarte(await send('GET', `/api/auftrag/${token}/pdf`, { headers: ip(1) }), 404, `PDF mit Token ${token}`, UNBEKANNT)
erwarte(await bestaetigen(token, 2, { bestaetigt: true }), 404, `Bestätigen mit Token ${token}`, UNBEKANNT)
}
})
// --- D1: Einstellungen ---------------------------------------------------------------------------------------
test('D1 Einstellungen: die Steuernummer wird geprüft und gespeichert (sie steht später auf dem AB-PDF)', async () => {
assert.equal(erfolg(await admin('GET', '/einstellungen'), 'Einstellungen lesen').einstellungen.steuernummer, '')
// V4: erlaubt sind Buchstaben, Ziffern, Leerzeichen, / - und . (auch eine USt-IdNr. „DE…“), höchstens 40 Zeichen
for (const falsch of ['12/345/67890<script>', '1'.repeat(41), '143/123/45678; DROP']) {
erwarte(await admin('PATCH', '/einstellungen', { steuernummer: falsch }), 400, `Steuernummer ${falsch.slice(0, 20)}`, /steuernummer/i)
}
assert.equal(erfolg(await admin('PATCH', '/einstellungen', { steuernummer: 'DE 123 456 789' }), 'USt-IdNr. mit Buchstaben').einstellungen.steuernummer, 'DE 123 456 789')
assert.equal(erfolg(await admin('PATCH', '/einstellungen', { steuernummer: '' }), 'leer löscht die Steuernummer').einstellungen.steuernummer, '')
const gespeichert = erfolg(
await admin('PATCH', '/einstellungen', { steuernummer: ' 143/123/45678 ', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00 · BIC TESTDEFF' }),
'Steuernummer speichern'
).einstellungen
assert.equal(gespeichert.steuernummer, '143/123/45678', 'getrimmt')
assert.equal(aw.docs(config.collections.financeSettings).get('main').steuernummer, '143/123/45678')
})
// --- D2: Festbetrag, Projektkonto, Rechnung ohne Projekt, Stripe-Ausfall -------------------------------------
test('D2 Festbetrag: Entwurf mit Zusatzfeld Projekt und A4, Senden, Projektkonto, Zahlung, Rechnung ohne Projekt, Stripe-Ausfall', async () => {
const k0 = await konto('kfirma', 'pfest')
assert.equal(k0.body.auftraegeAktiv, true)
assert.equal(k0.body.stripeOk, true)
assert.deepEqual([k0.body.kunde.id, k0.body.kunde.kundenart], ['kfirma', 'unternehmen'])
assert.deepEqual(k0.konto.vereinbart, { festpreisCents: null, aufwand: null })
assert.deepEqual([k0.konto.berechnetCents, k0.konto.offenCents], [0, null])
assert.deepEqual(k0.konto.tickets, [{ id: 'tkfest', woid: '100201' }])
const vorher = st.calls.length
const entw = await rechnung({
customerId: 'kfirma', projektId: 'pfest', art: 'festbetrag',
items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
}, 'Festbetrag als Entwurf')
assert.deepEqual([entw.status, entw.art, entw.projektId, entw.projektName, entw.totalCents], ['entwurf', 'festbetrag', 'pfest', 'Website Beispiel', 50000])
const anlage = stripeAufrufe(vorher, 'POST', '/v1/invoices')[0]
assert.ok(anlage, 'Rechnung bei Stripe angelegt')
assert.deepEqual([anlage.body['custom_fields[0][name]'], anlage.body['custom_fields[0][value]']], ['Projekt', 'Website Beispiel'])
assert.equal(anlage.body['custom_fields[1][name]'], undefined, 'ohne AB und Leistungszeitraum nur ein Zusatzfeld')
assert.equal(anlage.body['rendering[pdf][page_size]'], 'a4')
assert.deepEqual(
[anlage.body['metadata[projektId]'], anlage.body['metadata[art]'], anlage.body['metadata[webklarCustomerId]'], anlage.body['metadata[quelle]']],
['pfest', 'festbetrag', 'kfirma', 'woms']
)
assert.ok(anlage.body.footer.includes('IBAN DE00'), 'Rechnungsfuß mit Bankverbindung bleibt')
const pr = aw.docs(PR()).get(entw.id)
assert.ok(pr, 'projektRechnungen-Dokument mit der Stripe-Kennung als ID')
assert.deepEqual([pr.kundenId, pr.projektId, pr.art, pr.betragCents, pr.aktiv], ['kfirma', 'pfest', 'festbetrag', 50000, true])
assert.deepEqual(JSON.parse(pr.arbeitsblattIdsJson || '[]'), [])
assert.equal((await konto('kfirma', 'pfest')).konto.berechnetCents, 0, 'ein Entwurf zählt nicht als berechnet')
const gesendet = erfolg(await admin('POST', `/rechnungen/${entw.id}/senden`), 'Festbetrag senden').rechnung
assert.equal(gesendet.status, 'offen')
assert.ok(gesendet.number)
assert.equal(aw.docs(PR()).get(entw.id).rechnungsnummer, gesendet.number, 'Finalisieren schreibt die Nummer ans Projektdokument')
const k1 = await konto('kfirma', 'pfest')
assert.deepEqual([k1.konto.berechnetCents, k1.konto.bezahltCents, k1.konto.offenCents], [50000, 0, null])
const zeile = erfolg(await admin('GET', '/rechnungen?kunde=kfirma'), 'Rechnungsliste').rechnungen.find((r) => r.id === gesendet.id)
assert.deepEqual([zeile.projektId, zeile.projektName, zeile.art], ['pfest', 'Website Beispiel', 'festbetrag'])
assert.equal(customerStripeInvoice(st.invoices.get(gesendet.id)).projekt, 'Website Beispiel', 'das Kundenportal zeigt das Projekt')
bezahlen(gesendet.id)
assert.equal((await konto('kfirma', 'pfest')).konto.bezahltCents, 50000)
// Rechnung ohne Projekt wie im bisherigen Dialog (kein art, kein projektId): bleibt möglich, jetzt auch in A4
const vorFrei = st.calls.length
const frei = await rechnung({ customerId: 'kfirma', items: [{ description: 'Beratung', quantity: 2, unitAmountCents: 6000 }], senden: true }, 'Rechnung ohne Projekt')
assert.equal(frei.art, 'frei')
assert.ok(!frei.projektId)
const freiAnlage = stripeAufrufe(vorFrei, 'POST', '/v1/invoices')[0]
assert.equal(freiAnlage.body['rendering[pdf][page_size]'], 'a4', 'A4 auch im bisherigen Dialog')
assert.equal(freiAnlage.body['custom_fields[0][name]'], undefined, 'ohne Projekt keine Zusatzfelder')
const kFrei = (await konto('kfirma', 'pfest')).body
assert.equal(kFrei.ohneProjekt.berechnetCents, 12000)
assert.ok(kFrei.ohneProjekt.anzahl >= 1)
// Stripe fällt aus: Projektkonto antwortet trotzdem, markiert den Stripe-Teil
invalidateFinanceCache()
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'Nachbau: Stripe gestört' }) // 4xx wiederholt stripe-node nicht
const ausfall = await konto('kfirma', 'pfest')
assert.equal(ausfall.body.stripeOk, false)
assert.deepEqual([ausfall.konto.berechnetCents, ausfall.konto.bezahltCents, ausfall.konto.offenCents], [0, 0, null])
assert.equal(ausfall.konto.zeit.satzCents, 10000, 'Zeit und Vereinbartes laufen ohne Stripe weiter')
invalidateFinanceCache()
assert.equal((await konto('kfirma', 'pfest')).body.stripeOk, true)
})
// --- D3: Aufwand ---------------------------------------------------------------------------------------------
test('D3 Aufwand: Vorschau, Wettlauf um dieselben Arbeitsblätter, Abzeichen, Senden, Storno und Löschen geben frei, Aufwands-AB setzt den Satz', async () => {
const ALLE = ['wsA', 'wsB', 'wsC', 'wsD']
const aufwand = (ids, extra = {}) => ({ customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ids, ...extra })
const abzeichen = async () => erfolg(await send('GET', '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw', { headers: STAFF }), 'Abzeichen').abgerechnet
// Ausgangslage: vier abrechenbare Blätter (Abo-Zeit, Kommentar und 0 Minuten zählen nie), je Blatt auf 15 Minuten aufgerundet, Unterstützung eingerechnet
const k0 = await konto('kfirma', 'paufw')
assert.deepEqual(k0.konto.zeit, { minuten: 270, aboMinuten: 90, offeneMinuten: 270, satzCents: 10000, wertCents: 45000, offenerWertCents: 45000 })
const detail = erwarte(await admin('GET', '/projektkonten/paufw'), 200, 'Projektkonto-Detail')
assert.deepEqual(detail.offeneArbeitsblaetter.map((b) => b.id).sort(), ALLE)
for (const b of detail.offeneArbeitsblaetter) {
assert.equal(b.minuten % 15, 0, `${b.id}: auf 15 Minuten aufgerundet`)
assert.match(b.datum, /^\d{4}-\d{2}-\d{2}$/)
}
assert.equal(detail.offeneArbeitsblaetter.find((b) => b.id === 'wsD').minuten, 75, 'mit Unterstützung: 40 + 30 = 70, aufgerundet 75')
// Vorschau: eine Zeile je Tag, Zusatzfelder Projekt und Leistungszeitraum; sie schreibt nichts
const stripeVorher = st.calls.length
const rechnungenVorher = st.invoices.size
const vorschau = erwarte(await admin('POST', '/rechnungen/vorschau', { customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ALLE }), 200, 'Aufwand-Vorschau')
assert.deepEqual(vorschau.positionen.map((p) => p.betragCents), [22500, 10000, 12500])
assert.equal(vorschau.summeCents, 45000)
assert.match(vorschau.positionen[0].beschreibung, /^06\.10\.2026: /)
assert.ok(vorschau.positionen[0].beschreibung.includes('Startseite') && vorschau.positionen[0].beschreibung.includes('Kontaktformular'))
assert.match(vorschau.positionen[0].beschreibung, / – 2,25 Std\. à 100,00\s€$/)
assert.match(vorschau.leistungszeitraum, /^06\.10\.[–-]08\.10\.2026$/)
assert.deepEqual(vorschau.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
assert.equal(vorschau.customFields[0].value, 'Online-Shop Beispiel')
assert.equal(st.invoices.size, rechnungenVorher, 'die Vorschau legt nichts bei Stripe an')
assert.deepEqual(stripeAufrufe(stripeVorher, 'POST', /./), [], 'die Vorschau schreibt nichts zu Stripe')
// Wettlauf (Review Focus 1): zwei Admins rechnen dieselben Blätter ab. Vermerk und Zahlungsziel unterscheiden sich, damit der
// 30-Sekunden-Schutz gegen gleiche Inhalte nicht schon greift; verhindern muss es die Sperre mit der Prüfung der Arbeitsblätter.
const lauf = (nr) => admin('POST', '/rechnungen', aufwand(ALLE, { memo: `Wettlauf ${nr}`, daysUntilDue: 13 + nr, senden: false, idempotencyKey: `ablauf-aufwand-${nr}` }))
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 }) // die erste Anlage bei Stripe dauert, damit beide Aufrufe sicher gleichzeitig prüfen
const [r1, r2] = await Promise.all([lauf(1), lauf(2)])
assert.deepEqual([r1.status, r2.status].sort(), [200, 409], `genau eine gewinnt: ${JSON.stringify([r1.body, r2.body])}`)
const gewinner = (r1.status === 200 ? r1 : r2).body.rechnung
const verlierer = r1.status === 200 ? r2 : r1
assert.match(verlierer.body.error, /abgerechnet/i, 'die zweite meldet „Arbeitsblatt bereits abgerechnet“')
assert.deepEqual([gewinner.art, gewinner.totalCents], ['aufwand', 45000])
assert.equal([...st.invoices.values()].filter((r) => r.metadata?.projektId === 'paufw').length, 1, 'der Verlierer hinterlässt keinen Entwurf bei Stripe')
const aktive = [...aw.docs(PR()).values()].filter((d) => d.projektId === 'paufw' && d.aktiv === true)
assert.equal(aktive.length, 1)
assert.deepEqual(JSON.parse(aktive[0].arbeitsblattIdsJson).sort(), ALLE)
assert.deepEqual(zeilen(gewinner.id).map((z) => z.amount), [22500, 10000, 12500])
const anlage = stripeAufrufe(stripeVorher, 'POST', '/v1/invoices').find((c) => c.body['metadata[projektId]'] === 'paufw')
assert.equal(anlage.body['custom_fields[1][name]'], 'Leistungszeitraum')
assert.match(anlage.body['custom_fields[1][value]'], /^06\.10\.[–-]08\.10\.2026$/)
// Abzeichen „abgerechnet“ (Team, ohne Beträge): im Entwurf „Entwurf“, nach dem Senden die Rechnungsnummer
assert.deepEqual(await abzeichen(), { wsA: 'Entwurf', wsB: 'Entwurf', wsC: 'Entwurf', wsD: 'Entwurf' })
erwarte(await send('GET', '/api/team/arbeitsblaetter/abgerechnet', { headers: STAFF }), 400, 'Abzeichen ohne ticketId')
const gesendet = erfolg(await admin('POST', `/rechnungen/${gewinner.id}/senden`), 'Aufwandsrechnung senden').rechnung
assert.equal(gesendet.status, 'offen')
assert.deepEqual(await abzeichen(), { wsA: gesendet.number, wsB: gesendet.number, wsC: gesendet.number, wsD: gesendet.number })
const berechnet = await konto('kfirma', 'paufw')
assert.equal(berechnet.konto.berechnetCents, 45000)
assert.deepEqual([berechnet.konto.zeit.offeneMinuten, berechnet.konto.zeit.offenerWertCents], [0, 0])
assert.deepEqual(erwarte(await admin('GET', '/projektkonten/paufw'), 200, 'Detail nach dem Senden').offeneArbeitsblaetter, [])
// Storno gibt die Blätter frei, der Betrag zählt nicht mehr
assert.equal(erfolg(await admin('POST', `/rechnungen/${gewinner.id}/stornieren`), 'Aufwandsrechnung stornieren').rechnung.status, 'storniert')
assert.equal(aw.docs(PR()).get(gewinner.id).aktiv, false)
assert.deepEqual(await abzeichen(), {})
const frei = await konto('kfirma', 'paufw')
assert.deepEqual([frei.konto.berechnetCents, frei.konto.zeit.offeneMinuten], [0, 270])
// Entwurf löschen gibt ebenfalls frei
const teil = await rechnung(aufwand(['wsC']), 'Entwurf mit einem Blatt')
assert.deepEqual(Object.keys(await abzeichen()), ['wsC'])
erfolg(await admin('DELETE', `/rechnungen/${teil.id}`), 'Entwurf löschen')
assert.equal(aw.docs(PR()).get(teil.id)?.aktiv, false)
assert.deepEqual(await abzeichen(), {})
// Nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten, Blatt eines anderen Kunden, unbekanntes Blatt, leere Auswahl
const vorAblehnung = st.invoices.size
for (const [ids, was] of [[['wsAbo'], 'Abo-Zeit'], [['wsKomm'], 'Kommentar'], [['wsNull'], '0 Minuten'], [['wsFremd'], 'Blatt eines anderen Kunden'], [['gibtEsNicht'], 'unbekanntes Blatt'], [[], 'leere Auswahl']]) {
const antwort = await admin('POST', '/rechnungen', aufwand(ids))
assert.ok([400, 404, 409].includes(antwort.status), `${was}: erwartet 400, 404 oder 409, bekam ${antwort.status} ${JSON.stringify(antwort.body)}`)
}
assert.equal(st.invoices.size, vorAblehnung, 'abgelehnte Aufwandsrechnungen hinterlassen nichts bei Stripe')
// Aufwands-AB: der Satz der jüngsten gültigen Aufwands-AB (auch unbestätigt) gilt, das Projektkonto zeigt „nach Aufwand“
const aufwandsAb = await entwurf('kfirma', {
projektId: 'paufw', titel: 'Betreuung Online-Shop', art: 'aufwand', positionen: [], stundensatzCents: 12000, schaetzungMinuten: 600,
zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['tkaufw'],
})
assert.equal(aufwandsAb.summeCents, null)
const gesendeteAb = (await sende(aufwandsAb.id, 'Aufwands-AB senden')).auftrag
const mitAb = await konto('kfirma', 'paufw')
assert.deepEqual(mitAb.konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 12000, schaetzungMinuten: 600 } })
assert.deepEqual([mitAb.konto.zeit.satzCents, mitAb.konto.zeit.wertCents, mitAb.konto.zeit.offenerWertCents], [12000, 54000, 54000])
// Annahme (Spec Abschnitt 4: „Noch offen = Wert der noch nicht abgerechneten Zeit“; V4: offenCents ist null ohne Festpreis, der Wert steht in zeit.offenerWertCents)
assert.equal(mitAb.konto.offenCents, null)
const ohneId = erwarte(await admin('POST', '/rechnungen/vorschau', { customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ALLE }), 200, 'Vorschau, Satz aus der AB')
assert.deepEqual(ohneId.positionen.map((p) => p.betragCents), [27000, 12000, 15000])
assert.match(ohneId.positionen[0].beschreibung, /à 120,00\s€$/)
const mitId = erwarte(await admin('POST', '/rechnungen/vorschau', { customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ALLE, auftragId: aufwandsAb.id }), 200, 'Vorschau mit auftragId')
assert.deepEqual(mitId.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum'])
assert.equal(mitId.customFields[1].value, `${gesendeteAb.nummer} vom ${formatDe(berlinDate(new Date(gesendeteAb.gesendetAm)))}`)
})
// --- D4: AB bis Schlussrechnung ------------------------------------------------------------------------------
test('D4 AB bis Schlussrechnung: Vorschau-PDF, Senden mit Mail und Ablage, Bestätigen, Anzahlung, Schlussrechnung mit Minuszeile, Storno, neue Fassung', async () => {
const stripeVorher = st.calls.length
const dateienVorher = pdfDateien().length
// 1) Entwurf mit Umlauten, € und Gedankenstrich (Review Focus 5, echtes pdfkit); das Vorschau-PDF wird nicht gespeichert
const e = await entwurf('kfirma', {
projektId: 'pab', zeitquelle: ['tkab'], titel: 'Relaunch: Größe, Äpfel & Öl – ß, € (5 € Aufpreis)',
positionen: [
{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 },
{ beschreibung: 'Programmierung der Seiten (Größe, Äpfel, Öl, ß, € – ohne Abzug)', menge: 2, einzelpreisCents: 15000 },
],
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
})
assert.deepEqual([e.summeCents, e.pdfVorhanden], [80000, false])
for (const intern of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(intern in e, false, `AuftragTeam enthält kein ${intern}`)
const vorschau = await holePdf(`/api/admin/finanzen/auftraege/${e.id}/pdf`, { headers: ADMIN })
assert.equal(vorschau.status, 200)
assert.match(vorschau.typ, /application\/pdf/)
assert.ok(istPdf(vorschau.bytes), 'die Vorschau ist ein gültiges PDF')
assert.equal(pdfDateien().length, dateienVorher, 'die Vorschau legt keine Datei ab')
const zuFrueh = await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: e.id, art: 'anzahlung', anzahlung: { prozent: 50 } })
assert.ok([400, 409].includes(zuFrueh.status), `Anzahlung zu einem Entwurf: ${zuFrueh.status} ${JSON.stringify(zuFrueh.body)}`)
assert.equal(stripeAufrufe(stripeVorher, 'POST', '/v1/invoices').length, 0, 'zu einem Entwurf entsteht keine Rechnung')
// 2) Senden: Nummer, PDF in der Ablage mit Prüfsumme, Mail mit beiden PDFs und Link, Token nur als Hash
const s = await sende(e.id)
const a = s.auftrag
assert.deepEqual([a.status, a.jahr, a.nummer], ['versendet', JAHR, formatNummer(JAHR, a.laufnummer)])
assert.deepEqual([a.mailStatus, a.gesendetAn, a.agbFassung, a.pdfVorhanden], ['gesendet', 'kfirma@kunde.example', AGB_FASSUNG, true])
assert.ok(a.texteFassung)
assert.deepEqual(adressen(s.mail.to), ['kfirma@kunde.example'])
assert.deepEqual(adressen(s.mail.bcc), [], 'V6: K31 geht ohne BCC an den Kunden; die Teamkopie ohne Link prüft sende()')
assert.ok(s.mail.subject.startsWith(`Ihre Auftragsbestätigung ${a.nummer}`), s.mail.subject)
const abAnhang = s.mail.attachments.find((x) => x.filename === `${a.nummer}.pdf`)
const agbAnhang = s.mail.attachments.find((x) => x.filename === `WEBklar-AGB-${AGB_FASSUNG}.pdf`)
assert.ok(abAnhang && agbAnhang, `AB-PDF und AGB-PDF hängen an (${s.mail.attachments.map((x) => x.filename).join(', ')})`)
const abBytes = Buffer.from(abAnhang.content, 'base64')
assert.ok(istPdf(abBytes) && istPdf(Buffer.from(agbAnhang.content, 'base64')), 'beide Anhänge sind gültige PDFs')
const doc = aw.docs(AB()).get(a.id)
assert.match(doc.pdfDatei, /^kfirma\/[A-Za-z0-9_-]+\.pdf$/)
const datei = path.join(ABLAGE, doc.pdfDatei)
assert.equal(sha256(fs.readFileSync(datei)), doc.pdfSha256, 'die Prüfsumme am AB passt zur Datei')
assert.equal(doc.pdfSha256, sha256(abBytes), 'gemailtes und abgelegtes PDF sind dasselbe')
assert.equal(fs.statSync(datei).mode & 0o777, 0o600)
assert.equal(fs.statSync(path.dirname(datei)).mode & 0o777, 0o700)
assert.equal(doc.tokenHash, hashToken(s.token), 'gespeichert wird nur der Hash')
assert.match(doc.tokenHash, /^[0-9a-f]{64}$/)
assert.equal(JSON.stringify(doc).includes(s.token), false, 'der Token steht nirgends im Dokument')
const tage = (new Date(doc.tokenGueltigBis).getTime() - Date.now()) / 86_400_000
assert.ok(tage > 59 && tage <= 60.01, `Token 60 Tage gültig, war ${tage}`)
assert.equal(sha256((await holePdf(`/api/admin/finanzen/auftraege/${a.id}/pdf`, { headers: ADMIN })).bytes), doc.pdfSha256, 'nach dem Senden liefert der Admin die gespeicherte Datei')
erwarte(await admin('PATCH', `/auftraege/${a.id}`, { titel: 'Nachträglich geändert' }), 409, 'gesendete AB ändern')
erwarte(await admin('DELETE', `/auftraege/${a.id}`), 409, 'gesendete AB löschen')
// 3) Öffentliche Ansicht: nur die Whitelist, keine Interna
const sicht = erwarte(await ansicht(s.token, 11), 200, 'Ansicht per Link').auftrag
assert.deepEqual([sicht.nummer, sicht.status, sicht.summeCents, sicht.kundenart], [a.nummer, 'versendet', 80000, 'unternehmen'])
assert.deepEqual(sicht.positionen[0], { beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000, summeCents: 50000 })
assert.equal(sicht.positionen[1].summeCents, 30000)
assert.equal(sicht.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`)
assert.equal(sicht.texte.fruehbeginn, null, 'Unternehmen: keine Frühbeginn-Erklärung')
assert.ok(sicht.texte.bestaetigung)
assert.equal(sicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', 'V3: der Kleinunternehmer-Satz kommt aus texte.js')
const roh = JSON.stringify(sicht)
for (const intern of ['kfirma@kunde.example', '"kfirma"', 'tokenHash', 'pdfDatei', 'pdfSha256', 'gesendetAn', 'kundeJson']) assert.equal(roh.includes(intern), false, `öffentliche Ansicht enthält ${intern}`)
const linkPdf = await holePdf(`/api/auftrag/${s.token}/pdf`, { headers: ip(11) })
assert.deepEqual([linkPdf.status, sha256(linkPdf.bytes)], [200, doc.pdfSha256])
// 4) Bestätigen: ohne Häkchen nichts; per Link; Protokoll am AB ohne IP; K32 an den Kunden, T22 ans Team; zweites Mal folgenlos
erwarte(await bestaetigen(s.token, 11, {}), 400, 'Bestätigen ohne Angabe')
erwarte(await bestaetigen(s.token, 11, { bestaetigt: false }), 400, 'Bestätigen mit abgewähltem Häkchen')
assert.equal((await lies(a.id)).status, 'versendet')
const vorBest = mails.sent.length
const best = erwarte(await bestaetigen(s.token, 11), 200, 'Bestätigen per Link').auftrag
assert.equal(best.status, 'bestaetigt')
assert.ok(best.bestaetigtAm)
const bestDoc = aw.docs(AB()).get(a.id)
assert.deepEqual([bestDoc.bestaetigtUeber, bestDoc.bestaetigtPdfSha256, Boolean(bestDoc.fruehbeginn)], ['link', bestDoc.pdfSha256, false])
assert.ok(bestDoc.texteFassung)
assert.equal(JSON.stringify(bestDoc).includes('203.0.113.'), false, 'keine Client-Adresse am AB')
const k32 = await warteBis(() => findeMail(vorBest, new RegExp(`Bestätigung zu ${a.nummer}`)), { was: 'K32' })
assert.deepEqual(adressen(k32.to), ['kfirma@kunde.example'])
const t22 = await warteBis(() => findeMail(vorBest, new RegExp(`Auftrag ${a.nummer} bestätigt`)), { was: 'T22' })
assert.match(t22.subject, /^\[Aufträge\] Auftrag AB-\d{4}-\d{4} bestätigt: /, 'V6: Betreff von T22 mit dem Präfix [Aufträge]')
for (const adresse of config.chatNotify.emails) assert.ok(adressen(t22.to).includes(adresse), `T22 an ${adresse}`)
for (const mail of [k32, t22]) for (const token of GESEHEN) assert.equal(`${mail.html}${mail.text}`.includes(token), false, `${mail.subject}: kein Token in der Mail`)
assert.ok(`${t22.html}${t22.text}`.includes('/customers/kfirma?tab=invoices'), 'T22 verlinkt den Reiter im Ticketsystem')
const nachBest = mails.sent.length
const zweite = erwarte(await bestaetigen(s.token, 11), 200, 'zweite Bestätigung').auftrag
assert.equal(new Date(zweite.bestaetigtAm).getTime(), new Date(best.bestaetigtAm).getTime(), 'die erste Bestätigung zählt')
await pause(150)
assert.equal(mails.sent.length, nachBest, 'die zweite Bestätigung verschickt nichts')
// 5) Anzahlung 50 % aus dem Zahlungsplan, Zusatzfeld „Auftrag“, nie mehr als der Rest
const anz = await rechnung({ customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'anzahlung', anzahlung: { prozent: 50 }, senden: true, idempotencyKey: 'ablauf-anzahlung-1' }, 'Anzahlung')
assert.deepEqual([anz.art, anz.totalCents, anz.status, anz.auftragNummer], ['anzahlung', 40000, 'offen', a.nummer])
assert.match(zeilen(anz.id)[0].description, /Anzahlung/)
assert.ok(zeilen(anz.id)[0].description.includes(a.nummer))
const anzAnlage = stripeAufrufe(stripeVorher, 'POST', '/v1/invoices').find((c) => c.body['metadata[art]'] === 'anzahlung')
assert.deepEqual([anzAnlage.body['custom_fields[0][name]'], anzAnlage.body['custom_fields[1][name]']], ['Projekt', 'Auftrag'])
assert.equal(anzAnlage.body['custom_fields[1][value]'], `${a.nummer} vom ${formatDe(berlinDate(new Date(a.gesendetAm)))}`)
const zuViel = await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'anzahlung', anzahlung: { betragCents: 50000 } })
assert.ok([400, 409].includes(zuViel.status), `Anzahlung über den Rest: ${zuViel.status} ${JSON.stringify(zuViel.body)}`)
bezahlen(anz.id)
const kA = await konto('kfirma', 'pab')
assert.deepEqual([kA.konto.vereinbart.festpreisCents, kA.konto.berechnetCents, kA.konto.bezahltCents, kA.konto.offenCents], [80000, 40000, 40000, 40000])
// 6) Review Focus 3: ein offener Entwurf zur AB blockiert die Schlussrechnung; Minuszeilen baut nur der Server
const teil = await rechnung({ customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'anzahlung', anzahlung: { betragCents: 10000, bezeichnung: 'Teilrechnung' } }, 'Teilrechnung als Entwurf')
erwarte(await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss', senden: true }), 409, 'Schlussrechnung neben offenem Entwurf', /Entwurf/)
erfolg(await admin('DELETE', `/rechnungen/${teil.id}`), 'Entwurf löschen')
erwarte(await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss', items: [{ description: 'Abzug', quantity: 1, unitAmountCents: -100 }] }), 400, 'Minuszeile vom Client')
// 7) Schlussrechnung mit Nachtrag und Minuszeile; die AB ist danach „abgerechnet“, „noch offen“ nie unter 0
const schluss = await rechnung({
customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss', senden: true, idempotencyKey: 'ablauf-schluss-1',
items: [{ description: 'Zusätzliche Unterseite (Nachtrag)', quantity: 1, unitAmountCents: 10000 }],
}, 'Schlussrechnung')
assert.deepEqual([schluss.art, schluss.totalCents, schluss.status], ['schluss', 50000, 'offen'])
const z = zeilen(schluss.id)
assert.deepEqual(z.map((x) => x.amount), [50000, 30000, 10000, -40000], 'AB-Positionen, Nachtrag, dann die Minuszeile')
assert.deepEqual([z[0].quantity, z[1].quantity], [1, 2])
assert.match(z.at(-1).description, /abzüglich Anzahlung/)
assert.ok(z.at(-1).description.includes(anz.number), 'die Minuszeile nennt die Rechnung der Anzahlung')
assert.equal((await lies(a.id)).status, 'abgerechnet')
const kS = await konto('kfirma', 'pab')
assert.deepEqual([kS.konto.vereinbart.festpreisCents, kS.konto.berechnetCents, kS.konto.offenCents], [80000, 90000, 0])
const nochmal = await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss' })
assert.ok([400, 409].includes(nochmal.status), `zweite Schlussrechnung: ${nochmal.status} ${JSON.stringify(nochmal.body)}`)
// 8) Storno der Schlussrechnung setzt die AB zurück (auf „bestätigt“, weil bestaetigtAm gesetzt ist)
assert.equal(erfolg(await admin('POST', `/rechnungen/${schluss.id}/stornieren`), 'Schlussrechnung stornieren').rechnung.status, 'storniert')
assert.equal((await lies(a.id)).status, 'bestaetigt')
assert.equal(aw.docs(PR()).get(schluss.id).aktiv, false)
const kZ = await konto('kfirma', 'pab')
assert.deepEqual([kZ.konto.berechnetCents, kZ.konto.offenCents], [40000, 40000])
// 9) Neue Fassung mit anderem Preis: die Anzahlung wandert mit (projektRechnungen und Projektkonto folgen)
const neu = erfolg(await admin('POST', `/auftraege/${a.id}/neue-fassung`), 'neue Fassung').auftrag
assert.deepEqual([neu.status, neu.ersetztId, neu.nummer], ['entwurf', a.id, ''])
const geaendert = erfolg(await admin('PATCH', `/auftraege/${neu.id}`, {
positionen: [
{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 },
{ beschreibung: 'Programmierung der Seiten (Größe, Äpfel, Öl, ß, € – ohne Abzug)', menge: 2, einzelpreisCents: 17500 },
],
}), 'Entwurf der neuen Fassung ändern').auftrag
assert.equal(geaendert.summeCents, 85000)
assert.equal((await lies(a.id)).status, 'bestaetigt', 'bis zum Senden der neuen gilt die alte AB')
await sende(neu.id, 'neue Fassung senden')
const alt = await lies(a.id)
assert.deepEqual([alt.status, alt.ersetztDurchId], ['ersetzt', neu.id])
assert.equal(aw.docs(PR()).get(anz.id).auftragId, neu.id, 'die Anzahlung gehört jetzt zur neuen AB')
// Die Stripe-Metadaten (auftragId, auftragNummer) der alten Rechnung bleiben historisch (entschieden in der Planrunde, Vertragsfrage 3 dieser Datei, L3):
// maßgeblich ist projektRechnungen.auftragId; die Metadaten werden deshalb nicht geprüft.
const kN = await konto('kfirma', 'pab')
assert.deepEqual([kN.konto.vereinbart.festpreisCents, kN.konto.berechnetCents, kN.konto.offenCents], [85000, 40000, 45000], 'die ersetzte AB zählt nicht mehr als vereinbart')
const schlussNeu = await rechnung({ customerId: 'kfirma', projektId: 'pab', auftragId: neu.id, art: 'schluss', senden: true, idempotencyKey: 'ablauf-schluss-2' }, 'Schlussrechnung zur neuen AB')
assert.equal(schlussNeu.totalCents, 45000)
assert.deepEqual(zeilen(schlussNeu.id).map((x) => x.amount), [50000, 35000, -40000])
assert.equal((await lies(neu.id)).status, 'abgerechnet')
})
// --- D5: Storno einer Anzahlung ------------------------------------------------------------------------------
test('D5 Schlussrechnung neben stornierter Anzahlung: ein Storno zählt nicht, die Schlussrechnung hat keine Minuszeile', async () => {
const e = await entwurf('kfirma', {
projektId: 'pstorno', zeitquelle: ['tkstorno'], titel: 'Landingpage',
positionen: [{ beschreibung: 'Landingpage', menge: 1, einzelpreisCents: 30000 }], zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
})
const { auftrag } = await sende(e.id)
const anz = await rechnung({ customerId: 'kfirma', projektId: 'pstorno', auftragId: auftrag.id, art: 'anzahlung', anzahlung: { prozent: 50 }, senden: true }, 'Anzahlung')
assert.equal(anz.totalCents, 15000)
assert.equal((await konto('kfirma', 'pstorno')).konto.berechnetCents, 15000)
assert.equal(erfolg(await admin('POST', `/rechnungen/${anz.id}/stornieren`), 'Anzahlung stornieren').rechnung.status, 'storniert')
const k = await konto('kfirma', 'pstorno')
assert.deepEqual([k.konto.berechnetCents, k.konto.offenCents], [0, 30000], 'die stornierte Anzahlung zählt nicht')
const schluss = await rechnung({ customerId: 'kfirma', projektId: 'pstorno', auftragId: auftrag.id, art: 'schluss' }, 'Schlussrechnung ohne Abzug')
assert.equal(schluss.totalCents, 30000)
assert.deepEqual(zeilen(schluss.id).map((x) => x.amount), [30000], 'keine Minuszeile für eine stornierte Rechnung')
erfolg(await admin('DELETE', `/rechnungen/${schluss.id}`), 'Entwurf löschen')
})
// --- D6: alte Links ------------------------------------------------------------------------------------------
test('D6 Alte Links: Erinnerung, Ablauf, neue Fassung und Storno machen den alten Link unbrauchbar, die Seite nennt den Grund', async () => {
const e = await entwurf('kfirma', { titel: 'Pflege und Wartung der Seite', positionen: [{ beschreibung: 'Einrichtung', menge: 1, einzelpreisCents: 30000 }] })
const s1 = await sende(e.id)
assert.equal(erwarte(await ansicht(s1.token, 21), 200, 'erster Link').auftrag.status, 'versendet')
// Erinnerung: neuer Link, der alte gilt nicht mehr
const vorErinnerung = mails.sent.length
const erinnert = erfolg(await admin('POST', `/auftraege/${e.id}/erinnern`), 'Erinnerung senden')
assert.equal(erinnert.mailFehler, false)
const erinnerung = k31Mails(vorErinnerung, 'kfirma')
assert.equal(erinnerung.anKunde.length, 1, 'die Erinnerung verschickt K31 erneut an den Kunden')
const mail2 = erinnerung.anKunde[0]
const token2 = tokenAus(mail2)
pruefeTeamkopie(mail2, token2, erinnerung.teamkopien, 'Erinnerung') // V6: die Teamkopie gibt es auch bei Erinnerungen
// Annahme (nicht im Vertrag): das Wort „Erinnerung“ steht nur im Block erinnerung der Vorlage K31
assert.match(mail2.text, /Erinnerung/, 'K31 als Erinnerung trägt den Erinnerungs-Hinweis')
assert.notEqual(token2, s1.token)
erwarte(await ansicht(s1.token, 21), 404, 'alter Link nach Erinnerung', UNBEKANNT)
erwarte(await bestaetigen(s1.token, 21), 404, 'Bestätigen mit altem Link', UNBEKANNT)
assert.equal((await lies(e.id)).status, 'versendet', 'mit dem alten Link wurde nichts bestätigt')
erwarte(await ansicht(token2, 21), 200, 'neuer Link')
// Ablauf (60 Tage): das Datum im Dokument kippen und wieder zurück
const frisch = aw.docs(AB()).get(e.id)
aw.seed(AB(), { ...frisch, tokenGueltigBis: new Date(Date.now() - 1000).toISOString() })
erwarte(await ansicht(token2, 21), 404, 'abgelaufener Link', UNBEKANNT)
erwarte(await bestaetigen(token2, 21), 404, 'Bestätigen mit abgelaufenem Link', UNBEKANNT)
aw.seed(AB(), frisch)
erwarte(await ansicht(token2, 21), 200, 'der Link gilt wieder, sobald das Datum stimmt')
// Neue Fassung: bis zum Senden gilt die alte AB, danach 410 mit dem Grund
const neu = erfolg(await admin('POST', `/auftraege/${e.id}/neue-fassung`), 'neue Fassung').auftrag
erfolg(await admin('PATCH', `/auftraege/${neu.id}`, { positionen: [{ beschreibung: 'Einrichtung', menge: 1, einzelpreisCents: 35000 }] }), 'Entwurf ändern')
erwarte(await ansicht(token2, 21), 200, 'die alte AB gilt bis zum Senden der neuen')
const s2 = await sende(neu.id, 'neue Fassung senden')
erwarte(await ansicht(token2, 21), 410, 'Link der ersetzten AB', 'Diese Auftragsbestätigung wurde ersetzt.')
erwarte(await bestaetigen(token2, 21), 410, 'Bestätigen über die ersetzte AB', /ersetzt/)
assert.equal(aw.docs(AB()).get(e.id).bestaetigtAm ?? null, null, 'die ersetzte AB wurde nie bestätigt')
erwarte(await admin('POST', `/auftraege/${e.id}/erinnern`), 409, 'Erinnerung zu einer ersetzten AB')
// Storno (von Hand, ohne Mail)
const vorStorno = mails.sent.length
const storniert = erfolg(await admin('POST', `/auftraege/${neu.id}/stornieren`), 'Storno').auftrag
assert.equal(storniert.status, 'storniert')
assert.ok(storniert.storniertAm)
assert.equal(mails.sent.length, vorStorno, 'ein Storno schickt keine Mail')
erwarte(await ansicht(s2.token, 21), 410, 'Link nach Storno', 'Diese Auftragsbestätigung wurde storniert.')
erwarte(await bestaetigen(s2.token, 21), 410, 'Bestätigen nach Storno', /storniert/)
erwarte(await admin('POST', `/auftraege/${neu.id}/stornieren`), 409, 'zweiter Storno')
erwarte(await admin('POST', `/auftraege/${neu.id}/senden`), 409, 'Senden nach Storno')
})
// --- D6b: Bestätigen gegen Storno ----------------------------------------------------------------------------
test('D6b Bestätigen und Storno gleichzeitig: eine stornierte AB lebt nie wieder auf', async () => {
for (let runde = 1; runde <= 5; runde += 1) {
const s = await sende((await entwurf('kfirma', { titel: `Rennen ${runde}` })).id, `AB Rennen ${runde}`)
const [best, storno] = await Promise.all([bestaetigen(s.token, 23), admin('POST', `/auftraege/${s.auftrag.id}/stornieren`)])
assert.ok([200, 410].includes(best.status), `Runde ${runde}: Bestätigen ${best.status} ${JSON.stringify(best.body)}`)
assert.ok([200, 409].includes(storno.status), `Runde ${runde}: Storno ${storno.status} ${JSON.stringify(storno.body)}`)
const danach = await lies(s.auftrag.id)
// Ein Storno nach der Bestätigung ist erlaubt (Stand „storniert“, bestaetigtAm bleibt als Verlauf). Verboten ist der umgekehrte Fall:
// die Bestätigung überschreibt den Storno („bestätigt“ mit gesetztem storniertAm). L3 und L5 müssen dieselbe Sperre je AB benutzen (sperreAuftrag).
assert.equal(danach.status === 'bestaetigt' && Boolean(danach.storniertAm), false, `Runde ${runde}: die stornierte AB lebte wieder auf (${danach.status})`)
if (storno.status === 200) assert.equal(danach.status, 'storniert', `Runde ${runde}: der Storno gelang, Stand ${danach.status}`)
}
})
// --- D7: Mail scheitert --------------------------------------------------------------------------------------
test('D7 Mail scheitert: die AB bleibt versendet, mailStatus fehler, die Erinnerung sendet erneut', async () => {
const e = await entwurf('kfirma', { titel: 'Mail-Ausfall' })
setMailTransportForTests({ sendMail: async () => { throw new Error('Nachbau: SMTP nicht erreichbar') } })
let versendet
try {
const body = erwarte(await admin('POST', `/auftraege/${e.id}/senden`), 200, 'Senden bei Mail-Ausfall')
assert.equal(body.mailFehler, true)
versendet = body.auftrag
} finally {
setMailTransportForTests(mails)
}
assert.deepEqual([versendet.status, versendet.mailStatus, versendet.pdfVorhanden], ['versendet', 'fehler', true])
assert.match(versendet.nummer, /^AB-\d{4}-\d{4}$/, 'die Nummer ist vergeben')
assert.ok(String(aw.docs(AB()).get(e.id).mailFehler).length <= 500)
const vor = mails.sent.length
const neu = erfolg(await admin('POST', `/auftraege/${e.id}/erinnern`), 'Erneut senden')
assert.deepEqual([neu.mailFehler, neu.auftrag.mailStatus], [false, 'gesendet'])
const mail = k31Mails(vor, 'kfirma').anKunde[0]
assert.ok(mail, 'die Mail ging jetzt raus')
// L3 (Übersicht, Nachträge): nach einem Mail-Fehler ist „Erneut senden“ eine normale K31, keine Erinnerung; Annahme (nicht im Vertrag): „Erinnerung“ steht nur im Erinnerungs-Block
assert.doesNotMatch(mail.text, /Erinnerung/, 'normale K31 ohne Erinnerungs-Hinweis')
erwarte(await ansicht(tokenAus(mail), 22), 200, 'Link der neuen Mail')
})
// --- D8: Privatperson ----------------------------------------------------------------------------------------
test('D8 Privatperson: zusätzliche PDF-Seiten, Frühbeginn nur auf Verlangen, Bestätigen im Portal, Kundenansicht gesperrt, Widerruf', async () => {
const position = [{ beschreibung: 'Erstellung der Website', menge: 1, einzelpreisCents: 90000 }]
const privat = (titel) => entwurf('kprivat', { titel, positionen: position })
const pdfSeiten = ({ mail, auftrag }) => seiten(Buffer.from(mail.attachments.find((x) => x.filename === `${auftrag.nummer}.pdf`).content, 'base64'))
// 1) Die AB an eine Privatperson hat zusätzliche Seiten (Widerrufsbelehrung, Muster-Widerrufsformular)
const firma = await sende((await entwurf('kfirma', { titel: 'Website für die Firma', positionen: position })).id, 'AB Unternehmen')
const ohneFH = await sende((await privat('Website Privat ohne Frühbeginn')).id, 'AB Privatperson')
assert.ok(pdfSeiten(ohneFH) > pdfSeiten(firma), 'Privatperson: zusätzliche Seiten')
assert.deepEqual(adressen(ohneFH.mail.to), ['kprivat@kunde.example'])
// 2) Öffentliche Ansicht: Erklärung zum Frühbeginn vorhanden, frühester Beginn = Tag nach dem Fristende
const frist = addDays(withdrawalDeadline(new Date(ohneFH.auftrag.gesendetAm)).lastDay, 1)
const sicht = erwarte(await ansicht(ohneFH.token, 31), 200, 'Ansicht Privatperson').auftrag
assert.equal(sicht.kundenart, 'privat')
assert.ok(typeof sicht.texte.fruehbeginn === 'string' && sicht.texte.fruehbeginn.length > 20, 'Text der Frühbeginn-Erklärung')
assert.equal(sicht.fruehesterBeginn, frist)
// 3) Ohne Frühbeginn: der Wartetag steht in K32, T22 und im Ticketsystem
const vor3 = mails.sent.length
const b1 = erwarte(await bestaetigen(ohneFH.token, 31, { bestaetigt: true, fruehbeginn: false }), 200, 'Bestätigen ohne Frühbeginn').auftrag
assert.deepEqual([b1.fruehbeginn, b1.fruehesterBeginn], [false, frist])
const k32 = await warteBis(() => findeMail(vor3, new RegExp(`Bestätigung zu ${ohneFH.auftrag.nummer}`)), { was: 'K32 Privatperson' })
const t22 = await warteBis(() => findeMail(vor3, new RegExp(`Auftrag ${ohneFH.auftrag.nummer} bestätigt`)), { was: 'T22 Privatperson' })
assert.match(t22.subject, /^\[Aufträge\] /, 'V6: Präfix [Aufträge]')
for (const mail of [k32, t22]) assert.ok(`${mail.html}${mail.text}`.includes(formatDe(frist)), `${mail.subject}: nennt den frühesten Beginn ${formatDe(frist)}`)
const team = await lies(ohneFH.auftrag.id)
assert.deepEqual([team.fruehbeginn, team.fruehesterBeginn], [false, frist])
assert.ok(aw.docs(AB()).get(ohneFH.auftrag.id).fruehbeginnFassung, 'L5: die angezeigte Fassung der Erklärung wird bei Privatpersonen auch ohne Haken gespeichert')
// 4) Mit Frühbeginn: Erklärung samt Fassung gespeichert, kein Wartetag; ein Unternehmen kann keinen Frühbeginn erklären
const mitFH = await sende((await privat('Website Privat mit Frühbeginn')).id, 'zweite AB Privatperson')
const vor4 = mails.sent.length
const b2 = erwarte(await bestaetigen(mitFH.token, 31, { bestaetigt: true, fruehbeginn: true }), 200, 'Bestätigen mit Frühbeginn').auftrag
assert.deepEqual([b2.fruehbeginn, b2.fruehesterBeginn], [true, null])
const fhDoc = aw.docs(AB()).get(mitFH.auftrag.id)
assert.equal(fhDoc.fruehbeginn, true)
assert.ok(fhDoc.fruehbeginnFassung)
const k32b = await warteBis(() => findeMail(vor4, new RegExp(`Bestätigung zu ${mitFH.auftrag.nummer}`)), { was: 'K32 mit Frühbeginn' })
assert.equal(`${k32b.html}${k32b.text}`.includes(formatDe(frist)), false, 'mit Frühbeginn gibt es keinen Wartetag')
// V6: K32 gibt FRUEHBEGINN_TEXT wörtlich wieder (kein Satz „Ihr Widerrufsrecht bleibt unberührt“); Annahme (nicht im Vertrag): bis auf Leerraum unverändert
const ohneLeerraum = (text) => String(text).replace(/\s+/g, ' ').trim()
assert.ok(ohneLeerraum(k32b.text).includes(ohneLeerraum(FRUEHBEGINN_TEXT)), 'K32 enthält FRUEHBEGINN_TEXT')
assert.doesNotMatch(k32b.text, /Widerrufsrecht bleibt[^.]*unberührt/i)
// Annahme (V4: „fruehbeginn nur bei privat“): bei einem Unternehmen wird der Wert ignoriert, nicht abgelehnt
const firmaBest = erwarte(await bestaetigen(firma.token, 31, { bestaetigt: true, fruehbeginn: true }), 200, 'Unternehmen bestätigt').auftrag
assert.equal(firmaBest.fruehbeginn, false)
assert.equal(Boolean(aw.docs(AB()).get(firma.auftrag.id).fruehbeginn), false)
assert.equal(aw.docs(AB()).get(firma.auftrag.id).fruehbeginnFassung || '', '', 'L5: bei Unternehmen bleibt die Fassung leer')
// 5) Portal-Weg: eigene Liste ohne Entwürfe, andere Kunden sehen nichts, Bestätigen mit Sitzung, Kundenansicht gesperrt
const portal = await sende((await privat('Website Privat im Portal')).id, 'dritte AB Privatperson')
const offenerEntwurf = await privat('Entwurf bleibt unsichtbar')
const meine = kundeCookie('kprivat')
const fremde = kundeCookie('kfremd')
const liste = erfolg(await send('GET', '/api/auftraege', { cookie: meine }), 'Portal-Liste')
assert.equal(liste.aktiv, true)
const eintrag = liste.auftraege.find((x) => x.id === portal.auftrag.id)
assert.deepEqual([eintrag.status, eintrag.kannBestaetigen], ['versendet', true])
assert.equal(liste.auftraege.some((x) => x.id === offenerEntwurf.id || x.status === 'entwurf'), false, 'keine Entwürfe im Portal')
assert.equal(erfolg(await send('GET', '/api/auftraege', { cookie: fremde }), 'Liste eines anderen Kunden').auftraege.length, 0)
erwarte(await send('GET', `/api/auftraege/${portal.auftrag.id}/pdf`, { cookie: fremde }), 404, 'fremdes PDF')
erwarte(await send('POST', `/api/auftraege/${portal.auftrag.id}/bestaetigen`, { cookie: fremde, body: { bestaetigt: true, fruehbeginn: false } }), 404, 'fremde AB bestätigen')
erwarte(await send('GET', `/api/auftraege/${offenerEntwurf.id}/pdf`, { cookie: meine }), 404, 'ein Entwurf liefert kein PDF')
const eigenesPdf = await holePdf(`/api/auftraege/${portal.auftrag.id}/pdf`, { cookie: meine })
assert.deepEqual([eigenesPdf.status, sha256(eigenesPdf.bytes)], [200, aw.docs(AB()).get(portal.auftrag.id).pdfSha256])
const kv = { cookie: kundenansichtCookie({ customerId: 'kprivat' }), headers: { 'x-kundenansicht': KV } }
const gesperrt = await send('POST', `/api/auftraege/${portal.auftrag.id}/bestaetigen`, { ...kv, body: { bestaetigt: true, fruehbeginn: false } })
assert.deepEqual([gesperrt.status, gesperrt.body.code], [403, 'view_blocked'])
const gesperrtLink = await bestaetigen(portal.token, 32, { bestaetigt: true, fruehbeginn: false }, kv)
assert.deepEqual([gesperrtLink.status, gesperrtLink.body.code], [403, 'view_blocked'], 'auch der Link-Weg mit dem Cookie der Kundenansicht')
const ansichtListe = erwarte(await send('GET', '/api/auftraege', kv), 200, 'Lesen bleibt in der Kundenansicht erlaubt')
assert.equal(ansichtListe.aktiv, true)
assert.equal(ansichtListe.auftraege.find((x) => x.id === portal.auftrag.id).kannBestaetigen, false, 'V3: in der Kundenansicht ist Bestätigen nicht möglich')
assert.equal((await lies(portal.auftrag.id)).status, 'versendet', 'in der Kundenansicht wurde nichts bestätigt')
const imPortal = erwarte(await send('POST', `/api/auftraege/${portal.auftrag.id}/bestaetigen`, { cookie: meine, body: { bestaetigt: true, fruehbeginn: true } }), 200, 'Bestätigen im Portal').auftrag
assert.equal(imPortal.status, 'bestaetigt')
assert.equal(aw.docs(AB()).get(portal.auftrag.id).bestaetigtUeber, 'portal')
const danach = erfolg(await send('GET', '/api/auftraege', { cookie: meine }), 'Liste danach').auftraege.find((x) => x.id === portal.auftrag.id)
assert.equal(danach.kannBestaetigen, false)
// 6) Widerruf eines Projektauftrags: nur mit passender Nummer und E-Mail, nur Privatperson; der alte Link ist danach tot
const widerruf = (body) => send('POST', '/api/abo/public/withdraw', { body: { name: 'Petra Privat', order: 'projekt', ...body }, headers: ip(33) })
erfolg(await widerruf({ email: 'kprivat@kunde.example', abNummer: 'AB-1999-0001' }), 'falsche Nummer')
erfolg(await widerruf({ email: 'andere@kunde.example', abNummer: mitFH.auftrag.nummer }), 'falsche Adresse')
const vorFirma = mails.sent.length
erfolg(await widerruf({ email: 'kfirma@kunde.example', abNummer: firma.auftrag.nummer }), 'Unternehmen')
const k04Firma = await warteBis(() => findeMail(vorFirma, /Eingangsbestätigung Ihres Widerrufs/), { was: 'K04 für ein Unternehmen' })
assert.match(k04Firma.text, /kein gesetzliches Widerrufsrecht/i, 'V6: K04-Block projekt nennt bei Unternehmen den Hinweis')
// V5: die AB-Nummer ist optional; ohne sie (oder mit einer ungültigen) gibt es nur die Mails mit dem Vermerk „ungeprüft“, nie eine Statusänderung
const vorOhne = mails.sent.length
erfolg(await widerruf({ email: 'kprivat@kunde.example' }), 'ohne AB-Nummer')
const t09Ohne = await warteBis(() => findeMail(vorOhne, /WIDERRUF eingegangen/), { was: 'T09 ohne AB-Nummer' })
assert.match(`${t09Ohne.text}`, /ungeprüft/i, 'Vermerk „ungeprüft“')
assert.equal((await lies(mitFH.auftrag.id)).status, 'bestaetigt')
assert.equal((await lies(firma.auftrag.id)).status, 'bestaetigt', 'ein Unternehmen widerruft nicht')
const vor6 = mails.sent.length
erfolg(await widerruf({ email: 'KPrivat@Kunde.Example', abNummer: mitFH.auftrag.nummer }), 'richtiger Widerruf (E-Mail in anderer Schreibweise)')
const widerrufen = await lies(mitFH.auftrag.id)
assert.equal(widerrufen.status, 'widerrufen')
assert.ok(widerrufen.widerrufenAm)
const k04 = await warteBis(() => findeMail(vor6, /Eingangsbestätigung Ihres Widerrufs/), { was: 'K04 an die Privatperson' })
const t09 = await warteBis(() => findeMail(vor6, /WIDERRUF eingegangen/), { was: 'T09 ans Team' })
for (const mail of [k04, t09]) assert.ok(`${mail.text}`.includes('Projektauftrag') && `${mail.text}`.includes(mitFH.auftrag.nummer), `V6: Block projekt in ${mail.subject} nennt Projektauftrag und AB-Nummer`)
erwarte(await ansicht(mitFH.token, 33), 410, 'Link nach Widerruf', 'Diese Auftragsbestätigung wurde widerrufen.')
erwarte(await bestaetigen(mitFH.token, 33), 410, 'Bestätigen nach Widerruf', /widerrufen/)
})
// --- D9: unvollständige Kundendaten --------------------------------------------------------------------------
test('D9 Unvollständige Kundendaten: klare Meldung vor Nummer, PDF und Mail, keine Nummer verbraucht', async () => {
const nummernVorher = vergebeneNummern()
const dateienVorher = pdfDateien().length
const mailsVorher = mails.sent.length
const e = await entwurf('kluecke', { titel: 'AB für einen unvollständigen Kunden' })
const faelle = [
['Kundenart fehlt', {}, 400],
['E-Mail-Adresse fehlt', { kundenart: 'unternehmen', email: '' }, 400],
['Anschrift unvollständig', { kundenart: 'unternehmen', street: '' }, 409],
['Interessent statt Kunde', { kundenart: 'unternehmen', customerStatus: 'lead' }, 400],
]
for (const [was, patch, status] of faelle) {
setzeKunde('kluecke', patch)
const antwort = await admin('POST', `/auftraege/${e.id}/senden`)
assert.equal(antwort.status, status, `${was}: erwartet ${status}, bekam ${antwort.status} ${JSON.stringify(antwort.body)}`)
assert.ok(typeof antwort.body?.error === 'string' && antwort.body.error.length > 5, `${was}: verständliche Meldung`)
if (status === 409) assert.match(antwort.body.error, /Adresse des Kunden unvollständig/)
}
assert.deepEqual(vergebeneNummern(), nummernVorher, 'keine Nummer verbraucht')
assert.equal(pdfDateien().length, dateienVorher, 'kein PDF abgelegt')
assert.equal(mails.sent.length, mailsVorher, 'keine Mail')
assert.equal((await lies(e.id)).status, 'entwurf')
// Der Dialog fragt die Kundenart und speichert sie am Kunden (nur wenn sie dort noch leer ist)
setzeKunde('kluecke', {})
erfolg(await admin('PATCH', `/auftraege/${e.id}`, { kundenart: 'privat' }), 'Kundenart über den Entwurf setzen')
assert.equal(aw.docs('customers').get('kluecke').kundenart, 'privat')
assert.equal((await sende(e.id, 'Senden mit vollständigen Daten')).auftrag.status, 'versendet')
assert.equal(vergebeneNummern().length, nummernVorher.length + 1)
})
// --- D10: Ablage kaputt --------------------------------------------------------------------------------------
test('D10 Ablage nicht beschreibbar: Senden scheitert mit 500, die AB bleibt Entwurf, keine Nummer verbraucht', async (t) => {
const e = await entwurf('kfirma', { titel: 'Ablage kaputt' })
const nummernVorher = vergebeneNummern()
const blockade = path.join(ABLAGE, 'keine-ordner-sondern-datei')
fs.writeFileSync(blockade, 'x')
const ordner = config.auftraege.uploadDir
// Annahme (V1): ablage.js liest config.auftraege.uploadDir bei jedem Aufruf. Unter einer Datei lässt sich kein Ordner anlegen, auch nicht als root.
config.auftraege.uploadDir = path.join(blockade, 'auftraege')
try {
const antwort = await admin('POST', `/auftraege/${e.id}/senden`)
if (antwort.status === 200) return t.skip('ablage.js liest das Verzeichnis nur beim Laden: Fehlerpfad im Einzeltest von L3/L4 belegen und im Protokoll vermerken')
assert.equal(antwort.status, 500, JSON.stringify(antwort.body))
assert.equal(/ENOTDIR|EEXIST|auftraege-ablauf/.test(String(antwort.body?.error)), false, 'keine Pfade oder Systemfehler an der Oberfläche')
} finally {
config.auftraege.uploadDir = ordner
}
assert.equal((await lies(e.id)).status, 'entwurf')
assert.deepEqual(vergebeneNummern(), nummernVorher, 'keine Nummer verbraucht')
assert.equal((await sende(e.id, 'Senden nach dem Fehler')).auftrag.status, 'versendet')
})
// --- D11: Nummernvergabe -------------------------------------------------------------------------------------
test('D11 Nummernvergabe: parallele Sendungen bekommen fortlaufende Nummern, dasselbe zweimal senden gelingt nur einmal, über 25 ABs bleibt alles vollständig', async () => {
const entwuerfe = await Promise.all([1, 2, 3, 4].map((n) => entwurf('kfirma', { titel: `Parallel ${n}` })))
const vorher = mails.sent.length
const antworten = await Promise.all(entwuerfe.map((x) => admin('POST', `/auftraege/${x.id}/senden`)))
antworten.forEach((antwort, i) => erwarte(antwort, 200, `parallel senden ${i + 1}`))
const nummern = antworten.map((antwort) => antwort.body.auftrag.laufnummer).sort((a, b) => a - b)
assert.equal(new Set(nummern).size, 4, 'verschiedene Nummern')
assert.deepEqual(nummern, [nummern[0], nummern[0] + 1, nummern[0] + 2, nummern[0] + 3], 'fortlaufend')
assert.equal(k31Mails(vorher, 'kfirma').anKunde.length, 4, 'je AB genau eine K31 an den Kunden')
const einzeln = await entwurf('kfirma', { titel: 'Doppelt gesendet' })
const vorher2 = mails.sent.length
const [x, y] = await Promise.all([admin('POST', `/auftraege/${einzeln.id}/senden`), admin('POST', `/auftraege/${einzeln.id}/senden`)])
assert.deepEqual([x.status, y.status].sort(), [200, 409], JSON.stringify([x.body, y.body]))
assert.equal(k31Mails(vorher2, 'kfirma').anKunde.length, 1, 'nur eine K31 an den Kunden')
// Mehr als 25 Dokumente des Kunden: Appwrite liefert ohne limit() nur 25, die Nummer folgt trotzdem dem Maximum und die Liste ist vollständig
const kfirmaDokumente = () => [...aw.docs(AB()).values()].filter((d) => d.kundenId === 'kfirma')
while (kfirmaDokumente().length < 27) await sende((await entwurf('kfirma', { titel: `Auffüllen ${kfirmaDokumente().length}` })).id, 'Auffüllen')
const hoechste = Math.max(...[...aw.docs(AB()).values()].map((d) => d.laufnummer || 0))
const naechste = await sende((await entwurf('kfirma', { titel: 'Nummer nach über 25 ABs' })).id, 'Nummer nach über 25 ABs')
assert.equal(naechste.auftrag.laufnummer, hoechste + 1)
const alle = erwarte(await admin('GET', '/auftraege?kunde=kfirma'), 200, 'Liste des Kunden').auftraege
assert.equal(alle.length, kfirmaDokumente().length, 'die Liste ist vollständig')
assert.ok(alle.length > 25)
})
// --- D12: Schalter -------------------------------------------------------------------------------------------
test('D12 Schalter: off blendet alle AB-Funktionen aus (404/400), Projektkonto und Rechnungsarten ohne AB laufen weiter, pilot wirkt je Kunden-E-Mail', async () => {
const gesendet = await sende((await entwurf('kfirma', { titel: 'Für den Schalter-Test' })).id, 'AB senden')
const entwurfsAb = await entwurf('kfirma', { titel: 'Bleibt Entwurf' })
// eine bestätigte AB einer Privatperson: der Widerruf läuft auch bei ausgeschaltetem Schalter (Verbraucherrecht)
const widerrufsAb = await sende((await entwurf('kprivat', { titel: 'Widerruf bei ausgeschaltetem Schalter' })).id, 'AB Privatperson')
erwarte(await bestaetigen(widerrufsAb.token, 52), 200, 'Bestätigen vor dem Ausschalten')
const vorher = { mode: config.auftraege.mode, pilotEmails: [...config.auftraege.pilotEmails] }
try {
config.auftraege.mode = 'off'
erwarte(await admin('GET', '/auftraege?kunde=kfirma'), 404, 'Liste', SCHALTER_AUS_MELDUNG)
erwarte(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId: 'kfirma' }), 404, 'anlegen', SCHALTER_AUS_MELDUNG)
erwarte(await admin('POST', `/auftraege/${entwurfsAb.id}/senden`), 404, 'senden', SCHALTER_AUS_MELDUNG)
erwarte(await admin('POST', `/auftraege/${entwurfsAb.id}/projekt`, { projektId: 'pzuordnen' }), 404, 'Projekt zuordnen', SCHALTER_AUS_MELDUNG)
for (const art of ['anzahlung', 'schluss']) {
erwarte(await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: gesendet.auftrag.id, art, anzahlung: { prozent: 50 } }), 400, `Rechnungsart ${art}`, SCHALTER_AUS_MELDUNG)
}
// V5: der Widerruf eines Projektauftrags gilt unabhängig vom Schalter (Verbraucherrecht)
erfolg(await send('POST', '/api/abo/public/withdraw', { body: { name: 'Petra Privat', email: 'kprivat@kunde.example', order: 'projekt', abNummer: widerrufsAb.auftrag.nummer }, headers: ip(52) }), 'Widerruf bei Schalter aus')
assert.equal(erwarte(await admin('GET', '/projektkonten?kunde=kfirma'), 200, 'Projektkonto bei Schalter aus').auftraegeAktiv, false)
const festbetrag = await rechnung({ customerId: 'kfirma', projektId: 'pfest', art: 'festbetrag', items: [{ description: 'Kleinigkeit laut Absprache', quantity: 1, unitAmountCents: 12345 }] }, 'Festbetrag bei Schalter aus')
assert.equal(festbetrag.status, 'entwurf')
erfolg(await admin('DELETE', `/rechnungen/${festbetrag.id}`), 'Entwurf löschen')
erwarte(await ansicht(gesendet.token, 51), 404, 'Link bei Schalter aus', UNBEKANNT)
erwarte(await bestaetigen(gesendet.token, 51), 404, 'Bestätigen bei Schalter aus', UNBEKANNT)
assert.deepEqual(erfolg(await send('GET', '/api/auftraege', { cookie: kundeCookie('kfirma') }), 'Kundenliste bei Schalter aus'), { aktiv: false, auftraege: [] })
erwarte(await send('POST', `/api/auftraege/${gesendet.auftrag.id}/bestaetigen`, { cookie: kundeCookie('kfirma'), body: { bestaetigt: true, fruehbeginn: false } }), 404, 'Portal-Bestätigen bei Schalter aus')
config.auftraege.mode = 'on'
assert.equal((await lies(gesendet.auftrag.id)).status, 'versendet', 'der Schalter ändert keinen Stand')
assert.equal((await lies(widerrufsAb.auftrag.id)).status, 'widerrufen', 'der Widerruf wirkte auch bei ausgeschaltetem Schalter')
erwarte(await ansicht(gesendet.token, 51), 200, 'der Link gilt wieder, sobald der Schalter an ist')
config.auftraege.mode = 'pilot'
config.auftraege.pilotEmails = ['kfirma@kunde.example']
erfolg(await admin('GET', '/auftraege?kunde=kfirma'), 'Pilot: Kunde steht auf der Liste')
erwarte(await admin('GET', '/auftraege?kunde=kprivat'), 404, 'Pilot: anderer Kunde', SCHALTER_AUS_MELDUNG)
assert.equal(erwarte(await admin('GET', '/projektkonten?kunde=kprivat'), 200, 'Projektkonto im Pilot').auftraegeAktiv, false)
config.auftraege.pilotEmails = ['*']
erfolg(await admin('GET', '/auftraege?kunde=kprivat'), 'Pilot mit *: alle Kunden')
} finally {
config.auftraege.mode = vorher.mode
config.auftraege.pilotEmails = vorher.pilotEmails
}
})
// --- D13: Ratenbegrenzung ------------------------------------------------------------------------------------
test('D13 Ratenbegrenzung: auch Fehlversuche mit erfundenem Token zählen, je Adresse getrennt', async () => {
const erfunden = 'Z'.repeat(43)
GESEHEN.add(erfunden)
for (let i = 1; i <= 10; i += 1) erwarte(await bestaetigen(erfunden, 250), 404, `Versuch ${i}`, UNBEKANNT)
erwarte(await bestaetigen(erfunden, 250), 429, 'elfter Versuch', 'Zu viele Anfragen. Bitte später erneut versuchen.')
erwarte(await bestaetigen(erfunden, 251), 404, 'eine andere Adresse ist nicht betroffen', UNBEKANNT)
})
// --- D14: Dateiablage ----------------------------------------------------------------------------------------
test('D14 Dateiablage: manipulierte Pfade und Kennungen liefern nie fremde Dateien', async () => {
const echt = [...aw.docs(AB()).values()].find((d) => d.pdfDatei && d.kundenId === 'kfirma' && ['versendet', 'bestaetigt'].includes(d.status))
assert.ok(echt, 'Ausgangs-AB mit PDF vorhanden')
const sitzung = kundeCookie('kfirma')
try {
for (const pfad of ['../../../../etc/passwd', '/etc/passwd', 'kfirma/../../x.pdf', 'kfirma/a.pdf\u0000.png', '']) {
aw.seed(AB(), { ...echt, $id: 'manip', nummer: '', pdfDatei: pfad })
const durchAdmin = await holePdf('/api/admin/finanzen/auftraege/manip/pdf', { headers: ADMIN })
const durchKunde = await holePdf('/api/auftraege/manip/pdf', { cookie: sitzung })
for (const antwort of [durchAdmin, durchKunde]) {
assert.equal(antwort.status, 404, `Pfad ${JSON.stringify(pfad)}`)
assert.equal(antwort.bytes.toString('latin1').includes('root:'), false)
}
}
for (const kennung of ['..%2f..%2fetc%2fpasswd', '%00', 'a'.repeat(300)]) {
assert.equal((await holePdf(`/api/auftraege/${kennung}/pdf`, { cookie: sitzung })).status, 404, `Kennung ${kennung.slice(0, 20)}`)
assert.equal((await holePdf(`/api/admin/finanzen/auftraege/${kennung}/pdf`, { headers: ADMIN })).status, 404, `Admin-Kennung ${kennung.slice(0, 20)}`)
}
} finally {
await deleteDocument(AB(), 'manip').catch(() => {})
}
})
// --- D17: AB ohne Projekt, später dem Projekt zuordnen ---------------------------------------------------------
// Spec Abschnitt 1: „Projekt (oder freier Titel, wenn es noch kein Projekt gibt; später zuordenbar)“. V5: POST …/auftraege/:id/projekt.
// Der Test steht vor D15 und D16, weil diese am Ende alle Nummern und Token prüfen.
test('D17 AB ohne Projekt: nach dem Senden dem Projekt zuordnen; das Projektkonto zeigt sie unter „Vereinbart“, ein zweiter Aufruf ist 409', async () => {
const e = await entwurf('kfirma', { titel: 'Beratung ohne Projekt', projektId: '', zeitquelle: [], positionen: [{ beschreibung: 'Beratung und Konzept', menge: 1, einzelpreisCents: 70000 }] })
assert.equal(e.projektId, '')
const s = await sende(e.id, 'AB ohne Projekt senden')
const vorher = aw.docs(AB()).get(s.auftrag.id)
assert.equal(vorher.projektId || '', '')
const pdfVorher = await holePdf(`/api/admin/finanzen/auftraege/${s.auftrag.id}/pdf`, { headers: ADMIN })
assert.equal(pdfVorher.status, 200)
// vor der Zuordnung zählt die AB in keinem Projektkonto
const k0 = await konto('kfirma', 'pzuordnen')
assert.deepEqual([k0.konto.vereinbart.festpreisCents, k0.konto.offenCents], [null, null])
// Fehler: ohne projektId 400, unbekannte AB 404; ein unbekanntes oder fremdes Projekt wird abgelehnt (Annahme (nicht im Vertrag): 400, 404 oder 409); nichts ändert sich
erwarte(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, {}), 400, 'ohne projektId')
erwarte(await admin('POST', '/auftraege/gibtsnicht/projekt', { projektId: 'pzuordnen' }), 404, 'unbekannte AB')
for (const projektId of ['pfremd', 'gibtsnicht']) {
const antwort = await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId })
assert.ok([400, 404, 409].includes(antwort.status), `Projekt ${projektId}: ${antwort.status} ${JSON.stringify(antwort.body)}`)
}
assert.equal(aw.docs(AB()).get(s.auftrag.id).projektId || '', '', 'abgelehnte Zuordnungen ändern nichts')
// Zuordnung: projektId gesetzt, leere Zeitquelle = Ticket des Projekts; Nummer, Status, Sendezeit und das gespeicherte PDF bleiben
const zugeordnet = erfolg(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId: 'pzuordnen' }), 'Projekt zuordnen').auftrag
assert.deepEqual([zugeordnet.projektId, zugeordnet.zeitquelle, zugeordnet.status, zugeordnet.nummer], ['pzuordnen', ['tkzuordnen'], 'versendet', s.auftrag.nummer])
const nachher = aw.docs(AB()).get(s.auftrag.id)
assert.deepEqual([nachher.pdfDatei, nachher.pdfSha256, nachher.gesendetAm, nachher.tokenHash], [vorher.pdfDatei, vorher.pdfSha256, vorher.gesendetAm, vorher.tokenHash], 'PDF, Sendezeit und Link bleiben')
const pdfNachher = await holePdf(`/api/admin/finanzen/auftraege/${s.auftrag.id}/pdf`, { headers: ADMIN })
assert.equal(sha256(pdfNachher.bytes), sha256(pdfVorher.bytes), 'das PDF bleibt unverändert')
erwarte(await ansicht(s.token, 61), 200, 'der Link gilt weiter')
// Projektkonto: die AB steht jetzt unter „Vereinbart“, das Detail nennt sie
const k1 = await konto('kfirma', 'pzuordnen')
assert.deepEqual([k1.konto.vereinbart.festpreisCents, k1.konto.berechnetCents, k1.konto.offenCents], [70000, 0, 70000])
assert.deepEqual(k1.konto.zeitquelle, ['tkzuordnen'])
const detail = erwarte(await admin('GET', '/projektkonten/pzuordnen'), 200, 'Detail des Projektkontos')
assert.ok(detail.auftraege.some((x) => x.id === s.auftrag.id), 'das Detail nennt die AB')
// zweiter Aufruf: 409 (hat schon ein Projekt), auch mit einem anderen Projekt; der Stand bleibt
erwarte(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId: 'pzuordnen' }), 409, 'zweite Zuordnung')
erwarte(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId: 'pab' }), 409, 'Zuordnung zu einem anderen Projekt')
assert.equal(aw.docs(AB()).get(s.auftrag.id).projektId, 'pzuordnen')
// eine stornierte AB lässt sich nicht mehr zuordnen
const t = await sende((await entwurf('kfirma', { titel: 'Beratung ohne Projekt, storniert', projektId: '', zeitquelle: [] })).id, 'zweite AB ohne Projekt')
erfolg(await admin('POST', `/auftraege/${t.auftrag.id}/stornieren`), 'stornieren')
erwarte(await admin('POST', `/auftraege/${t.auftrag.id}/projekt`, { projektId: 'pzuordnen' }), 409, 'Zuordnung einer stornierten AB')
assert.equal(aw.docs(AB()).get(t.auftrag.id).projektId || '', '', 'die stornierte AB bleibt ohne Projekt')
assert.equal((await konto('kfirma', 'pzuordnen')).konto.vereinbart.festpreisCents, 70000, 'das Projektkonto zählt nur die zugeordnete AB')
})
// --- D15 und D16: Gesamtbild am Ende -------------------------------------------------------------------------
test('D15 Nummern: AB-JJJJ-NNNN, je Jahr fortlaufend und ohne Lücke', () => {
const dokumente = [...aw.docs(AB()).values()].filter((d) => d.nummer)
assert.ok(dokumente.length >= 28, `genug ABs im Test: ${dokumente.length}`)
const lauf = dokumente.map((d) => d.laufnummer).sort((a, b) => a - b)
assert.deepEqual(lauf, lauf.map((_, i) => i + 1), 'lückenlos und ohne Doppelte (fehlgeschlagene Sendungen verbrauchen nichts)')
for (const d of dokumente) {
assert.equal(d.jahr, JAHR)
assert.equal(d.nummer, formatNummer(d.jahr, d.laufnummer))
assert.match(d.nummer, /^AB-\d{4}-\d{4}$/)
}
})
test('D16 Datenschutz: kein Token und keine Client-Adresse in Dokumenten und im Protokoll, kein Feld für Adresse oder Browser', () => {
assert.ok(GESEHEN.size >= 10, `Token wurden gesammelt: ${GESEHEN.size}`)
const dokumente = JSON.stringify([...aw.docs(AB()).values()])
const protokoll = PROTOKOLL.join('\n')
for (const token of GESEHEN) {
assert.equal(dokumente.includes(token), false, 'Token im Dokument')
assert.equal(protokoll.includes(token), false, 'Token im Protokoll')
}
assert.equal(protokoll.includes('203.0.113.'), false, 'Client-Adresse im Protokoll')
// V6: Mails ans Team (Teamkopie der K31, T22, T09 …) enthalten nie einen Bestätigungs-Token und keinen Bestätigungslink
const teamMails = mails.sent.filter((mail) => adressen(mail.to).some((adresse) => config.chatNotify.emails.includes(adresse)))
assert.ok(teamMails.length >= 10, `Mails ans Team gesammelt: ${teamMails.length}`)
for (const mail of teamMails) {
const inhalt = `${mail.html}\n${mail.text}`
for (const token of GESEHEN) assert.equal(inhalt.includes(token), false, `Token in der Team-Mail "${mail.subject}"`)
assert.equal(/auftrag\.html[?#]t=/.test(inhalt), false, `Bestätigungslink in der Team-Mail "${mail.subject}"`)
}
const schluessel = AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key)
for (const verboten of ['ip', 'ipAdresse', 'clientIp', 'ipHash', 'userAgent', 'consentIp', 'consentUserAgent']) assert.equal(schluessel.includes(verboten), false, `Feld ${verboten}`)
})