Shipmind Labs

The payment means code decides which account rules fire

· 10 min read

An invoice can pass every arithmetic rule in EN 16931 and still be a document nobody can pay. The totals reconcile, the VAT breakdown gets computed rather than summarised, the validator says yes, and the payment instruction names no account at all, or names one with a transposed pair of characters that sends the money to a different bank while the document stays structurally perfect. The payment part of an invoice answers to a different logic from the money part: what an instruction has to carry follows from the means code it declares, not from a fixed list of required fields.

We spend a lot of our time on the other side of that document, payment services on card rails and account-to-account flows, ledgering, attribution of incoming payments by requisites, reconciliation against what the bank actually did. An invoice that reconciles but cannot be paid is not a validation curiosity. It is a support ticket, a late payment, and in a clearance regime a document that was never issued. So in invoicerules the payment instruction gets its own pass, validatePayment in src/rules.ts, and that pass is conditional by design.

The declaration is what creates the obligation#

Here is all of it, one instruction at a time:

typescript
instructions.forEach((means, index) => {
  const at = `paymentMeans[${index}]`;
  const code = means.typeCode?.trim() ?? "";
  check("BR-49", !!code, `${at}.typeCode`,
    "the payment instruction gives no payment means code (BT-81)");

  const account = means.creditTransfer?.accountId?.trim();
  if (means.creditTransfer) {
    check("BR-50", !!account, `${at}.creditTransfer.accountId`,
      "the credit transfer names no account for the money to go to (BT-84)");
  }
  if (CREDIT_TRANSFER_CODES.has(code)) {
    check("BR-61", !!account, `${at}.creditTransfer.accountId`,
      `payment means ${code} is a credit transfer, so the account the money goes to (BT-84) has to be given`);
  }
  if (account && looksLikeIban(account)) {
    check(IBAN_CHECK_DIGITS, ibanChecksumHolds(account), `${at}.creditTransfer.accountId`,
      `the account identifier "${account}" is in IBAN shape but its check digits do not hold`);
  }
});

Three rules, and only one of them runs unconditionally. BR-49 applies every time: an instruction with a blank typeCode says nothing about how it is to be paid. BR-50 runs only when a creditTransfer block is present, so if you opened the group, it has to name an account. BR-61 runs only when the trimmed code is one of CREDIT_TRANSFER_CODES, which is "30", "31" and "58".

That asymmetry is doing the work, and it produces an outcome worth internalising. An instruction with typeCode: "30" and no creditTransfer block at all fails BR-61 alone. BR-50 never runs, because there is no group to be incomplete, there is nothing there. The document declared a credit transfer and then declined to say where the money goes, and the rule that catches it is keyed to the code, not to the group. Had we written the check the other way round, as "a payment instruction must have an account", the same defect would have been reported against a field the sender never wrote, and a cash or card instruction would have been rejected for the same missing field it has no business carrying.

The model keeps the same shape, with the code first and the group optional:

typescript
export type PaymentMeans = {
  /** BT-81: UNCL4461 payment means code. 30 is a credit transfer. */
  readonly typeCode: string;
  /** BT-82: the same means in words. */
  readonly name?: string;
  /** BT-83: the reference the payer quotes, so the money can be matched back. */
  readonly remittanceInformation?: string;
  /** BG-17: the account, for a credit transfer. */
  readonly creditTransfer?: CreditTransfer;
};

The payee follows the same discipline one level up. BR-17 asks for a name only when invoice.payee exists: naming someone other than the seller as the recipient of the money is optional, but once you have done it, saying who they are is not. A conditional rule does the same job as any other rule. Its trigger simply sits in the document.

IBAN shape earns a free test; anything else is left alone#

An account identifier in IBAN shape carries its own check digits, so a transposition is detectable locally, with no network call and no reference data:

typescript
/** Two letters, two check digits, then the account. Anything else is not an IBAN. */
function looksLikeIban(value: string): boolean {
  return /^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(compact(value));
}

/** ISO 7064 mod 97-10: the first four characters move to the end, letters become numbers. */
function ibanChecksumHolds(value: string): boolean {
  const account = compact(value);
  if (account.length < 15 || account.length > 34) return false;

  let remainder = 0;
  for (const character of account.slice(4) + account.slice(0, 4)) {
    const digits = /[A-Z]/.test(character) ? String(character.charCodeAt(0) - 55) : character;
    for (const digit of digits) remainder = (remainder * 10 + Number(digit)) % 97;
  }
  return remainder === 1;
}

function compact(value: string): string {
  return value.replace(/\s+/g, "").toUpperCase();
}

