1140 lines
86 KiB
JavaScript
1140 lines
86 KiB
JavaScript
// 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}`)
|
||
})
|