Shipmind Labs

Where a discount belongs: the invoice or the line, never both

· 10 min read

An invoice can agree with itself in every direction and still be the wrong document. The case that survives validation is a discount granted once and recorded twice, once as a line level allowance and again at document level. The second copy moves every derived total along with it, so there is no arithmetic left for a business rule to object to. The neighbouring mistakes are loud: a discount left out of the line net amount, or a percentage that does not match the base amount it claims to be a percentage of, get refused on sight. The duplicate is quiet. That asymmetry is what turns placement into a design decision rather than a detail.

We build and maintain invoicerules, a TypeScript library that checks a document against the EN 16931 business rules before a tax platform does. This is the failure we point at when a client asks what validation cannot do for them.

Two homes, and they are not interchangeable#

The standard gives a discount two places to live, and the difference is not cosmetic. The model types carry the distinction explicitly:

typescript
/**
 * BG-27 / BG-28: a line level allowance or charge. It carries no VAT category
 * of its own: it belongs to the line, follows the line's category and rate, and
 * is taken off or added on before the line net amount (BT-131) that the totals
 * and the VAT breakdown are built from.
 */
export type LineAllowanceCharge = {
  readonly amount: Amount;
  readonly baseAmount?: Amount;
  readonly percentage?: string;
  readonly reason?: string;
  readonly reasonCode?: string;
};

/**
 * BG-20 / BG-21: a document level allowance or charge. The amount is always
 * positive; which list it is in decides whether it is taken off or added on.
 * It carries a category and a rate because it belongs to a VAT breakdown
 * group, not to the invoice as a whole.
 */
export type AllowanceCharge = {
  readonly amount: Amount;
  readonly baseAmount?: Amount;
  readonly percentage?: string;
  readonly vatCategory: VatCategory;
  readonly vatRate: string;
  readonly reason?: string;
  readonly reasonCode?: string;
};

A line level discount has no category because it does not need one. It is subtracted inside the line, and it reaches the totals and the VAT breakdown only through the line net amount, BT-131, which is quantity times price less the line's allowances and plus its charges. A document level discount carries a category and a rate because it is summed into BT-107 and moves the taxable amount of the breakdown group it names. That is also why the breakdown check insists the group exists at all, and reports a missing one as BR-CO-18 against allowances[n].vatCategory.

So the two placements are two different claims about what was discounted. They happen to agree on the money whenever a single breakdown group is involved, and that coincidence is where the trouble starts.

One discount, four documents#

Take one line: ten units at 100.00 net, standard rate at 23%, and a 50.00 discount granted once. Four ways to record it:

Where the 50.00 goes Line net (BT-131) Allowance total (BT-107) Taxable (BT-116) VAT (BT-117) totals.payable Validator
On the line 950.00 n/a 950.00 218.50 1168.50 passes
At document level 1000.00 50.00 950.00 218.50 1168.50 passes
Both 950.00 50.00 900.00 207.00 1107.00 passes
On the line, but BT-131 not reduced 1000.00 n/a 1000.00 230.00 1230.00 refused

The first two rows are the same invoice. That is the useful fact and the dangerous one: both correct placements produce an identical payable amount, so nobody downstream can tell you which one you meant, and nobody has to.

The fourth row is the easy bug. The line carries an allowance, the stated net amount still equals quantity times price, and the derived value disagrees with the document. The discount hides from the totals and from the VAT breakdown both, so the check on the line net amount refuses the document. It gets reported under the Peppol identifier, because EN 16931 leaves that arithmetic to the syntax binding and the Peppol identifier is the one a receiver will quote back at you.

The third row is the one that ships. The document is internally perfect: the line total matches the lines, the allowance total matches the document level allowances, the taxable amount is the line total less the allowance, the VAT is 23% of that taxable amount, the inclusive total and the payable amount follow. Every one of those is a rule, and every one of them passes. The invoice is 61.50 light (the discount a second time, plus the 23% that was never charged on it), and it describes a sale that did not happen.

What a coverage report can and cannot tell you#

validate returns more than a boolean. You get every rule that was evaluated, how it came out, and the element it looked at, named both as the model field and as the path the element has in the document that gets sent. On the duplicate, the interesting part is the monotony.

typescript
import { validate } from "invoicerules";

const result = validate(duplicated);
result.ok; // true

["BR-CO-10", "BR-CO-11", "BR-CO-13", "BR-CO-14", "BR-CO-15", "BR-CO-16"].map(
  (rule) => result.coverage.find((c) => c.rule === rule)?.outcome,
);
// [ "pass", "pass", "pass", "pass", "pass", "pass" ]

A rule with nothing to check is absent from the report rather than counted as a pass, so the coverage list is an honest account of what was actually looked at. Read it with that in mind and the gap is plain: nothing in it asks whether this allowance and that one describe the same commercial fact. No rule can. BT-107 is a number and BT-131 is a number, and the duplicate makes both of them true.

toUBL throws unless the rules pass, which stops the fourth row from ever leaving the building. It writes the third row without a word.

Decide once, in the builder's type#

If validation cannot refuse the document, then the document has to be impossible to build. We keep one representation of a discount above the library, with a tag that answers the placement question exactly once:

