Rechnungen: Routen für Projektrechnungen und Vorschau

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:18:36 +00:00
1 parent 88a7305e41
commit d2c48ab99b
2 files changed
+151 -2

No files matched your search

+24 -2
View File
@@ -22,13 +22,14 @@ import {
createProjectInvoice, createProjectInvoice,
deleteDraft, deleteDraft,
listInvoices, listInvoices,
rechnungsVorschau,
sendInvoiceNow, sendInvoiceNow,
voidInvoice, voidInvoice,
} from '../../services/finance/stripeFinance.js' } from '../../services/finance/stripeFinance.js'
/** /**
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, * Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein * Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* und rufen die Dienste in server/services/finance auf. * und rufen die Dienste in server/services/finance auf.
*/ */
const router = Router() const router = Router()
@@ -209,11 +210,22 @@ router.get('/rechnungen', async (req, res) => {
}) })
router.post('/rechnungen', async (req, res) => { router.post('/rechnungen', async (req, res) => {
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {} const {
customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
daysUntilDue, memo, senden, idempotencyKey,
} = req.body || {}
try { try {
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
const rechnung = await createProjectInvoice({ const rechnung = await createProjectInvoice({
customerId, customerId,
art,
projektId,
auftragId,
items, items,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE, daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
memo, memo,
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf // Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
@@ -228,6 +240,16 @@ router.post('/rechnungen', async (req, res) => {
} }
}) })
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
router.post('/rechnungen/vorschau', async (req, res) => {
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
try {
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
} catch (err) {
return sendFinanceError(res, 'Rechnungsvorschau', err)
}
})
router.post('/rechnungen/:id/senden', async (req, res) => { router.post('/rechnungen/:id/senden', async (req, res) => {
try { try {
return res.json({ rechnung: await sendInvoiceNow(req.params.id) }) return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
+127
View File
@@ -0,0 +1,127 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
const aw = await startAppwriteMock({
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
datetimes: AUFTRAEGE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(async () => {
await leereRechnungsDaten(aw, st)
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
})
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
const base = '/api/admin/finanzen'
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET')
const erwarte = async (pfad, body, status, error) => {
const res = await post(`${base}${pfad}`, body, admin)
assert.equal(res.status, status, JSON.stringify(res.body))
assert.deepEqual(res.body, { error })
}
test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => {
const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200])
assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4')
})
test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => {
const ab = await legeAuftragAn()
const antwort = async (body) => {
const res = await post(`${base}/rechnungen`, body, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
return res.body.rechnung
}
const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN })
assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', ''])
const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] })
assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500])
const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' })
assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002'])
const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000])
})
test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => {
const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
assert.equal(res.body.summeCents, 32500)
assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026')
assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
assert.equal(schreibaufrufe().length, 0)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => {
const ab = await legeAuftragAn()
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.')
await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.')
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.')
await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.')
config.auftraege.mode = 'off'
try {
await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS)
} finally {
config.auftraege.mode = 'on'
}
}
assert.equal(schreibaufrufe().length, 0)
})
test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => {
const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
}
})
test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => {
const ab = await legeAuftragAn()
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
assert.equal(schreibaufrufe().length, 0)
})
test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => {
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401)
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403)
}
assert.equal(st.invoices.size, 0)
await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin)
await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin)
const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen
const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer]
assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']])
})