diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.spec.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.spec.ts new file mode 100644 index 000000000000..abbd9296228d --- /dev/null +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.spec.ts @@ -0,0 +1,275 @@ +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types"; + +import { + BillingAddressControls, + EnterBillingAddressComponent, + getBillingAddressFromControls, +} from "./enter-billing-address.component"; + +describe("getBillingAddressFromControls", () => { + const buildControls = ( + overrides: Partial = {}, + ): BillingAddressControls => ({ + country: "US", + postalCode: "10001", + line1: "123 Main St", + line2: "Apt 4B", + city: "New York", + state: "NY", + taxId: null, + ...overrides, + }); + + it("resolves a Canadian value against the entered value rather than the country default", () => { + const result = getBillingAddressFromControls( + buildControls({ country: "CA", taxId: "987654321" }), + ); + + expect(result.taxId).toEqual({ code: "ca_bn", value: "987654321" }); + }); + + it("resolves Canadian GST/HST and QST values to their matching types", () => { + expect( + getBillingAddressFromControls(buildControls({ country: "CA", taxId: "123456789RT0002" })) + .taxId?.code, + ).toBe("ca_gst_hst"); + expect( + getBillingAddressFromControls(buildControls({ country: "CA", taxId: "1234567890TQ1234" })) + .taxId?.code, + ).toBe("ca_qst"); + }); + + it("resolves United Kingdom values to their matching types", () => { + expect( + getBillingAddressFromControls(buildControls({ country: "GB", taxId: "GB123456789" })).taxId + ?.code, + ).toBe("gb_vat"); + expect( + getBillingAddressFromControls(buildControls({ country: "GB", taxId: "XI123456789" })).taxId + ?.code, + ).toBe("eu_vat"); + }); + + it("returns a null taxId when the taxId control is null", () => { + const result = getBillingAddressFromControls(buildControls({ country: "CA", taxId: null })); + + expect(result.taxId).toBeNull(); + }); + + it("returns a null taxId when the taxId control is an empty string", () => { + const result = getBillingAddressFromControls(buildControls({ country: "CA", taxId: "" })); + + expect(result.taxId).toBeNull(); + }); + + it("returns a null taxId when the country has no tax ID types", () => { + const result = getBillingAddressFromControls( + buildControls({ country: "ZZ", taxId: "123456789" }), + ); + + expect(result.taxId).toBeNull(); + }); + + it("passes the address fields through unchanged and replaces the raw taxId string", () => { + const controls = buildControls({ country: "CA", taxId: "987654321" }); + + const result = getBillingAddressFromControls(controls); + + expect(result.country).toBe(controls.country); + expect(result.postalCode).toBe(controls.postalCode); + expect(result.line1).toBe(controls.line1); + expect(result.line2).toBe(controls.line2); + expect(result.city).toBe(controls.city); + expect(result.state).toBe(controls.state); + expect(result.taxId).toEqual({ code: "ca_bn", value: "987654321" }); + }); +}); + +describe("EnterBillingAddressComponent", () => { + let component: EnterBillingAddressComponent; + + const i18nService = mock(); + i18nService.t.mockImplementation((key: string, ...args: unknown[]) => [key, ...args].join(":")); + + const setup = (scenario: EnterBillingAddressComponent["scenario"]) => { + component = new EnterBillingAddressComponent(i18nService); + component.scenario = scenario; + component.group = EnterBillingAddressComponent.getFormGroup(); + component.ngOnInit(); + }; + + const setCountry = (country: string) => component.group.controls.country.setValue(country); + const setTaxId = (taxId: string) => component.group.controls.taxId.setValue(taxId); + const hint = () => (component as any).taxIdHint as string | null; + + afterEach(() => { + component?.ngOnDestroy(); + }); + + it("shows the example up front for a single-format country", () => { + setup({ type: "update", supportsTaxId: true }); + + setCountry("FR"); + + expect(hint()).toBe("taxIdFormatExample:FRAB123456789"); + }); + + it("shows no hint for a multi-format country with no value", () => { + setup({ type: "update", supportsTaxId: true }); + + setCountry("CA"); + expect(hint()).toBeNull(); + + setCountry("GB"); + expect(hint()).toBeNull(); + }); + + it("reacts to the entered value for a multi-format country", () => { + setup({ type: "update", supportsTaxId: true }); + setCountry("CA"); + + setTaxId("987654321"); + expect(hint()).toBe("recognizedTaxIdFormat:Canadian Business Number"); + + setTaxId("123456789RT0002"); + expect(hint()).toBe("recognizedTaxIdFormat:Canadian GST/HST number"); + + setTaxId("12345"); + expect(hint()).toBeNull(); + }); + + it("resolves United Kingdom values by the entered value", () => { + setup({ type: "update", supportsTaxId: true }); + setCountry("GB"); + + setTaxId("GB123456789"); + expect(hint()).toBe("recognizedTaxIdFormat:United Kingdom VAT number"); + + setTaxId("XI123456789"); + expect(hint()).toBe("recognizedTaxIdFormat:Northern Ireland VAT number"); + }); + + it("prefixes the failed-verification warning with the example", () => { + setup({ + type: "update", + supportsTaxId: true, + taxIdWarning: TaxIdWarningTypes.FailedVerification, + }); + setCountry("CA"); + setTaxId("987654321"); + + expect((component as any).taxIdWarningActive).toBe(true); + expect(hint()).toBe("checkInputFormat taxIdFormatExample:123456789"); + }); + + it("shows the failed-verification prefix alone when there is no example", () => { + setup({ + type: "update", + supportsTaxId: true, + taxIdWarning: TaxIdWarningTypes.FailedVerification, + }); + + setCountry("CA"); + + expect(hint()).toBe("checkInputFormat"); + }); + + it("shows guidance during checkout", () => { + setup({ type: "checkout", supportsTaxId: true }); + + setCountry("FR"); + + expect((component as any).taxIdWarningActive).toBe(false); + expect(hint()).toBe("taxIdFormatExample:FRAB123456789"); + }); + + it("returns no hint when tax IDs are unsupported", () => { + setup({ type: "update", supportsTaxId: false }); + + setCountry("FR"); + + expect(hint()).toBeNull(); + }); + + it("resets the tax ID when switching to an unsupported country", () => { + setup({ type: "update", supportsTaxId: true }); + setCountry("CA"); + setTaxId("987654321"); + + setCountry("US"); + + expect(component.group.controls.taxId.value).toBeNull(); + expect(component.group.controls.taxId.disabled).toBe(true); + }); + + it("does not submit a stale tax ID after switching to an unsupported country", () => { + setup({ type: "update", supportsTaxId: true }); + setCountry("CA"); + setTaxId("987654321"); + + setCountry("US"); + + expect(getBillingAddressFromControls(component.group.getRawValue()).taxId).toBeNull(); + }); + + it("preserves the tax ID when switching between supported countries", () => { + setup({ type: "update", supportsTaxId: true }); + setCountry("CA"); + setTaxId("987654321"); + + setCountry("GB"); + + expect(component.group.controls.taxId.value).toBe("987654321"); + }); + + it("re-enables the tax ID when returning to a supported country", () => { + setup({ type: "update", supportsTaxId: true }); + setCountry("CA"); + setCountry("US"); + setCountry("CA"); + + expect(component.group.controls.taxId.enabled).toBe(true); + }); + + it("returns no hint for the United States", () => { + setup({ type: "update", supportsTaxId: true }); + + setCountry("US"); + + expect(hint()).toBeNull(); + }); + + it("shows no stale hint after switching from an unsupported country to a supported one", () => { + setup({ type: "update", supportsTaxId: true }); + + setCountry("US"); + setCountry("CA"); + + expect(hint()).toBeNull(); + }); + + it("does not clear an existing tax ID on open when the country can't enter one", () => { + setup({ + type: "update", + supportsTaxId: true, + existing: { + country: "US", + postalCode: "12345", + line1: null, + line2: null, + city: null, + state: null, + taxId: { code: "us_ein", value: "12-3456789" }, + }, + }); + + expect(component.group.controls.taxId.value).toBe("12-3456789"); + expect(getBillingAddressFromControls(component.group.getRawValue()).taxId).toEqual({ + code: "us_ein", + value: "12-3456789", + }); + }); +}); diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index db95beea7f88..31f15dc736a0 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnDestroy, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; +import { map, Observable, pairwise, startWith, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -10,7 +10,13 @@ import { } from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "../../../shared"; -import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types"; +import { + BillingAddress, + findTaxIdTypeByValue, + getTaxIdTypeForCountry, + selectableCountries, + taxIdTypes, +} from "../types"; export interface BillingAddressControls { country: string; @@ -29,7 +35,7 @@ export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): B export const getBillingAddressFromControls = (controls: BillingAddressControls) => { const { taxId, ...addressFields } = controls; - const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; + const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country, taxId) : null; return taxIdType ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } : { ...addressFields, taxId: null }; @@ -140,16 +146,18 @@ type Scenario = [formControl]="group.controls.taxId" data-testid="tax-id" /> - @let hint = taxIdWarningHint; + @let hint = taxIdHint; @if (hint) { - {{ hint }} + + @if (taxIdWarningActive) { + + } + {{ hint }} + } @@ -202,13 +210,18 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { }), ); - this.supportsTaxId$.pipe(takeUntil(this.destroy$)).subscribe((supportsTaxId) => { - if (supportsTaxId) { - this.group.controls.taxId.enable(); - } else { - this.group.controls.taxId.disable(); - } - }); + this.supportsTaxId$ + .pipe(startWith(undefined), pairwise(), takeUntil(this.destroy$)) + .subscribe(([previouslySupported, supportsTaxId]) => { + if (supportsTaxId) { + this.group.controls.taxId.enable(); + } else { + this.group.controls.taxId.disable(); + if (previouslySupported) { + this.group.controls.taxId.reset(); + } + } + }); } ngOnDestroy() { @@ -223,38 +236,40 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.group.controls.state.disable(); }; - get taxIdWarningHint() { - if ( - this.scenario.type === "checkout" || - !this.scenario.supportsTaxId || - !this.group.value.country || - this.scenario.taxIdWarning !== TaxIdWarningTypes.FailedVerification - ) { - return null; - } + get taxIdHint(): string | null { + return this.computeTaxIdHint(this.group.value.country, this.group.value.taxId); + } - const taxIdType = getTaxIdTypeForCountry(this.group.value.country); + get taxIdWarningActive(): boolean { + return ( + this.scenario.type === "update" && + this.scenario.taxIdWarning === TaxIdWarningTypes.FailedVerification + ); + } - if (!taxIdType) { + private computeTaxIdHint(country: string | null | undefined, taxId: string | null | undefined) { + if (!this.scenario.supportsTaxId || !country || country === "US") { + return null; + } + const types = taxIdTypes.filter((type) => type.iso === country); + if (types.length === 0) { return null; } + const resolved = + types.length === 1 ? types[0] : taxId ? findTaxIdTypeByValue(types, taxId) : undefined; - const checkInputFormat = this.i18nService.t("checkInputFormat"); + if (this.taxIdWarningActive) { + const check = this.i18nService.t("checkInputFormat"); + return resolved + ? `${check} ${this.i18nService.t("taxIdFormatExample", resolved.example)}` + : check; + } - switch (taxIdType.code) { - case "au_abn": { - const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "ABN", taxIdType.example); - return `${checkInputFormat} ${exampleFormat}`; - } - case "eu_vat": { - const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "EU VAT", taxIdType.example); - return `${checkInputFormat} ${exampleFormat}`; - } - case "gb_vat": { - const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "GB VAT", taxIdType.example); - return `${checkInputFormat} ${exampleFormat}`; - } + if (types.length === 1) { + return this.i18nService.t("taxIdFormatExample", types[0].example); } + + return resolved ? this.i18nService.t("recognizedTaxIdFormat", resolved.description) : null; } static getFormGroup = (): BillingAddressFormGroup => diff --git a/apps/web/src/app/billing/payment/types/tax-id-type.resolver.spec.ts b/apps/web/src/app/billing/payment/types/tax-id-type.resolver.spec.ts new file mode 100644 index 000000000000..da114b8d88e2 --- /dev/null +++ b/apps/web/src/app/billing/payment/types/tax-id-type.resolver.spec.ts @@ -0,0 +1,116 @@ +import { getTaxIdTypeForCountry, taxIdTypes } from "./tax-id-type"; + +describe("getTaxIdTypeForCountry", () => { + describe("value-aware resolution", () => { + it("resolves Canadian values to the matching type", () => { + expect(getTaxIdTypeForCountry("CA", "987654321")?.code).toBe("ca_bn"); + expect(getTaxIdTypeForCountry("CA", "123456789RT0002")?.code).toBe("ca_gst_hst"); + expect(getTaxIdTypeForCountry("CA", "1234567890TQ1234")?.code).toBe("ca_qst"); + expect(getTaxIdTypeForCountry("CA", "PST-1234-5678")?.code).toBe("ca_pst_bc"); + expect(getTaxIdTypeForCountry("CA", "123456-7")?.code).toBe("ca_pst_mb"); + expect(getTaxIdTypeForCountry("CA", "1234567")?.code).toBe("ca_pst_sk"); + }); + + it("resolves United Kingdom values to the matching type", () => { + expect(getTaxIdTypeForCountry("GB", "XI123456789")?.code).toBe("eu_vat"); + expect(getTaxIdTypeForCountry("GB", "GB123456789")?.code).toBe("gb_vat"); + expect(getTaxIdTypeForCountry("GB", "123456789")?.code).toBe("gb_vat"); + }); + + it("resolves Spanish values to the matching type", () => { + expect(getTaxIdTypeForCountry("ES", "A12345678")?.code).toBe("es_cif"); + expect(getTaxIdTypeForCountry("ES", "ESA1234567Z")?.code).toBe("eu_vat"); + }); + + it("resolves German values to the matching type", () => { + expect(getTaxIdTypeForCountry("DE", "DE123456789")?.code).toBe("eu_vat"); + expect(getTaxIdTypeForCountry("DE", "1234567890")?.code).toBe("de_stn"); + }); + }); + + describe("Brazil declaration order", () => { + it("resolves a CPF-shaped value to br_cpf", () => { + expect(getTaxIdTypeForCountry("BR", "123.456.789-87")?.code).toBe("br_cpf"); + }); + + it("resolves a CNPJ-shaped value to br_cnpj", () => { + expect(getTaxIdTypeForCountry("BR", "01.234.456/5432-10")?.code).toBe("br_cnpj"); + }); + + it("resolves a bare 14-digit value to br_cnpj", () => { + expect(getTaxIdTypeForCountry("BR", "12345678901234")?.code).toBe("br_cnpj"); + }); + }); + + describe("fallbacks and edge cases", () => { + it("returns the sole type for a single-entry country regardless of value", () => { + expect(getTaxIdTypeForCountry("FR", "anything")?.code).toBe("eu_vat"); + expect(getTaxIdTypeForCountry("FR")?.code).toBe("eu_vat"); + }); + + it("returns null for an unknown country", () => { + expect(getTaxIdTypeForCountry("ZZ")).toBeNull(); + expect(getTaxIdTypeForCountry("ZZ", "123456789")).toBeNull(); + }); + + it("falls back to the tax-impacting type when no value is provided", () => { + expect(getTaxIdTypeForCountry("CA")?.code).toBe("ca_gst_hst"); + expect(getTaxIdTypeForCountry("GB")?.code).toBe("eu_vat"); + expect(getTaxIdTypeForCountry("ES")?.code).toBe("eu_vat"); + }); + + it("falls back to the tax-impacting type when the value is empty", () => { + expect(getTaxIdTypeForCountry("CA", "")?.code).toBe("ca_gst_hst"); + expect(getTaxIdTypeForCountry("GB", "")?.code).toBe("eu_vat"); + expect(getTaxIdTypeForCountry("ES", "")?.code).toBe("eu_vat"); + }); + + it("falls back to the tax-impacting type when the value matches nothing", () => { + expect(getTaxIdTypeForCountry("CA", "not-a-tax-id")?.code).toBe("ca_gst_hst"); + expect(getTaxIdTypeForCountry("GB", "not-a-tax-id")?.code).toBe("eu_vat"); + expect(getTaxIdTypeForCountry("ES", "not-a-tax-id")?.code).toBe("eu_vat"); + }); + + it("falls back to the tax-impacting type on a near-miss value", () => { + expect(getTaxIdTypeForCountry("CA", "12345678")?.code).toBe("ca_gst_hst"); + }); + + it("falls back to the first entry when no entry impacts tax calculation", () => { + expect(getTaxIdTypeForCountry("BR")?.code).toBe("br_cnpj"); + expect(getTaxIdTypeForCountry("BR", "not-a-tax-id")?.code).toBe("br_cnpj"); + }); + }); + + describe("format coverage", () => { + const countsByIso = taxIdTypes.reduce>((counts, type) => { + counts[type.iso] = (counts[type.iso] ?? 0) + 1; + return counts; + }, {}); + const multiEntryTypes = taxIdTypes.filter((type) => countsByIso[type.iso] > 1); + + it("defines format on every entry of a multi-entry country", () => { + expect(multiEntryTypes.length).toBeGreaterThan(0); + multiEntryTypes.forEach((type) => { + expect(type.format).toBeDefined(); + }); + }); + + it("resolves every multi-entry example back to its own type", () => { + expect(multiEntryTypes.length).toBeGreaterThan(0); + multiEntryTypes.forEach((type) => { + expect(getTaxIdTypeForCountry(type.iso, type.example)?.code).toBe(type.code); + }); + }); + + it("matches every entry's own example against its format", () => { + const typesWithFormat = taxIdTypes.filter((type) => type.format); + + expect(typesWithFormat.length).toBeGreaterThan(0); + taxIdTypes.forEach((type) => { + if (type.format) { + expect(type.format.test(type.example)).toBe(true); + } + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/payment/types/tax-id-type.ts b/apps/web/src/app/billing/payment/types/tax-id-type.ts index 8f6264e088c1..b07f1a27102b 100644 --- a/apps/web/src/app/billing/payment/types/tax-id-type.ts +++ b/apps/web/src/app/billing/payment/types/tax-id-type.ts @@ -5,21 +5,32 @@ export type TaxIdType = Readonly<{ description: string; example: string; impactsTaxCalculation: boolean; + format?: RegExp; }>; -export const getTaxIdTypeForCountry = (country: string): TaxIdType | null => { +export const findTaxIdTypeByValue = ( + types: ReadonlyArray, + value: string, +): TaxIdType | undefined => types.find((type) => type.format?.test(value)); + +export const getTaxIdTypeForCountry = (country: string, value?: string): TaxIdType | null => { const types = taxIdTypes.filter((type) => type.iso === country); + if (types.length === 0) { return null; - } else if (types.length === 1) { + } + if (types.length === 1) { return types[0]; - } else { - const impactful = types.find((taxIdType) => taxIdType.impactsTaxCalculation); - if (!impactful) { - return types[0]; + } + + if (value) { + const matched = findTaxIdTypeByValue(types, value); + if (matched) { + return matched; } - return impactful; } + + return types.find((type) => type.impactsTaxCalculation) ?? types[0]; }; export const taxIdTypes: ReadonlyArray = [ @@ -78,6 +89,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Australian Business Number (AU ABN)", example: "12345678912", impactsTaxCalculation: true, + format: /^[0-9]{11}$/, }, { country: "Australia", @@ -86,6 +98,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Australian Taxation Office Reference Number", example: "123456789123", impactsTaxCalculation: false, + format: /^[0-9]{12}$/, }, { country: "Austria", @@ -182,6 +195,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Brazilian CNPJ number", example: "01.234.456/5432-10", impactsTaxCalculation: false, + format: /^[0-9]{2}.?[0-9]{3}.?[0-9]{3}\/?[0-9]{4}-?[0-9]{2}$/, }, { country: "Brazil", @@ -190,6 +204,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Brazilian CPF number", example: "123.456.789-87", impactsTaxCalculation: false, + format: /^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{2}$/, }, { country: "Bulgaria", @@ -198,6 +213,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Bulgaria Unified Identification Code", example: "123456789", impactsTaxCalculation: false, + format: /^[0-9]{9}$/, }, { country: "Bulgaria", @@ -206,6 +222,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "BG0123456789", impactsTaxCalculation: true, + format: /^BG[0-9]{9,10}$/, }, { country: "Burkina Faso", @@ -235,9 +252,10 @@ export const taxIdTypes: ReadonlyArray = [ country: "Canada", iso: "CA", code: "ca_bn", - description: "Canadian BN", + description: "Canadian Business Number", example: "123456789", impactsTaxCalculation: false, + format: /^[0-9]{9}$/, }, { country: "Canada", @@ -246,6 +264,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Canadian GST/HST number", example: "123456789RT0002", impactsTaxCalculation: true, + format: /^[0-9]{9}RT[0-9]{4}$/, }, { country: "Canada", @@ -254,6 +273,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Canadian PST number (British Columbia)", example: "PST-1234-5678", impactsTaxCalculation: false, + format: /^PST-[0-9]{4}-[0-9]{4}$/, }, { country: "Canada", @@ -262,6 +282,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Canadian PST number (Manitoba)", example: "123456-7", impactsTaxCalculation: false, + format: /^[0-9]{6}-[0-9]{1}$/, }, { country: "Canada", @@ -270,6 +291,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Canadian PST number (Saskatchewan)", example: "1234567", impactsTaxCalculation: false, + format: /^[0-9]{7}$/, }, { country: "Canada", @@ -278,6 +300,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Canadian QST number (Québec)", example: "1234567890TQ1234", impactsTaxCalculation: true, + format: /^[0-9]{10}TQ[0-9]{4}$/, }, { country: "Cape Verde", @@ -334,6 +357,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "HR12345678912", impactsTaxCalculation: true, + format: /^HR[0-9]{11}$/, }, { country: "Croatia", @@ -342,6 +366,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Croatian Personal Identification Number", example: "12345678901", impactsTaxCalculation: false, + format: /^[0-9]{11}$/, }, { country: "Cyprus", @@ -454,6 +479,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "German Tax Number (Steuernummer)", example: "1234567890", impactsTaxCalculation: false, + format: /^[0-9]{10}$/, }, { country: "Germany", @@ -462,6 +488,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "DE123456789", impactsTaxCalculation: true, + format: /^DE[0-9]{9}$/, }, { country: "Greece", @@ -494,6 +521,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "HU12345678", impactsTaxCalculation: true, + format: /^HU[0-9]{8}$/, }, { country: "Hungary", @@ -502,6 +530,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Hungary tax number (adószám)", example: "12345678-1-23", impactsTaxCalculation: false, + format: /^[0-9]{8}-?[0-9]-?[0-9]{2}$/, }, { country: "Iceland", @@ -555,26 +584,29 @@ export const taxIdTypes: ReadonlyArray = [ country: "Japan", iso: "JP", code: "jp_cn", - description: "Japanese Corporate Number (*Hōjin Bangō*)", + description: "Japanese Corporate Number (Hōjin Bangō)", example: "1234567891234", impactsTaxCalculation: false, + format: /^[0-9]{13}$/, }, { country: "Japan", iso: "JP", code: "jp_rn", description: - "Japanese Registered Foreign Businesses' Registration Number (*Tōroku Kokugai Jigyōsha no Tōroku Bangō*)", + "Japanese Registered Foreign Businesses' Registration Number (Tōroku Kokugai Jigyōsha no Tōroku Bangō)", example: "12345", impactsTaxCalculation: false, + format: /^[0-9]{5}$/, }, { country: "Japan", iso: "JP", code: "jp_trn", - description: "Japanese Tax Registration Number (*Tōroku Bangō*)", + description: "Japanese Tax Registration Number (Tōroku Bangō)", example: "T1234567891234", impactsTaxCalculation: true, + format: /^T[0-9]{13}$/, }, { country: "Kazakhstan", @@ -623,6 +655,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Liechtensteinian UID number", example: "CHE123456789", impactsTaxCalculation: false, + format: /^CHE[0-9]{9}$/, }, { country: "Liechtenstein", @@ -631,6 +664,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Liechtensteinian VAT number", example: "12345", impactsTaxCalculation: true, + format: /^[0-9]{5}$/, }, { country: "Lithuania", @@ -655,14 +689,16 @@ export const taxIdTypes: ReadonlyArray = [ description: "Malaysian FRP number", example: "12345678", impactsTaxCalculation: false, + format: /^[0-9]{8}$/, }, { country: "Malaysia", iso: "MY", code: "my_itn", - description: "Malaysian ITN", + description: "Malaysian Income Tax Number", example: "C 1234567890", impactsTaxCalculation: false, + format: /^[A-Z]{1} ?[0-9]{10}$/, }, { country: "Malaysia", @@ -671,6 +707,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Malaysian SST number", example: "A12-3456-78912345", impactsTaxCalculation: false, + format: /^[A-Z]{1}[0-9]{2}-?[0-9]{4}-?[0-9]{8}$/, }, { country: "Malta", @@ -767,6 +804,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Norwegian VAT number", example: "123456789MVA", impactsTaxCalculation: true, + format: /^[0-9]{9}MVA$/, }, { country: "Norway", @@ -775,6 +813,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Norwegian VAT on e-commerce number", example: "1234567", impactsTaxCalculation: false, + format: /^[0-9]{7}$/, }, { country: "Oman", @@ -823,6 +862,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "RO1234567891", impactsTaxCalculation: true, + format: /^RO[0-9]{2,10}$/, }, { country: "Romania", @@ -831,22 +871,25 @@ export const taxIdTypes: ReadonlyArray = [ description: "Romanian tax ID number", example: "1234567890123", impactsTaxCalculation: false, + format: /^[0-9]{13}$/, }, { country: "Russia", iso: "RU", code: "ru_inn", - description: "Russian INN", + description: "Russian Taxpayer Identification Number (INN)", example: "1234567891", impactsTaxCalculation: true, + format: /^[0-9]{10,12}$/, }, { country: "Russia", iso: "RU", code: "ru_kpp", - description: "Russian KPP", + description: "Russian Tax Registration Reason Code (KPP)", example: "123456789", impactsTaxCalculation: true, + format: /^[0-9]{9}$/, }, { country: "Saudi Arabia", @@ -876,17 +919,19 @@ export const taxIdTypes: ReadonlyArray = [ country: "Singapore", iso: "SG", code: "sg_gst", - description: "Singaporean GST", + description: "Singaporean GST number", example: "M12345678X", impactsTaxCalculation: true, + format: /^[A-Z]{1}[0-9]{8}[A-Z]{1}$/, }, { country: "Singapore", iso: "SG", code: "sg_uen", - description: "Singaporean UEN", + description: "Singaporean Unique Entity Number (UEN)", example: "123456789F", impactsTaxCalculation: false, + format: /^[0-9]{9}[A-Z]{1}$/, }, { country: "Slovakia", @@ -903,6 +948,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "SI12345678", impactsTaxCalculation: true, + format: /^SI[0-9]{8}$/, }, { country: "Slovenia", @@ -911,6 +957,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Slovenia tax number (davčna številka)", example: "12345678", impactsTaxCalculation: false, + format: /^[0-9]{8}$/, }, { country: "South Africa", @@ -935,6 +982,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Spanish NIF number (previously Spanish CIF number)", example: "A12345678", impactsTaxCalculation: false, + format: /^[A-Z]{1}[0-9]{8}$/, }, { country: "Spain", @@ -943,6 +991,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "European VAT number", example: "ESA1234567Z", impactsTaxCalculation: true, + format: /^ES[A-Z]{1}[0-9]{7}[A-Z]{1}$/, }, { country: "Suriname", @@ -967,6 +1016,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Switzerland UID number", example: "CHE-123.456.789 HR", impactsTaxCalculation: false, + format: /^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?HR$/, }, { country: "Switzerland", @@ -975,6 +1025,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Switzerland VAT number", example: "CHE-123.456.789 MWST", impactsTaxCalculation: true, + format: /^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?MWST$/, }, { country: "Taiwan", @@ -1047,6 +1098,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Northern Ireland VAT number", example: "XI123456789", impactsTaxCalculation: true, + format: /^XI[0-9]{9}$/, }, { country: "United Kingdom", @@ -1055,6 +1107,8 @@ export const taxIdTypes: ReadonlyArray = [ description: "United Kingdom VAT number", example: "GB123456789", impactsTaxCalculation: true, + // Folds the server TaxService's prefixed and unprefixed gb_vat rows so a bare 9-digit value resolves to gb_vat, not the XI eu_vat row. + format: /^(?:GB)?[0-9]{9}$/, }, { country: "United States", @@ -1079,6 +1133,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Uzbekistan TIN Number", example: "123456789", impactsTaxCalculation: false, + format: /^[0-9]{9}$/, }, { country: "Uzbekistan", @@ -1087,6 +1142,7 @@ export const taxIdTypes: ReadonlyArray = [ description: "Uzbekistan VAT Number", example: "123456789012", impactsTaxCalculation: true, + format: /^[0-9]{12}$/, }, { country: "Venezuela", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d428fa0a4c30..3058e46ff05f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -15415,16 +15415,21 @@ "checkInputFormat": { "message": "Check input format for typos." }, - "exampleTaxIdFormat": { - "message": "Example $CODE$ format: $EXAMPLE$", + "taxIdFormatExample": { + "message": "Example tax ID format: $EXAMPLE$", "placeholders": { - "code": { - "content": "$1", - "example": "ABN" - }, "example": { - "content": "$2", - "example": "92873837267" + "content": "$1", + "example": "123456789" + } + } + }, + "recognizedTaxIdFormat": { + "message": "Recognized format: $TYPE$", + "placeholders": { + "type": { + "content": "$1", + "example": "Canadian Business Number" } } },