From 3d373240802869ae3d181b9cf67d70335809408d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 26 May 2026 23:54:50 +0200 Subject: [PATCH] feat(crm): allow negative line items for manual discount/Rabatt rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the isInt({ min: 0 }) constraint on lineItems.*.unitPriceMinor in both the adminInvoices and adminQuotes POST/PUT validators so admins can add Treuerabatt / Frühbucherrabatt rows as standalone negative-priced lines (matches standard DE/CH invoice practice). A service-layer guard rejects saves whose computed total goes below zero (INVOICE_TOTAL_NEGATIVE / QUOTE_TOTAL_NEGATIVE, both 400) so a mis-typed discount can't accidentally mint a credit-balance invoice that would masquerade as a regular row in dashboards. Credit notes still belong in the Storno path (createStorno), which is unchanged. Quote-side integration coverage is omitted for now — createQuote's cold-require path takes ~30s under the test harness; the invoice test exercises the same validator + guard shape. --- .../integration/discountLineItems.test.js | 79 +++++++++++++++++++ backend/src/routes/adminInvoices.js | 16 +++- backend/src/routes/adminQuotes.js | 6 +- backend/src/services/invoiceService.js | 12 +++ backend/src/services/quoteService.js | 21 +++++ 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/integration/discountLineItems.test.js diff --git a/backend/__tests__/integration/discountLineItems.test.js b/backend/__tests__/integration/discountLineItems.test.js new file mode 100644 index 00000000..6b222d36 --- /dev/null +++ b/backend/__tests__/integration/discountLineItems.test.js @@ -0,0 +1,79 @@ +/** + * Negative line items (Rabatt / manual discount lines) are accepted + * end-to-end as long as the resulting total stays ≥ 0. When the + * discount would drive the total negative, the service rejects with + * a clear, code-tagged error so the admin is steered to Storno for + * credit-note workflows. + * + * Touches the actual createInvoice / createQuote service paths so a + * future change to either computeTotals or the guard fires this test. + */ + +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// Service-level CRM calls cold-require heavy modules (pdfService, +// nodemailer, etc.) on first use; the global 5 s per-test budget is +// too tight for that. Bump it for this file only. +jest.setTimeout(30000); + +describe('discount line items (negative unit_price_minor)', () => { + let db; + let cleanup; + let adminId; + let customerId; + let invoiceService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + invoiceService = require('../../src/services/invoiceService'); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + // Quote-side coverage of the symmetric validator + guard is + // deliberately omitted: createQuote's init path takes ~30 s under + // this harness (something in pdfService / emailProcessor cold- + // require), which would push the suite well past CI's per-test + // budget. The shape of the guard is identical to the invoice one + // covered below; a future change to extract the slow init or to + // stub it for tests should re-enable a parallel quote test. + + describe('invoices', () => { + it('accepts a negative-price line and computes the net correctly', async () => { + const { invoiceIds } = await invoiceService.createInvoice({ + customerAccountId: customerId, + currency: 'CHF', + vatRate: 0, + lineItems: [ + { position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 }, + { position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 }, + ], + }, adminId); + + expect(Array.isArray(invoiceIds)).toBe(true); + expect(invoiceIds.length).toBe(1); + + const row = await db('invoices').where({ id: invoiceIds[0] }).first(); + expect(row.net_amount_minor).toBe(15000); + expect(row.total_amount_minor).toBe(15000); + }); + + it('rejects when the discount drives the total negative', async () => { + await expect(invoiceService.createInvoice({ + customerAccountId: customerId, + currency: 'CHF', + vatRate: 0, + lineItems: [ + { position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 }, + { position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 }, + ], + }, adminId)).rejects.toMatchObject({ + code: 'INVOICE_TOTAL_NEGATIVE', + statusCode: 400, + }); + }); + }); +}); diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index 51ce2334..c8f4e7c5 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -247,7 +247,11 @@ const INVOICE_BODY_VALIDATORS = [ body('lineItems').optional({ values: 'falsy' }).isArray(), body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }), body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }), - body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + // Negative unit prices are allowed so admins can add manual + // discount / Rabatt lines (e.g. "Treuerabatt -50,00 €"). The + // service-layer total guard rejects invoices whose net goes below + // zero — for credit notes, use Storno instead. + body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt(), body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), // Migration 119: sub-item + details support. Cross-row constraints // (parent must exist, max 1 level deep) are enforced by the service @@ -663,6 +667,16 @@ router.put( updates.shipping_amount_minor = shipping; updates.total_amount_minor = net + vatAmount + shipping; + // Negative line items (Rabatt) are allowed, but the resulting + // invoice total must not go below zero. Credit notes belong in + // the Storno path, not in regular invoice edits. + if (updates.total_amount_minor < 0) { + return res.status(400).json({ + error: 'Invoice total cannot be negative. To issue a credit note, cancel the original invoice with Storno.', + code: 'INVOICE_TOTAL_NEGATIVE', + }); + } + const quoteService = require('../services/quoteService'); const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal; await db.transaction(async (trx) => { diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index 4900c280..417d6dda 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -335,7 +335,11 @@ const QUOTE_BODY_VALIDATORS = [ body('lineItems').optional({ values: 'falsy' }).isArray(), body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }), body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }), - body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + // Negative unit prices are allowed so admins can add manual + // discount / Rabatt lines (e.g. "Treuerabatt -50,00 €"). The + // service-layer total guard rejects quotes whose net goes below + // zero — see quoteService. + body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt(), body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), // Migration 119: sub-item + details support. Cross-row constraints // (parent must exist, max 1 level deep) are enforced by the service diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 2f0f7266..e0b5879b 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -696,6 +696,18 @@ async function createInvoice(payload, adminId, trx = db) { const shippingMinor = ensureInt(payload.shippingAmountMinor); const totalMinor = netMinor + vatMinor + shippingMinor; + // Negative line items (Rabatt) are allowed, but the resulting + // invoice total must not go below zero. Credit notes belong in + // the Storno path (createStorno), which mints a separate + // kind='storno' record with cancels_invoice_id set. + if (totalMinor < 0) { + throw new AppError( + 'Invoice total cannot be negative. To issue a credit note, cancel the original invoice with Storno.', + 400, + 'INVOICE_TOTAL_NEGATIVE', + ); + } + const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); // Snapshot the selected payment-term template (net days / Skonto / diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 9ab6c64e..b93c7528 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -476,6 +476,17 @@ async function createQuote(payload, adminId) { payload.shippingAmountMinor ); + // Negative line items (Rabatt) are allowed, but the resulting + // quote total must not go below zero — a quote represents an + // offer of value, not a credit note. + if (totals.totalAmountMinor < 0) { + throw new AppError( + 'Quote total cannot be negative. Reduce the discount amount.', + 400, + 'QUOTE_TOTAL_NEGATIVE', + ); + } + // Resolve bank account for the chosen currency. const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); @@ -586,6 +597,16 @@ async function updateQuote(id, payload, adminId) { payload.shippingAmountMinor ?? existing.shipping_amount_minor ); + // Negative line items (Rabatt) are allowed, but the resulting + // quote total must not go below zero. See createQuote. + if (totals.totalAmountMinor < 0) { + throw new AppError( + 'Quote total cannot be negative. Reduce the discount amount.', + 400, + 'QUOTE_TOTAL_NEGATIVE', + ); + } + return await db.transaction(async (trx) => { const updates = { updated_at: new Date(),