import { test, after, beforeEach, mock } 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 { mapInvoice, createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests, } = await import('../server/services/finance/stripeFinance.js') const { getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') const { withLocks } = await import('../server/services/keyedLock.js') const { berlinDate, formatDe } = await import('../server/services/finance/dates.js') after(() => Promise.all([aw.close(), st.close()])) seedRechnungsDaten(aw) config.auftraege.mode = 'on' beforeEach(async () => { mock.restoreAll() await leereRechnungsDaten(aw, st) invalidateFinanceCache() resetInvoiceCreateGuardForTests() }) const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) const writes = () => st.calls.filter((c) => c.method !== 'GET') const wirft = (promise, status, message) => assert.rejects(promise, (err) => { assert.equal(err.status, status, `Status ${err.status}: ${err.message}`) assert.equal(err.message, message) return true }) test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder frei', () => { const roh = (over = {}) => ({ id: 'in_1', object: 'invoice', status: 'open', customer: 'cus_1', created: 1_790_000_000, total: 1000, metadata: {}, lines: { object: 'list', data: [] }, ...over }) const felder = (r) => [r.projektId, r.projektName, r.art, r.auftragId, r.auftragNummer] const heute = { today: '2026-10-06' } assert.deepEqual(felder(mapInvoice(roh(), heute)), ['', '', 'frei', '', '']) assert.deepEqual(felder(mapInvoice(roh({ subscription: 'sub_1' }), heute)), ['', '', 'abo', '', '']) const metadata = { projektId: 'p1', projektName: 'Website Wolf', art: 'schluss', auftragId: 'a1', auftragNummer: 'AB-2026-0002' } assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002']) for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`) }) test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => { const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' }) const [create] = callsTo('POST', '/v1/invoices') assert.equal(create.body['rendering[pdf][page_size]'], 'a4') assert.equal(create.body['metadata[art]'], 'frei') assert.equal(create.body['metadata[webklarCustomerId]'], 'k1') assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields'))) assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k))) assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, '']) assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) }) test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => { const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }] const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' }) const [create] = callsTo('POST', '/v1/invoices') assert.equal(create.body['custom_fields[0][name]'], 'Projekt') assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com') assert.equal(create.body['custom_fields[1][name]'], undefined) assert.equal(create.body['rendering[pdf][page_size]'], 'a4') assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com']) assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000]) const doc = await getProjektRechnung(rechnung.id) assert.deepEqual( [doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon], [rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com'] ) }) test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => { const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' }) const items = callsTo('POST', '/v1/invoiceitems') assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]]) assert.ok(items[0].body.description.startsWith('06.10.2026: ')) const [create] = callsTo('POST', '/v1/invoices') assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum']) assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026') assert.equal(rechnung.totalCents, 32500) assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3']) }) test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => { const ab = await legeAuftragAn() const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' }) assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen']) st.calls.length = 0 const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' }) const [create] = callsTo('POST', '/v1/invoices') assert.deepEqual( [0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]), [['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']] ) const items = callsTo('POST', '/v1/invoiceitems') const kunde = items[0].body.customer assert.deepEqual(items.map((c) => c.body), [ { customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' }, { customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' }, { customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' }, ]) assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf']) }) test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => { const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' } aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 }) await assert.rejects(createProjectInvoice(eingabe)) assert.equal(st.invoices.size, 0) assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1) assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) // der Schutz vor doppeltem Anlegen ist wieder frei assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf') }) test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => { const basis = { customerId: 'k1', items: POSTEN } const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.' await createProjectInvoice(basis) await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' }) await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }) await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT) const ab = await legeAuftragAn() await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } }) await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }) await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT) assert.equal(st.invoices.size, 5) }) test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => { const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' }) assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents']) assert.equal(v.summeCents, 32500) assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07']) assert.equal(v.leistungszeitraum, '06.10.–07.10.2026') assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum']) assert.equal(writes().length, 0) assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) }) test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => { await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }) await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.') const ab = await legeAuftragAn() const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }) assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung']) assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben') }) test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => { st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 }) const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' }) const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))]) assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected']) assert.equal(ergebnisse[1].reason.status, 409) assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).') assert.equal(st.invoices.size, 1) assert.equal(callsTo('POST', '/v1/invoices').length, 1) assert.equal(aw.docs(config.collections.projektRechnungen).size, 1) }) test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => { const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo }) await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A')) await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') const rest = await createProjectInvoice(eingabe(['ws3'], 'C')) assert.equal(rest.totalCents, 10000) assert.equal(st.invoices.size, 2) }) test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => { const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] } const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)]) assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected']) assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.') assert.equal(st.invoices.size, 1) }) test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => { const ab = await legeAuftragAn() st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 }) const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo }) const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))]) assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected']) assert.equal(ergebnisse[1].reason.status, 409) assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`) assert.equal(st.invoices.size, 1) }) test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => { const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true }) assert.match(gesendet.number, /^WK-\d{4}$/) assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number) const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' }) assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '') const nachher = await sendInvoiceNow(entwurf.id) assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number) const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true }) assert.equal(await getProjektRechnung(frei.id), null) }) test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => { const ab = await legeAuftragAn() await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true }) assert.equal((await getAuftrag(ab.id)).status, 'versendet') const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) assert.equal((await getAuftrag(ab.id)).status, 'versendet') const gesendet = await sendInvoiceNow(entwurf.id) assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number) await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') }) test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => { for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) { await leereRechnungsDaten(aw, st) resetInvoiceCreateGuardForTests() const ab = await legeAuftragAn({}, patch) const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true }) await voidInvoice(anzahlung.id) assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false) assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet') const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true }) assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') await voidInvoice(schluss.id) assert.equal((await getProjektRechnung(schluss.id)).aktiv, false) assert.equal((await getAuftrag(ab.id)).status, erwartet) // die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000) } }) test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => { const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' } const entwurf = await createProjectInvoice(args) await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') await deleteDraft(entwurf.id) assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false) const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true }) assert.equal(offen.status, 'offen') await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`) await voidInvoice(offen.id) assert.equal((await getProjektRechnung(offen.id)).aktiv, false) assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf') }) test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => { const log = mock.method(console, 'error', () => {}) aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 }) const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true }) assert.equal(rechnung.status, 'offen') assert.equal(callsTo('POST', /\/send$/).length, 1) assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen'))) assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '') const erneut = await sendInvoiceNow(rechnung.id) assert.equal(erneut.status, 'offen') assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number) }) test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => { const ab = await legeAuftragAn() const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) let freigeben const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve })) const senden = sendInvoiceNow(entwurf.id) await new Promise((resolve) => setTimeout(resolve, 200)) assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB') freigeben() await Promise.all([gehalten, senden]) assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') })