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(),