From f6cc210598051cd477b94264bdeeffc7c4cf4726 Mon Sep 17 00:00:00 2001 From: Julien Benac Date: Mon, 24 Nov 2025 15:27:34 +0100 Subject: [PATCH 1/3] feat(rules): add `vat` rule --- src/defaults.ts | 1 + src/schema/string/main.ts | 9 +++++++++ src/schema/string/rules.ts | 18 ++++++++++++++++++ src/types.ts | 15 +++++++++++++++ src/vine/helpers.ts | 3 +++ 5 files changed, 46 insertions(+) diff --git a/src/defaults.ts b/src/defaults.ts index bbf6e84..80e28f8 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -60,6 +60,7 @@ export const messages = { 'in': 'The selected {{ field }} is invalid', 'notIn': 'The selected {{ field }} is invalid', 'ipAddress': 'The {{ field }} field must be a valid IP address', + 'vat': 'The {{ field }} field must be a valid VAT number', 'uuid': 'The {{ field }} field must be a valid UUID', 'ulid': 'The {{ field }} field must be a valid ULID', 'hexCode': 'The {{ field }} field must be a valid hex color code', diff --git a/src/schema/string/main.ts b/src/schema/string/main.ts index 8d6936d..5232312 100644 --- a/src/schema/string/main.ts +++ b/src/schema/string/main.ts @@ -55,6 +55,7 @@ import { normalizeUrlRule, alphaNumericRule, normalizeEmailRule, + vatRule, } from './rules.js' /** @@ -102,6 +103,7 @@ export class VineString extends BaseLiteralType { minLength: minLengthRule, notSameAs: notSameAsRule, maxLength: maxLengthRule, + vat: vatRule, ipAddress: ipAddressRule, creditCard: creditCardRule, postalCode: postalCodeRule, @@ -184,6 +186,13 @@ export class VineString extends BaseLiteralType { return this.use(mobileRule(...args)) } + /** + * Validates the value to be a valid VAT number. + */ + vat(...args: Parameters) { + return this.use(vatRule(...args)) + } + /** * Validates the value to be a valid IP address. */ diff --git a/src/schema/string/rules.ts b/src/schema/string/rules.ts index 3173d06..68c202c 100644 --- a/src/schema/string/rules.ts +++ b/src/schema/string/rules.ts @@ -27,6 +27,7 @@ import type { NormalizeUrlOptions, AlphaNumericOptions, NormalizeEmailOptions, + VATOptions, } from '../../types.js' /** @@ -382,6 +383,23 @@ export const passportRule = createRule< } }) +/** + * Validates the value to be a valid VAT number. + */ +export const vatRule = createRule VATOptions)>( + function vat(value, options, field) { + const countryCodes = + typeof options === 'function' ? options(field).countryCode : options.countryCode + + const matchesAnyCountryCode = countryCodes.find((countryCode) => + helpers.isVAT(value as string, countryCode) + ) + if (!matchesAnyCountryCode) { + field.report(messages.vat, 'vat', field, { countryCodes }) + } + } +) + /** * Validates the value to be a valid postal code */ diff --git a/src/types.ts b/src/types.ts index e2bcf67..bc02ed5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import type { Options as UrlOptions } from 'normalize-url' import type { IsURLOptions } from 'validator/lib/isURL.js' import type { IsEmailOptions } from 'validator/lib/isEmail.js' import type { PostalCodeLocale } from 'validator/lib/isPostalCode.js' +import type { VATCountryCode } from 'validator/lib/isVAT.js' import type { NormalizeEmailOptions } from 'validator/lib/normalizeEmail.js' import type { IsMobilePhoneOptions, MobilePhoneLocale } from 'validator/lib/isMobilePhone.js' import type { @@ -160,6 +161,20 @@ export type PostalCodeOptions = { countryCode: PostalCodeLocale[] } +/** + * Options accepted by the VAT number validation rule. + * Specifies which country codes are used for VAT number format validation. + * + * @example + * const options: VATOptions = { + * countryCode: ['FR', 'CH', 'VE'] + * } + */ +export type VATOptions = { + /** Array of country codes for VAT number validation */ + countryCode: VATCountryCode[] +} + /** * Options accepted by the alpha validation rule. * Controls which additional characters are allowed in alphabetic validation. diff --git a/src/vine/helpers.ts b/src/vine/helpers.ts index 30da959..c300581 100644 --- a/src/vine/helpers.ts +++ b/src/vine/helpers.ts @@ -26,6 +26,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter.js' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore.js' import isAlphanumeric from 'validator/lib/isAlphanumeric.js' import isPassportNumber from 'validator/lib/isPassportNumber.js' +import isVAT from 'validator/lib/isVAT.js' import customParseFormat from 'dayjs/plugin/customParseFormat.js' import isPostalCode, { type PostalCodeLocale } from 'validator/lib/isPostalCode.js' import isMobilePhone, { type MobilePhoneLocale } from 'validator/lib/isMobilePhone.js' @@ -447,6 +448,8 @@ export const helpers = { isMobilePhone: isMobilePhone.default, /** Validates passport numbers for supported countries */ isPassportNumber: isPassportNumber.default, + /** Validates VAT numbers for supported countries */ + isVAT: isVAT.default, /** Validates postal codes for various countries */ isPostalCode: isPostalCode.default, /** Validates URL slugs (lowercase, hyphenated strings) */ From 7ec393a1806ce6b6adc90bbec8886abf754dccee Mon Sep 17 00:00:00 2001 From: Julien Benac Date: Mon, 24 Nov 2025 15:28:17 +0100 Subject: [PATCH 2/3] test(rules): add `vat` unit tests for various country codes --- tests/unit/rules/string.spec.ts | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/unit/rules/string.spec.ts b/tests/unit/rules/string.spec.ts index f5235cb..a172f40 100644 --- a/tests/unit/rules/string.spec.ts +++ b/tests/unit/rules/string.spec.ts @@ -46,6 +46,7 @@ import { toCamelCaseRule, escapeRule, normalizeUrlRule, + vatRule, } from '../../../src/schema/string/rules.ts' import type { FieldContext, Validation } from '../../../src/types.ts' @@ -1607,3 +1608,97 @@ test.group('String | normalizeUrl', () => { ]) .run(stringRuleValidator) }) + +test.group('String | vat', () => { + test('validate {value}') + .with([ + { + errorsCount: 1, + rule: vatRule({ countryCode: ['FR'] }), + value: 22, + error: 'The dummy field must be a string', + }, + { + errorsCount: 1, + rule: vatRule({ countryCode: ['FR'] }), + value: 22, + bail: false, + error: 'The dummy field must be a string', + }, + { + errorsCount: 1, + rule: vatRule({ countryCode: ['FR'] }), + value: 'FR3255208', + error: 'The dummy field must be a valid VAT number', + }, + { + errorsCount: 0, + rule: vatRule({ countryCode: ['FR'] }), + value: 'FR32552081317', + }, + { + errorsCount: 1, + rule: vatRule({ countryCode: ['FR'] }), + value: 'GB980780684', + error: 'The dummy field must be a valid VAT number', + }, + { + errorsCount: 0, + rule: vatRule({ countryCode: ['DE'] }), + value: 'DE136695976', + }, + { + errorsCount: 1, + rule: vatRule({ countryCode: ['IT'] }), + value: 'DE136695976', + error: 'The dummy field must be a valid VAT number', + }, + { + errorsCount: 1, + rule: vatRule(() => { + return { countryCode: ['FR'] } + }), + value: 'FR3255208', + error: 'The dummy field must be a valid VAT number', + }, + { + errorsCount: 0, + rule: vatRule(() => { + return { countryCode: ['FR'] } + }), + value: 'FR32552081317', + }, + { + errorsCount: 0, + rule: vatRule({ countryCode: ['FR', 'DE', 'IT'] }), + value: 'IT12345678901', + }, + { + errorsCount: 0, + rule: vatRule({ countryCode: ['GB', 'ES', 'NL'] }), + value: 'ES12345678Z', + }, + { + errorsCount: 1, + rule: vatRule({ countryCode: ['GB', 'ES', 'NL'] }), + value: 'FR32552081317', + error: 'The dummy field must be a valid VAT number', + }, + { + errorsCount: 0, + rule: vatRule(() => { + return { countryCode: ['FR', 'DE', 'IT'] } + }), + value: 'DE136695976', + }, + { + errorsCount: 1, + rule: vatRule(() => { + return { countryCode: ['FR', 'IT'] } + }), + value: 'DE136695976', + error: 'The dummy field must be a valid VAT number', + }, + ]) + .run(stringRuleValidator) +}) From b5233b91402f371c36af5eacfa45cab21248f1bf Mon Sep 17 00:00:00 2001 From: Julien Benac Date: Mon, 24 Nov 2025 16:29:54 +0100 Subject: [PATCH 3/3] test(rules): apply `vat` rule via schema api --- tests/unit/schema/string.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/schema/string.spec.ts b/tests/unit/schema/string.spec.ts index 8491f34..9bc1bfe 100644 --- a/tests/unit/schema/string.spec.ts +++ b/tests/unit/schema/string.spec.ts @@ -49,6 +49,7 @@ import { escapeRule, normalizeUrlRule, mobileRule, + vatRule, } from '../../../src/schema/string/rules.ts' const vine = new Vine() @@ -681,6 +682,11 @@ test.group('VineString | applying rules', () => { schema: vine.string().normalizeUrl(), rule: normalizeUrlRule(), }, + { + name: 'vat', + schema: vine.string().vat({ countryCode: ['IN'] }), + rule: vatRule({ countryCode: ['IN'] }), + }, ]) .run(({ assert }, { schema, rule }) => { const refs = refsBuilder()