Shipmind Labs

A credit note is a type code, not a minus sign

· 8 min read

A refund issued as an invoice with negative amounts puts the direction of the money in the sign of every figure on the document, instead of in the one field a receiver is required to read. EN 16931 puts direction in the type code, BT-3, and gives the refund its own document type. When the sign and the code disagree, the same money can get booked twice, once from the original invoice, and once more by a system that reads a negative invoice as another payable rather than as a reversal.

We build payment and invoicing services, and this is the defect that survives every other fix. The totals reconcile. The VAT breakdown is consistent with the lines. The document is still wrong, because the fact that makes it a refund was never stated, only implied, by arithmetic, across a dozen elements. Our EN 16931 validator, invoicerules, treats that as a rule violation rather than a stylistic preference.

The type code decides which document it is#

In UBL a credit note is not an invoice with a flag set on it. It is a different root element, a different line element, and a different quantity element. In invoicerules that decision is one predicate over one field:

typescript
import { isCreditNote } from "invoicerules";

isCreditNote({ typeCode: "381" }); // true
isCreditNote({ typeCode: "261" }); // true
isCreditNote({ typeCode: "380" }); // false
isCreditNote({ typeCode: "" });    // false

The codes are UNCL1001's, the code list BT-3 draws on; 380 is an invoice, 381 a credit note. toUBL asks this question once, at the top, and then writes a different tree. The test that pins it down reads like the difference itself:

typescript
const xml = toUBL(creditNote());

assert.ok(
  xml.includes(`<CreditNote xmlns="urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"`),
);
assert.ok(xml.includes("<cbc:CreditNoteTypeCode>381</cbc:CreditNoteTypeCode>"));
assert.ok(xml.includes(`<cbc:CreditedQuantity unitCode="C62">2</cbc:CreditedQuantity>`));
assert.ok(xml.includes("<cac:CreditNoteLine>"));
for (const invoiceOnly of ["<cac:InvoiceLine>", "InvoicedQuantity", "InvoiceTypeCode"]) {
  assert.ok(!xml.includes(invoiceOnly), `the credit note carries ${invoiceOnly}`);
}

// The parties, the breakdown and the totals are the same elements as always.
assert.ok(xml.includes("<cbc:CompanyID>PL5260250274</cbc:CompanyID>"));
assert.ok(xml.includes(`<cbc:PayableAmount currencyID="EUR">246.00</cbc:PayableAmount>`));
assert.ok(!/>-\d/.test(xml), "an amount was written with a minus sign");

Three element names change, plus the root. The parties, the VAT breakdown and the monetary totals are written by exactly the code that writes them on an invoice. That is what makes this cheap to implement correctly: a credit note is a different document, not a different mapping, so there is no second mapping to keep in sync with the first.

The last assertion is the one this article is about. No amount anywhere in the output carries a minus sign, not on a line, not in a tax subtotal, not in the totals block.

A negative invoice is the same money twice#

Consider what a receiver has to do with a document whose type code says 380 and whose amounts are all negative. There are two consistent implementations, and both are defensible.

One reads BT-3, sees an invoice, and books a payable. The amounts are negative, so the payable is negative, and if nothing downstream is prepared for a negative payable it becomes a zero, an absolute value, or an exception in a ledger that nobody watches in real time. The other ignores the type code, notices the signs, and treats the document as a reversal of something, but it has to guess what, because a sign is not a reference.

The original invoice has already been booked. Now the refund is booked again, or booked twice, or not at all, depending on which of those two receivers is on the other end. The failure is not that either implementation is wrong; it is that the document did not say which one is correct. Direction was left to be derived, and derivation is where two correct systems disagree.

The type code removes the derivation. It is one field, on one document, in a closed code list, and every receiver reads it in the same place. Amounts then have one job, magnitude, and the sign stops carrying meaning it was never defined to carry.

This matters more under a clearance model than it did when an invoice was an email. A document that fails validation at the platform was never issued; there is nothing to correct, because nothing exists. Failing at our own desk, with a message naming the rule, is better than failing at theirs.

Refusing it before the platform does#

So invoicerules refuses it. A document with an invoice type code and negative amounts fails, and the failure names every element where the sign appears:

typescript
const result = validate(negative);
result.ok; // false

