From d264f2af5a87f69fecfacdca3abd102d9db7afba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:16:48 +0000 Subject: [PATCH] Rechnungen: Projektrechnungen bei Stripe (Zusatzfelder, A4, Minuszeilen, Projektdatensatz) und Vorschau Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 3 +- server/services/finance/stripeFinance.js | 103 +++++++++------------ test/auftraegeStripe.test.js | 108 +++++++++++++++++++++++ 3 files changed, 151 insertions(+), 63 deletions(-) diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 43d66a9..f48008e 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -1,6 +1,7 @@ import { Router } from 'express' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { withLocks } from '../../services/keyedLock.js' +import { AuftragError } from '../../services/auftraege/fehler.js' import { EnableBankingError } from '../../services/finance/enableBanking.js' import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js' import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js' @@ -148,7 +149,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW * Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben. */ function sendFinanceError(res, what, err) { - if (err instanceof AssignError || err instanceof InvoiceError) { + if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) { // Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll if (err.status === 502 && err.cause) logFailure(what, err.cause) const body = { error: err.message } diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 85c584e..468a5f1 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -1,9 +1,10 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' -import { getDocument } from '../appwriteAdmin.js' import { addDays, berlinDate, daysBetween } from './dates.js' import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js' +import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js' +import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' /** @@ -132,11 +133,6 @@ export async function listPayouts({ force = false } = {}) { // Projektrechnungen schreiben // --------------------------------------------------------------------------- -const MAX_ITEMS = 20 -const MAX_DESCRIPTION = 200 -const MAX_QUANTITY = 999 -const MAX_UNIT_CENTS = 10_000_000 -const MAX_TOTAL_CENTS = 99_999_999 const MAX_DAYS_UNTIL_DUE = 60 const DEFAULT_DAYS_UNTIL_DUE = 14 const MAX_MEMO = 1000 @@ -208,45 +204,6 @@ export function invoiceFooter(bankDetailsText = '') { const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max -/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */ -function validateItems(items) { - if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) { - throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`) - } - const lines = items.map((item, index) => { - const label = `Position ${index + 1}` - const description = typeof item?.description === 'string' ? item.description.trim() : '' - if (description.length < 1 || description.length > MAX_DESCRIPTION) { - throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`) - } - if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) { - throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`) - } - if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) { - throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`) - } - return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents } - }) - const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0) - if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.') - return lines -} - -/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ -async function loadBillableCustomer(customerId) { - if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.') - let customer - try { - customer = await getDocument(config.collections.customers, customerId) - } catch (err) { - if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.') - throw err - } - const stage = String(customer.customerStatus || '').trim().toLowerCase() - if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.') - return customer -} - /** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */ async function retrieveInvoice(invoiceId) { if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.') @@ -344,14 +301,20 @@ function validateIdempotencyKey(key) { */ export async function createProjectInvoice({ customerId, + art = 'frei', + projektId, + auftragId, items, + arbeitsblattIds, + anzahlung, + zeilentexte, + leistungszeitraum, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, idempotencyKey, createdBy, }) { - const lines = validateItems(items) if (!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.`) } @@ -359,10 +322,15 @@ export async function createProjectInvoice({ if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`) const key = validateIdempotencyKey(idempotencyKey) - const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex') + // Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400) + const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds + const fingerprint = createHash('sha256') + .update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true])) + .digest('hex') claimCreate(fingerprint) try { - const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy }) + const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy } + const invoice = await createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key }) releaseCreate(fingerprint, true) return invoice } catch (err) { @@ -372,11 +340,21 @@ export async function createProjectInvoice({ } } -async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) { - const customer = await loadBillableCustomer(customerId) +/** + * Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch + * Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre. + */ +export async function rechnungsVorschau(eingabe) { + const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe) + return { positionen, summeCents, leistungszeitraum, customFields } +} + +async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) { + const entwurf = await baueRechnung(eingabe) + 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) - if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)) + if (senden) assertAddressForSend(customer, entwurf.summeCents) const { bankDetailsText } = await getSettings() const stripe = getStripe() @@ -395,22 +373,23 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke currency: 'eur', footer: invoiceFooter(bankDetailsText), description: note || undefined, - metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) }, + metadata: entwurf.metadata, + custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined, + rendering: { pdf: { page_size: 'a4' } }, }, key ? { idempotencyKey: key } : undefined ) invoiceId = draft.id // der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen - for (const line of lines) { - await stripe.invoiceItems.create({ - customer: stripeCustomerId, - invoice: invoiceId, - description: line.description, - quantity: line.quantity, - unit_amount_decimal: String(line.unitAmountCents), - currency: 'eur', - }) + for (const position of entwurf.positionen) { + const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung } + // Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity + if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents }) + else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) }) + posten.currency = 'eur' + await stripe.invoiceItems.create(posten) } + if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung) } catch (err) { // Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {}) @@ -454,7 +433,7 @@ export async function sendInvoiceNow(invoiceId) { // Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon // fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet. let customer = null - if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId) + if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId) // Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim() if (!email) throw httpError(400, NO_EMAIL_MESSAGE) diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js index b617218..c81715d 100644 --- a/test/auftraegeStripe.test.js +++ b/test/auftraegeStripe.test.js @@ -55,3 +55,111 @@ test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder fr 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') +})