compact strips whitespace and uppercases first, because an IBAN printed in groups of four is the same identifier as one printed solid, and a document that happens to preserve the spacing should not be treated as a different document. The checksum then moves the first four characters to the end, maps letters through charCodeAt minus 55, and reduces modulo 97 digit by digit, so no intermediate value ever comes near a float. Lengths outside 15 to 34 are refused before any of that runs.

The interesting half is the branch that does nothing. An identifier that does not match the IBAN shape is left alone. No guessing, no "probably a local account number, let us try a national checksum". A failure here is reported as INVOICERULES-IBAN-01, and the prefix is deliberate: no receiving platform is ever going to quote that back at you, so it must not look like something they would. Standard rules keep the standard's identifiers precisely so that our error and the platform's error name the same thing, and a check of our own has to look visibly ours.

One field, two elements, depending on the document#

The due date is where payment validation stops being about payment and becomes about where the value can physically be written. UBL gives an invoice a root DueDate element. A credit note has none, because BT-9 lives inside the first payment instruction. src/paths.ts carries both:

typescript
dueDate: syntax.dueDate
  ? `${root}/${syntax.dueDate}`
  : `${root}/PaymentMeans[1]/PaymentDueDate`,

Which means a credit note that has a due date and no payment instruction has nowhere to put it, and toUBL will drop it silently. The rules say so instead:

typescript
if (invoice.dueDate !== undefined) {
  if (!isDate(invoice.dueDate)) {
    fail(DUE_DATE, `the due date "${invoice.dueDate}" is not a date`, "dueDate");
  } else if (isDate(invoice.issueDate)) {
    check(DUE_DATE, invoice.dueDate >= invoice.issueDate, "dueDate",
      `the due date ${invoice.dueDate} is before the issue date ${invoice.issueDate}`);
  }
  // UBL keeps a credit note's BT-9 inside the payment instruction, so without
  // one there is nowhere to write it and the date is silently lost.
  if (creditNote && instructions.length === 0) {
    warn(DUE_DATE,
      "a credit note carries its due date inside a payment instruction (BG-16); with none, the due date will not be written",
      "dueDate");
  }
}

Note the ordering guard: the comparison against issueDate runs only when issueDate is itself a date, so a malformed issue date gets reported once, as its own defect, instead of producing a second confusing complaint about the order between two values one of which is not a value. And the credit-note case is a warning, not a fatal: the document is valid, it will simply arrive carrying less than you wrote.

The rules we do not run are published too#

A coverage report that lists every rule that was evaluated, how it came out and which element it looked at is only honest if the inverse is available too. A rule with nothing to check is absent from coverage rather than counted as a pass, and that is the first half. The second half is the set of rules we deliberately do not attempt, exported as UNCHECKED_PAYMENT_RULES: five UncheckedRule entries, each with a rule, an about and a note.

Identifier Why a library on its own cannot settle it
BR-CO-25 Due date or payment terms when an amount is payable, and nothing in our pass demands either.
BR-51 Card payment digits: the model represents no payment card group, so there is nothing to validate.
PEPPOL-EN16931-R061 Direct debit mandate reference: same story, the instruction we model is a credit transfer.
UNCL4461 The means code is checked for being present, not for membership in the code list.
ISO 13616 Country-specific IBAN length and account structure, beyond shape and check digits.

This costs almost nothing to ship and it is the part we would defend hardest. "Supports payment validation" is a claim that can quietly mean half of payment validation, and the half it leaves out is the half that bites: a means code that is a plausible-looking number nobody allocated, an IBAN whose check digits hold but whose length is wrong for its country. An integrator reading UNCHECKED_PAYMENT_RULES knows what still needs a registry, a provider, or a human. An integrator reading a green tick does not.

What it costs to run#

The conditional structure costs one set membership test and two optional-chained reads per instruction. The checksum is a single pass over at most 34 characters, with no network call, no IBAN registry to keep current, and no state, so it is cheap enough to run on every validation rather than being deferred to a "deep check" nobody enables. The shape test is what bounds the false confidence: it refuses to opine on identifiers it cannot reason about, so the only identifiers we reject are the ones that claimed to be checkable and then failed.

The real maintenance cost is the unchecked list. It is a promise, and promises rot: the day one of those five becomes checkable, the entry has to move from data into code, and the day our model grows a payment card group, BR-51 stops being unimplementable and becomes unimplemented. Keeping that list truthful is probably more work than keeping the checks passing.

The same instinct runs through the totals work we have written about before: round once on the line, derive everything from the rounded lines, leave no field for a plausible-looking wrong value to sit in. Here the equivalent move is to let the document's own declaration decide which rules apply to it, and to say out loud which rules we never asked.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com