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;
}