diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 62e0b9e..6adc5c6 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -188,7 +188,6 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => { // --- Rechnungen (Stripe) ---------------------------------------------------------------------------------------- const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf']) -const DEFAULT_DAYS_UNTIL_DUE = 14 const createdOf = (invoice) => invoice.createdDate ?? '' router.get('/rechnungen', async (req, res) => { @@ -226,7 +225,8 @@ router.post('/rechnungen', async (req, res) => { anzahlung, zeilentexte, leistungszeitraum, - daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE, + // Ohne Angabe entscheidet createProjectInvoice (AB-Zahlungsziel bei Anzahlung und Schluss, sonst 14 Tage) + daysUntilDue: daysUntilDue ?? undefined, memo, // Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf senden: senden === true || senden === 'true', diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 5bb49e5..0740367 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' import { addDays, berlinDate, daysBetween } from './dates.js' -import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js' +import { RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js' import { withLocks } from '../keyedLock.js' import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js' import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js' @@ -336,13 +336,14 @@ export async function createProjectInvoice({ anzahlung, zeilentexte, leistungszeitraum, - daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, + daysUntilDue, memo = '', senden = false, idempotencyKey, createdBy, }) { - if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) { + // Ohne Angabe: bei Anzahlung und Schluss das Zahlungsziel der AB (siehe createInvoice), sonst 14 Tage + if (daysUntilDue !== undefined && !isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) { throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`) } const note = typeof memo === 'string' ? memo.trim() : '' @@ -379,8 +380,11 @@ export async function rechnungsVorschau(eingabe) { return { positionen, summeCents, leistungszeitraum, customFields } } -async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) { +async function createInvoice({ eingabe, daysUntilDue: angegeben, note, senden, key }) { const entwurf = await baueRechnung(eingabe) + // Zahlungsziel: die Angabe des Aufrufers, sonst bei Anzahlung und Schluss das der AB, sonst 14 Tage + const abZiel = AB_RECHNUNGS_ARTEN.includes(entwurf.art) ? entwurf.auftrag?.zahlungszielTage : undefined + const daysUntilDue = angegeben ?? (isWholeNumberBetween(abZiel, 0, MAX_DAYS_UNTIL_DUE) ? abZiel : DEFAULT_DAYS_UNTIL_DUE) const customer = entwurf.kunde // Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) diff --git a/test/auftraegeKontrolleRechnung.test.js b/test/auftraegeKontrolleRechnung.test.js new file mode 100644 index 0000000..01762a5 --- /dev/null +++ b/test/auftraegeKontrolleRechnung.test.js @@ -0,0 +1,52 @@ +// Schlusskontrolle (Z3.4): Korrekturen an Projektrechnungen zu Auftragsbestaetigungen +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 { createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') +const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { umhaengen } = await import('../server/services/auftraege/projektRechnungenStore.js') + +after(() => Promise.all([aw.close(), st.close()])) +seedRechnungsDaten(aw) +config.auftraege.mode = 'on' +beforeEach(async () => { + await leereRechnungsDaten(aw, st) + invalidateFinanceCache() + resetInvoiceCreateGuardForTests() +}) + +const letzterEntwurf = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1) +const anzahlung = (ab, prozent, extra = {}) => + createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent, bezeichnung: `Anzahlung ${prozent}` }, ...extra }) +const schluss = (ab, extra = {}) => createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, projektId: 'p1', ...extra }) + +test('Zahlungsziel: Anzahlung und Schluss ohne eigene Angabe nehmen das Zahlungsziel der AB, eine Angabe gilt vor der AB', async () => { + const ab = await legeAuftragAn({ zahlungszielTage: 30 }) + await anzahlung(ab, 50, { senden: true }) + assert.equal(letzterEntwurf().body.days_until_due, '30') + await schluss(ab) + assert.equal(letzterEntwurf().body.days_until_due, '30') + const ab2 = await legeAuftragAn({ zahlungszielTage: 30, projektId: 'p3' }, { nummer: 'AB-2026-0003', laufnummer: 3 }) + await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab2.id, projektId: 'p3', anzahlung: { prozent: 50 }, daysUntilDue: 7 }) + assert.equal(letzterEntwurf().body.days_until_due, '7') +}) + +test('Zahlungsziel: ohne AB-Bezug (frei, festbetrag) bleibt es bei 14 Tagen', async () => { + await legeAuftragAn({ zahlungszielTage: 30 }) + await createProjectInvoice({ customerId: 'k1', items: [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] }) + assert.equal(letzterEntwurf().body.days_until_due, '14') +}) diff --git a/test/auftraegeRoutesRechnungen.test.js b/test/auftraegeRoutesRechnungen.test.js index c67eb44..a0d9fb2 100644 --- a/test/auftraegeRoutesRechnungen.test.js +++ b/test/auftraegeRoutesRechnungen.test.js @@ -125,3 +125,14 @@ test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnun 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', '', '', '', '']]) }) + +test('POST /rechnungen: Anzahlung ohne daysUntilDue nimmt das Zahlungsziel der AB, mit Angabe diese; freie Rechnung bleibt bei 14', async () => { + const ab = await legeAuftragAn({ zahlungszielTage: 30 }) + const tage = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1).body.days_until_due + assert.equal((await post(`${base}/rechnungen`, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent: 20 } }, admin)).status, 200) + assert.equal(tage(), '30') + assert.equal((await post(`${base}/rechnungen`, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent: 10 }, daysUntilDue: 7 }, admin)).status, 200) + assert.equal(tage(), '7') + assert.equal((await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN }, admin)).status, 200) + assert.equal(tage(), '14') +})