diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index bc76d4d..92739e8 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -109,11 +109,13 @@ 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 const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.' +const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.' function httpError(status, message) { const err = new Error(message) @@ -134,7 +136,7 @@ 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.`) } - return items.map((item, index) => { + 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) { @@ -148,6 +150,9 @@ function validateItems(items) { } 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. */ @@ -178,17 +183,30 @@ async function readInvoice(invoiceId) { return mapInvoice(raw, { today: berlinDate() }) } -// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden +// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das +// Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der +// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen. async function finalizeAndSend(stripe, invoiceId) { - await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) - await stripe.invoices.sendInvoice(invoiceId) + const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) + try { + await stripe.invoices.sendInvoice(invoiceId) + } catch (cause) { + const number = finalized.number || '' + const err = httpError(502, `Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`) + err.invoiceId = invoiceId + err.invoiceNumber = number + err.cause = cause + throw err + } } /** * Projektrechnung bei Stripe anlegen: Entwurf mit den Positionen (Menge und Einzelpreis je Position), Zahlungsziel * in Tagen, Rechnungsfuss mit Kleinunternehmer-Hinweis und Bankdaten. Mit senden=true wird sie sofort finalisiert und - * per E-Mail gesendet (offen), sonst bleibt sie ein Entwurf. Fehler mit status 400 bei ungueltigen Eingaben oder - * einem Kunden, der noch Lead/verloren ist. + * per E-Mail gesendet (offen), sonst bleibt sie ein Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch + * Summe ueber 99.999.999 Cent), einem Kunden, der noch Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true + * (alles vor dem ersten Stripe-Aufruf). Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und + * invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow). */ export async function createProjectInvoice({ customerId, items, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, createdBy, bankDetailsText = '' }) { const lines = validateItems(items) @@ -198,6 +216,8 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D const note = typeof memo === 'string' ? memo.trim() : '' if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`) const customer = await loadBillableCustomer(customerId) + // Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen + if (senden === true && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) const stripe = getStripe() const stripeCustomerId = await getOrCreateStripeCustomer(customer) @@ -241,14 +261,25 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D } } -/** Entwurf finalisieren (ohne Auto-Advance) und senden. Nur Projekt-Entwuerfe, sonst status 409. */ +/** + * Projektrechnung senden: ein Entwurf wird finalisiert (ohne Auto-Advance) und gesendet; eine offene oder ueberfaellige + * wird nur erneut gesendet (Erinnerung, kein Finalisieren). Fehler: 409 bei Abo-Rechnungen (verwaltet Stripe) und bei + * bezahlten/stornierten/uneinbringlichen, 400 ohne E-Mail-Adresse des Kunden, 404 unbekannt. Scheitert beim Entwurf + * nur das Senden nach dem Festschreiben: status 502 mit invoiceId und invoiceNumber (die Rechnung ist dann offen und + * kann erneut gesendet werden). + */ export async function sendInvoiceNow(invoiceId) { const invoice = await readInvoice(invoiceId) // Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) - if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gesendet werden.') + const isDraft = invoice.status === 'entwurf' + if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') { + throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.') + } + if (!String(invoice.customerEmail || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) try { - await finalizeAndSend(getStripe(), invoice.id) + if (isDraft) await finalizeAndSend(getStripe(), invoice.id) + else await getStripe().invoices.sendInvoice(invoice.id) return await readInvoice(invoice.id) } finally { invalidateFinanceCache() diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js index 2f4317d..d1e440f 100644 --- a/test/financeStripeWrite.test.js +++ b/test/financeStripeWrite.test.js @@ -24,6 +24,11 @@ aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Inter aw.seed('customers', { $id: 'lost1', email: 'lost@example.invalid', name: 'Verloren', customerStatus: 'Lost', status: 'active' }) aw.seed('customers', { $id: 'act1', email: 'act@example.invalid', name: 'Aktiv', companyName: 'Aktiv GmbH', customerStatus: 'active', status: 'active' }) +aw.seed('customers', { $id: 'noMail', name: 'Ohne Mail', customerStatus: 'active', status: 'active' }) +aw.seed('customers', { $id: 'blankMail', email: ' ', name: 'Leere Mail', customerStatus: 'active', status: 'active' }) + +const NO_MAIL = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.' +const NOT_SENDABLE = 'Diese Rechnung kann nicht mehr gesendet werden.' const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' const BASE_ITEMS = [ { description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 }, @@ -228,12 +233,30 @@ test('createProjectInvoice: Eingaben werden geprueft (Positionen, Texte, Mengen, assert.equal(writes().length, 0, 'bei ungueltigen Eingaben darf nichts an Stripe gehen') }) -test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, 10.000.000 Cent, Ziel 60)', async () => { - const items = Array.from({ length: 20 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 10_000_000 })) +test('createProjectInvoice: Rechnungssumme hoechstens 99.999.999 Cent, genau die Grenze ist erlaubt', async () => { + const TOO_HIGH = 'Rechnungssumme zu hoch.' + // eine Position allein ueber der Grenze (999 x 10.000.000) und mehrere zusammen (9 x 10.000.000 + 10.000.000) + await rejectsWith(createProjectInvoice({ customerId: 'c1', items: [{ description: 'Gross', quantity: 999, unitAmountCents: 10_000_000 }], createdBy: 'k' }), 400, TOO_HIGH) + const nine = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 })) + await rejectsWith(createProjectInvoice({ customerId: 'c1', items: [...nine, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }], createdBy: 'k' }), 400, TOO_HIGH) + // vor jedem Stripe-Aufruf, auch vor dem Anlegen des Stripe-Kunden + assert.equal(st.calls.length, 0) + + // genau 99.999.999 Cent (9 x 10.000.000 + 9.999.999) + const result = await createProjectInvoice({ customerId: 'c1', items: [...nine, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }], createdBy: 'k' }) + assert.equal(result.totalCents, 99_999_999) +}) + +test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, Einzelpreis 10.000.000 Cent, Ziel 60)', async () => { + // 19 Positionen mit Menge 999, dazu eine mit dem hoechsten Einzelpreis: zusammen unter der Summengrenze + const items = Array.from({ length: 19 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 1000 })) + items.push({ description: 'Teuer', quantity: 1, unitAmountCents: 10_000_000 }) const result = await createProjectInvoice({ customerId: 'c1', items, daysUntilDue: 60, createdBy: 'k' }) assert.equal(callsTo('POST', '/v1/invoiceitems').length, 20) - assert.equal(result.totalCents, 20 * 999 * 10_000_000) + assert.equal(result.totalCents, 19 * 999 * 1000 + 10_000_000) assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '60') + assert.equal(callsTo('POST', '/v1/invoiceitems')[19].body.unit_amount_decimal, '10000000') + assert.equal(callsTo('POST', '/v1/invoiceitems')[0].body.quantity, '999') }) test('createProjectInvoice: scheitert eine Position, wird der halbfertige Entwurf geloescht', async () => { @@ -256,12 +279,80 @@ test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet' assert.match(sent.number, /^WK-\d{4}$/) }) -test('sendInvoiceNow: nur Entwuerfe (409), nur Projektrechnungen, unbekannte Rechnung 404', async () => { - st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001' })) +test('createProjectInvoice: senden=true ohne E-Mail-Adresse des Kunden -> 400 vor jedem Stripe-Aufruf, als Entwurf geht es', async () => { + for (const customerId of ['noMail', 'blankMail']) { + await rejectsWith(createProjectInvoice({ customerId, items: BASE_ITEMS, senden: true, createdBy: 'k' }), 400, NO_MAIL) + } + assert.equal(st.calls.length, 0) + const draft = await createProjectInvoice({ customerId: 'noMail', items: BASE_ITEMS, createdBy: 'k' }) + assert.equal(draft.status, 'entwurf') +}) + +test('createProjectInvoice: scheitert das Senden nach dem Festschreiben -> 502 mit Rechnungs-ID und -Nummer, Rechnung bleibt offen', async () => { + st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' }) + const err = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' }).then( + () => assert.fail('haette scheitern muessen'), + (e) => e + ) + assert.equal(err.status, 502) + const [stored] = [...st.invoices.values()] + assert.equal(err.invoiceId, stored.id) + assert.equal(err.invoiceNumber, stored.number) + assert.equal(err.message, `Rechnung ${stored.number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`) + assert.equal(stored.status, 'open') + // Zwischenspeicher geleert: die Liste zeigt die offene Rechnung sofort + assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[stored.id, 'offen']]) + + // erneutes Senden: nur "send", nicht noch einmal finalisieren, keine zweite Rechnung + st.calls.length = 0 + const resent = await sendInvoiceNow(err.invoiceId) + assert.deepEqual(writes().map((c) => c.path.replace(err.invoiceId, ':id')), ['/v1/invoices/:id/send']) + assert.equal(resent.status, 'offen') + assert.equal(resent.number, stored.number) + assert.equal(st.invoices.size, 1) +}) + +test('sendInvoiceNow: scheitert das Senden eines Entwurfs nach dem Festschreiben -> 502 mit Nummer', async () => { + const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) + st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' }) + const err = await sendInvoiceNow(draft.id).then( + () => assert.fail('haette scheitern muessen'), + (e) => e + ) + assert.equal(err.status, 502) + assert.equal(err.invoiceId, draft.id) + assert.match(err.invoiceNumber, /^WK-\d{4}$/) + assert.equal(err.message, `Rechnung ${err.invoiceNumber} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`) + assert.equal(st.invoices.get(draft.id).status, 'open') + assert.equal(callsTo('POST', /\/finalize$/).length, 1) + // der Versand-Fehler einer reinen Erinnerung (offen) kommt dagegen unveraendert von Stripe + st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' }) + await assert.rejects(sendInvoiceNow(draft.id), (e) => e.status === undefined && /Versand abgelehnt/.test(e.message)) +}) + +test('sendInvoiceNow: offene und ueberfaellige Projektrechnungen werden nur erneut gesendet (kein Finalisieren)', async () => { + st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001', due_date: Math.floor(Date.now() / 1000) + 5 * 86_400 })) + st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0002', due_date: Math.floor(Date.now() / 1000) - 5 * 86_400 })) + assert.equal((await sendInvoiceNow('in_open')).status, 'offen') + assert.equal((await sendInvoiceNow('in_late')).status, 'ueberfaellig') + assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_open/send', '/v1/invoices/in_late/send']) +}) + +test('sendInvoiceNow: bezahlt/storniert/uneinbringlich -> 409, Abo-Rechnungen -> 409, ohne E-Mail -> 400, unbekannt -> 404', async () => { + for (const status of ['paid', 'void', 'uncollectible']) st.invoices.set(`in_${status}`, projectInvoice({ id: `in_${status}`, status, number: 'WK-0003' })) st.invoices.set('in_abo', aboInvoice({ status: 'draft', number: null })) - await rejectsWith(sendInvoiceNow('in_open'), 409, 'Nur Entwürfe können gesendet werden.') + st.invoices.set('in_abo_open', aboInvoice({ id: 'in_abo_open' })) + for (const status of ['paid', 'void', 'uncollectible']) await rejectsWith(sendInvoiceNow(`in_${status}`), 409, NOT_SENDABLE) await rejectsWith(sendInvoiceNow('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.') + await rejectsWith(sendInvoiceNow('in_abo_open'), 409, 'Abo-Rechnungen verwaltet Stripe.') await rejectsWith(sendInvoiceNow('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.') + + // Kunde ohne E-Mail-Adresse: Entwurf und offene Rechnung scheitern mit 400 vor jedem Schreibzugriff + st.customers.set('cus_nomail', { id: 'cus_nomail', object: 'customer', name: 'Ohne Mail', email: null, metadata: {} }) + st.invoices.set('in_nomail_draft', projectInvoice({ id: 'in_nomail_draft', customer: 'cus_nomail' })) + st.invoices.set('in_nomail_open', projectInvoice({ id: 'in_nomail_open', customer: 'cus_nomail', status: 'open', number: 'WK-0004' })) + await rejectsWith(sendInvoiceNow('in_nomail_draft'), 400, NO_MAIL) + await rejectsWith(sendInvoiceNow('in_nomail_open'), 400, NO_MAIL) assert.equal(writes().length, 0) })