typescript
import type { AllowanceCharge, Amount, Line, LineAllowanceCharge, VatCategory } from "invoicerules";

/** Whole minor units, our representation before the document exists. */
type Minor = number;

const toAmount = (minor: Minor): Amount =>
  `${Math.trunc(minor / 100)}.${String(minor % 100).padStart(2, "0")}`;

type Discount =
  | {
      readonly scope: "document";
      readonly amount: Minor;
      readonly vatCategory: VatCategory;
      readonly vatRate: string;
      readonly reason: string;
    }
  | {
      readonly scope: "line";
      readonly lineId: string;
      readonly amount: Minor;
      readonly reason: string;
    };

type RawLine = {
  readonly id: string;
  readonly name: string;
  readonly quantity: number;
  readonly netPrice: Minor;
  readonly vatCategory: VatCategory;
  readonly vatRate: string;
};

function lineOf(raw: RawLine, discounts: readonly Discount[]): Line {
  const mine = discounts.filter((d) => d.scope === "line" && d.lineId === raw.id);
  const net = mine.reduce((running, d) => running - d.amount, raw.netPrice * raw.quantity);
  const allowances: LineAllowanceCharge[] = mine.map((d) => ({
    amount: toAmount(d.amount),
    reason: d.reason,
  }));
  return {
    id: raw.id,
    name: raw.name,
    quantity: raw.quantity,
    netPrice: toAmount(raw.netPrice),
    netAmount: toAmount(net),
    vatCategory: raw.vatCategory,
    vatRate: raw.vatRate,
    allowances,
  };
}

function documentAllowances(discounts: readonly Discount[]): AllowanceCharge[] {
  return discounts
    .filter((d) => d.scope === "document")
    .map((d) => ({
      amount: toAmount(d.amount),
      vatCategory: d.vatCategory,
      vatRate: d.vatRate,
      reason: d.reason,
    }));
}

The two functions partition the same list. A discount with scope: "line" is read by lineOf and is invisible to documentAllowances, and the reduction that produces netAmount is the same traversal that produces the line's allowance list, so the discount cannot be present in one and missing from the other. Getting the third row of the table now takes two Discount objects, which is a different commercial fact, visible at the point where someone has to type it.

The tag also forces a question the arithmetic was hiding. On a mixed basket the two placements stop agreeing: a document level discount must name a category and a rate, so it either belongs to one breakdown group or has to be split across groups, while a line level discount simply inherits its line's category. "Which group does this discount reduce" has a commercial answer rather than a technical one, and the union is where you record it instead of guessing.

Prove both paths against each other#

The test that earns its keep is probably the differential one. Build the same discount both correct ways and assert the documents agree, then assemble the duplicate by hand and assert that it validates and is still wrong.

typescript
import test from "node:test";
import assert from "node:assert/strict";
import { validate } from "invoicerules";

const basket: readonly RawLine[] = [
  { id: "1", name: "Support retainer", quantity: 10, netPrice: 10_000, vatCategory: "S", vatRate: "23" },
];

test("one discount, two placements, one document", () => {
  const onLine = build(header, basket, [
    { scope: "line", lineId: "1", amount: 5_000, reason: "Volume" },
  ]);
  const atDocument = build(header, basket, [
    { scope: "document", amount: 5_000, vatCategory: "S", vatRate: "23", reason: "Volume" },
  ]);

  assert.ok(validate(onLine).ok);
  assert.ok(validate(atDocument).ok);
  assert.equal(onLine.totals.payable, atDocument.totals.payable);
  assert.equal(onLine.totals.payable, "1168.50");
});

test("the duplicate validates and is still the wrong amount", () => {
  const onLine = build(header, basket, [
    { scope: "line", lineId: "1", amount: 5_000, reason: "Volume" },
  ]);
  const duplicated = withDocumentAllowance(onLine, {
    amount: "50.00",
    vatCategory: "S",
    vatRate: "23",
    reason: "Volume",
  });

  assert.ok(validate(duplicated).ok);
  assert.notEqual(duplicated.totals.payable, onLine.totals.payable);
});

build fills the breakdown with computeVatBreakdown and derives the totals from it, so the first test is checking the whole chain from discount to payable amount. The second test needs a helper to reach around the builder and re-derive the totals, because the duplicate is unreachable through build. That is the result we wanted, written down as a test rather than as a convention. If someone later adds a second path where discounts enter the draft, that assertion is the thing that stops passing.

What it costs to run#

A tagged union, two filters, and a fixture built twice. The ongoing cost is the rule that discounts enter the draft in exactly one place, which is a review habit rather than a test. Our review gate exists to catch that kind of thing, because no validator on either side of the wire will.

Worth keeping separate in your head: the loud placement bugs are the library's job, and the duplicate is yours. The only external check that would catch the third row is the counterparty comparing the payable amount against their own order, and by that point the invoice has been accepted by a platform and cleared, so the correction is a credit note rather than an edit.

Close#

A discount is one fact with two legal spellings. Pick the spelling in the type, derive both the line net amount and the allowance total from that one decision, and keep a validator in front of the send so the spellings that are merely wrong never reach a platform. The document that adds up and still lies is the one you have to make unbuildable.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com