feat(crm): allow negative line items for manual discount/Rabatt rows
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.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user