Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions src/schema/string/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
normalizeUrlRule,
alphaNumericRule,
normalizeEmailRule,
vatRule,
} from './rules.js'

/**
Expand Down Expand Up @@ -102,6 +103,7 @@ export class VineString extends BaseLiteralType<string, string, string> {
minLength: minLengthRule,
notSameAs: notSameAsRule,
maxLength: maxLengthRule,
vat: vatRule,
ipAddress: ipAddressRule,
creditCard: creditCardRule,
postalCode: postalCodeRule,
Expand Down Expand Up @@ -184,6 +186,13 @@ export class VineString extends BaseLiteralType<string, string, string> {
return this.use(mobileRule(...args))
}

/**
* Validates the value to be a valid VAT number.
*/
vat(...args: Parameters<typeof vatRule>) {
return this.use(vatRule(...args))
}

/**
* Validates the value to be a valid IP address.
*/
Expand Down
18 changes: 18 additions & 0 deletions src/schema/string/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
NormalizeUrlOptions,
AlphaNumericOptions,
NormalizeEmailOptions,
VATOptions,
} from '../../types.js'

/**
Expand Down Expand Up @@ -382,6 +383,23 @@ export const passportRule = createRule<
}
})

/**
* Validates the value to be a valid VAT number.
*/
export const vatRule = createRule<VATOptions | ((field: FieldContext) => 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
*/
Expand Down
15 changes: 15 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/vine/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) */
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/rules/string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
toCamelCaseRule,
escapeRule,
normalizeUrlRule,
vatRule,
} from '../../../src/schema/string/rules.ts'
import type { FieldContext, Validation } from '../../../src/types.ts'

Expand Down Expand Up @@ -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)
})
6 changes: 6 additions & 0 deletions tests/unit/schema/string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
escapeRule,
normalizeUrlRule,
mobileRule,
vatRule,
} from '../../../src/schema/string/rules.ts'

const vine = new Vine()
Expand Down Expand Up @@ -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()
Expand Down