Functional Weave
Code in Rust

validation.uk-vat-number@1.0.0

impl/typescript.ts

3,901 bytes · the TypeScript implementation · view raw

/**
 * The weights HMRC applies to the first seven digits, most significant first.
 * They are 8 down to 2, which is why the last two digits (the check pair) get
 * no weight of their own.
 */
const WEIGHTS = [8, 7, 6, 5, 4, 3, 2];

/**
 * The offset HMRC added for registrations issued from roughly 2010 onwards,
 * commonly called the "9755" variant after the constant in their published
 * worked example. A number satisfies exactly one of the two checks, never
 * both, because 55 is not a multiple of 97.
 */
const MODULUS_9755_OFFSET = 55;

/**
 * Uppercase ASCII only.
 *
 * `toUpperCase()` is locale- and Unicode-aware: it turns "ß" into "SS" and
 * would make this function disagree with the Rust sibling, which has no such
 * machinery. Nothing in a VAT number is outside ASCII, so restricting the
 * fold keeps all three languages honest.
 */
function asciiUpper(ch: string): string {
  return ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 32) : ch;
}

/**
 * Is this a valid UK VAT registration number?
 *
 * Nine digits, or twelve for a branch trader, optionally prefixed "GB" and
 * optionally spaced. The last two of the first nine are a mod-97 check pair.
 *
 * A number that passes is arithmetically well formed. It is not proof that
 * the trader is registered, still registered, or registered for the goods on
 * the invoice: only HMRC's VAT number checker can tell you that, and for a
 * reverse-charge or zero-rated supply you are expected to have asked.
 */
export function isUkVatNumber(value: string): boolean {
  if (typeof value !== "string") return false;

  // Only the ASCII space is ignored. Tabs and non-breaking spaces usually
  // arrive from a bad paste, and silently accepting them hides that.
  let compact = "";
  for (const ch of value) {
    if (ch === " ") continue;
    compact += asciiUpper(ch);
  }

  if (compact.startsWith("GB")) compact = compact.slice(2);

  // Nine digits is a standard registration; twelve is a branch trader, where
  // the final three identify the branch and take no part in the checksum.
  if (compact.length !== 9 && compact.length !== 12) return false;

  const digits: number[] = [];
  for (const ch of compact) {
    if (ch < "0" || ch > "9") return false;
    digits.push(ch.charCodeAt(0) - 48);
  }

  // 000000000 satisfies the arithmetic and has never been issued to anyone.
  // Rejecting it costs nothing and stops an all-zero placeholder field from
  // reading as a valid registration.
  let allZero = true;
  for (let i = 0; i < 9; i++) {
    if (digits[i] !== 0) allZero = false;
  }
  if (allZero) return false;

  let total = 0;
  for (let i = 0; i < 7; i++) total += digits[i] * WEIGHTS[i];

  const check = digits[7] * 10 + digits[8];
  return mod97Matches(total, check) || mod97Matches(total + MODULUS_9755_OFFSET, check);
}

/**
 * HMRC's "97 check", written the way HMRC writes it: subtract 97 from the
 * weighted total until the result is zero or negative, then compare the
 * magnitude with the check pair. That is the same as `(-total) mod 97` but
 * this form is what a reviewer can hold against the published guidance.
 */
function mod97Matches(total: number, check: number): boolean {
  let remainder = total;
  while (remainder > 0) remainder -= 97;
  return Math.abs(remainder) === check;
}

/**
 * The canonical form, "GB999 9999 99", or null if the number does not check
 * out. Branch numbers keep their three-digit suffix.
 */
export function formatUkVatNumber(value: string): string | null {
  if (!isUkVatNumber(value)) return null;
  let compact = "";
  for (const ch of value) {
    if (ch === " ") continue;
    compact += asciiUpper(ch);
  }
  if (compact.startsWith("GB")) compact = compact.slice(2);
  const grouped = `GB${compact.slice(0, 3)} ${compact.slice(3, 7)} ${compact.slice(7, 9)}`;
  return compact.length === 12 ? `${grouped} ${compact.slice(9)}` : grouped;
}