result.violations
  .filter((v) => v.rule === "INVOICERULES-CN-01")
  .map((v) => v.path);
// [
//   "/Invoice/InvoiceLine[1]/LineExtensionAmount",
//   "/Invoice/TaxTotal/TaxSubtotal[1]/TaxableAmount",
//   "/Invoice/LegalMonetaryTotal/LineExtensionAmount",
//   "/Invoice/LegalMonetaryTotal/TaxExclusiveAmount",
//   "/Invoice/LegalMonetaryTotal/TaxInclusiveAmount",
// ]

The message on the first of those says a refund is a credit note, type code 381. Alongside it, BR-27 fires on the negative item net price, because that one is the standard's rule and always was.

Two details about that identifier are deliberate. It is prefixed INVOICERULES- because it is not EN 16931's. Every other rule in the library carries the standard's own identifier, or Peppol's where the standard leaves an arithmetic rule to the syntax binding, precisely so that an error from us and an error from the receiving end name the same thing. A rule of our own must not be mistakable for one a receiver will quote back. And it is reported against each element rather than against the document, because a violation is a place. "This invoice has negative amounts" is a sentence someone has to go and investigate; five paths are five edits.

Flipping the type code to 381 without fixing the amounts does not satisfy the rule either:

typescript
const result = validate({ ...negative, typeCode: "381" });
const violation = result.violations.find((v) => v.rule === "INVOICERULES-CN-01")!;
violation.message; // a credit note states what it credits as a positive amount ...
violation.path;    // "/CreditNote/CreditNoteLine[1]/LineExtensionAmount"

A credit note already means "this is money going back". Stating it again with a minus sign either double-negates or does nothing, and which of the two is another derivation we would be asking a receiver to perform. Allowances behave the same way: a negative allowance amount is refused under BR-31, at /CreditNote/AllowanceCharge[1]/Amount, because an allowance already has a direction in its name.

One rule set, two trees#

The paths above are worth looking at twice. The same rules run against both document types, and they report against the credit note's own elements:

typescript
const result = validate(creditNote());
result.ok; // true
result.coverage.every((entry) => entry.path?.startsWith("/CreditNote")); // true

const wrong = validate(creditNote({ lines: [line("100.00", "250.00")] }));
wrong.violations.find((v) => v.rule === "PEPPOL-EN16931-R120")!.path;
// "/CreditNote/CreditNoteLine[1]/LineExtensionAmount"

A line net amount that does not match price times quantity is the same defect on both documents, checked by the same rule, and quoted back at the path it has in the document that actually gets sent. This is the other half of not duplicating the mapping: we do not maintain a credit-note rule set either. The rules are written against the model; only the path builder knows which tree it is in.

Where a minus sign is still correct#

The rule is not "no negative numbers anywhere". BT-115, the amount due for payment, may be negative, because more can have been prepaid than was owed. That is a balance, not a direction. It is the result of arithmetic the document itself states, and the receiver does not have to infer anything from its sign.

One more asymmetry is worth knowing before it surprises someone: a credit note has no due date element at all.

typescript
const xml = toUBL(creditNote({ dueDate: "2026-09-20", paymentTerms: "Credited to the account" }));
xml.includes("<cbc:DueDate>");                                // false
xml.includes("<cbc:Note>Credited to the account</cbc:Note>"); // true

UBL carries BT-9 on an invoice only. If there is something to say about when and how the money goes back, the payment terms are the place to say it, and the model lets the field through so that callers with one shared document builder do not have to branch. The writer simply does not emit it.

What it costs to run#

Almost nothing, which is the argument for doing it at the boundary rather than in a reconciliation job later. One predicate over one field, one path builder that knows which root it is under, one rule set. The recurring cost is in the tests, and specifically in the assertions about absence: that no invoice-only element name appears in a credit note, and that no amount anywhere carries a minus sign. Those are the assertions that catch a regression the day someone adds an element to the writer and forgets there are two documents.

The habit underneath is probably the same one that keeps money out of floats: do not encode a fact in something that also means something else. Sign means magnitude direction in arithmetic; it does not mean document direction in a tax document, no matter how clear it looks on screen. Put the fact in the field the standard reserved for it, and refuse the document that does not.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com