From 8e164e64548f576a42946d53e39582a6c5f1b063 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Mon, 2 Feb 2026 16:38:15 +0000 Subject: [PATCH 1/9] skip CVC validation when card number validation fails --- packages/ui-components/src/Card/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui-components/src/Card/index.tsx b/packages/ui-components/src/Card/index.tsx index 89e0dc04a..5fc5dc892 100644 --- a/packages/ui-components/src/Card/index.tsx +++ b/packages/ui-components/src/Card/index.tsx @@ -109,10 +109,11 @@ export function Card({ config }: { config: CardConfig }) { return undefined; const cardValidation = validateNumber(values.number, { customBrands }); + if (!cardValidation.isValid) return undefined; + const cvcValidation = validateCVC(values.cvc, values.number, { customBrands, }); - if (!cvcValidation.isValid) { return "invalid"; } From a9d0ac83e2324ef1466a9bce8a9ab59b04775fa9 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Mon, 2 Feb 2026 17:17:37 +0000 Subject: [PATCH 2/9] fix specs with validateCVC reason --- packages/card-validator/index.ts | 16 +++++++++++++--- packages/card-validator/types.ts | 14 ++++++++++---- packages/ui-components/src/Card/index.tsx | 10 ++++++++-- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/card-validator/index.ts b/packages/card-validator/index.ts index 9c05fa92c..24f352757 100644 --- a/packages/card-validator/index.ts +++ b/packages/card-validator/index.ts @@ -133,12 +133,13 @@ export function validateCVC( return { cvc: null, isValid: false, + reason: "invalid_cvc", }; } if (!cardNumber) { return { - cvc: cvc, + cvc, isValid: true, }; } @@ -148,6 +149,7 @@ export function validateCVC( return { cvc: null, isValid: false, + reason: "invalid_number", }; } @@ -166,9 +168,17 @@ export function validateCVC( return brand.securityCodeValidationRules.lengths.includes(cvc.length); }); + if (!isCVCValid) { + return { + cvc: null, + isValid: false, + reason: "invalid_brand_cvc", + }; + } + return { - cvc: isCVCValid ? cvc : null, - isValid: isCVCValid, + cvc, + isValid: true, }; } diff --git a/packages/card-validator/types.ts b/packages/card-validator/types.ts index 0400b0651..7f790289b 100644 --- a/packages/card-validator/types.ts +++ b/packages/card-validator/types.ts @@ -49,7 +49,13 @@ export type CardExpiryValidationResult = { isValid: boolean; }; -export type CardCVCValidationResult = { - cvc: string | null; - isValid: boolean; -}; +export type CardCVCValidationResult = + | { + cvc: string; + isValid: true; + } + | { + cvc: null; + isValid: false; + reason: "invalid_cvc" | "invalid_number" | "invalid_brand_cvc"; + }; diff --git a/packages/ui-components/src/Card/index.tsx b/packages/ui-components/src/Card/index.tsx index 5fc5dc892..53ff78e48 100644 --- a/packages/ui-components/src/Card/index.tsx +++ b/packages/ui-components/src/Card/index.tsx @@ -109,12 +109,18 @@ export function Card({ config }: { config: CardConfig }) { return undefined; const cardValidation = validateNumber(values.number, { customBrands }); - if (!cardValidation.isValid) return undefined; - const cvcValidation = validateCVC(values.cvc, values.number, { customBrands, }); if (!cvcValidation.isValid) { + // Skip CVC validation if failed because of invalid card number + if ( + cvcValidation.reason === "invalid_number" && + !cardValidation.isValid + ) { + return undefined; + } + return "invalid"; } From 7d64a7abf809983d3c955103b8eb8b4045ac81d8 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Mon, 2 Feb 2026 17:18:58 +0000 Subject: [PATCH 3/9] fix specs with validateCVC reason --- packages/card-validator/test/validate-cvc.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/card-validator/test/validate-cvc.test.ts b/packages/card-validator/test/validate-cvc.test.ts index 584cb702b..029bef3c3 100644 --- a/packages/card-validator/test/validate-cvc.test.ts +++ b/packages/card-validator/test/validate-cvc.test.ts @@ -14,31 +14,31 @@ const testCases: TestCase[] = [ scope: "Invalid card number", cardNumber: "123", cvc: "123", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_number" }, }, { scope: "Non digit CVC", cardNumber: "4242424242424242", cvc: "abc", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, }, { scope: "CVC with spaces", cardNumber: "4242424242424242", cvc: "123 ", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, }, { scope: "CVC with wrong length mastercard", cardNumber: "5555555555554444", cvc: "1234", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_brand_cvc" }, }, { scope: "CVC with wrong length amex", cardNumber: "378282246310005", cvc: "12", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_brand_cvc" }, }, { scope: "Valid CVC Mastercard", @@ -86,13 +86,13 @@ const testCases: TestCase[] = [ scope: "2 digit CVC", cardNumber: "", cvc: "12", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, }, { scope: "5 digit CVC", cardNumber: "", cvc: "12345", - expectedResult: { cvc: null, isValid: false }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, }, ]; From 9d23f1b751ae983b1716cecf4fc64280f4c14c9a Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Tue, 3 Feb 2026 08:50:13 +0000 Subject: [PATCH 4/9] fix unit test --- packages/card-validator/test/validate-cvc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/card-validator/test/validate-cvc.test.ts b/packages/card-validator/test/validate-cvc.test.ts index 029bef3c3..6db2b4084 100644 --- a/packages/card-validator/test/validate-cvc.test.ts +++ b/packages/card-validator/test/validate-cvc.test.ts @@ -38,7 +38,7 @@ const testCases: TestCase[] = [ scope: "CVC with wrong length amex", cardNumber: "378282246310005", cvc: "12", - expectedResult: { cvc: null, isValid: false, reason: "invalid_brand_cvc" }, + expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, }, { scope: "Valid CVC Mastercard", From 938b455e0ed1408db607e707fab05bdc133bbf06 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Tue, 3 Feb 2026 09:31:42 +0000 Subject: [PATCH 5/9] add changeset --- .changeset/spicy-mayflies-give.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/spicy-mayflies-give.md diff --git a/.changeset/spicy-mayflies-give.md b/.changeset/spicy-mayflies-give.md new file mode 100644 index 000000000..354f309c4 --- /dev/null +++ b/.changeset/spicy-mayflies-give.md @@ -0,0 +1,7 @@ +--- +"@evervault/card-validator": minor +"@evervault/ui-components": patch +--- + +- Adds `reason` to CVC validation failures +- Fixes bug where failed card validation forces a failed CVC validation From fe01bda3ef73c38eb8fc1e74fa1514ec0a375020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9?= <136079302+jmorgadosoares@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:04:37 +0100 Subject: [PATCH 6/9] Decouple cvc validation from number validation --- packages/card-validator/index.ts | 34 ++++++++----------- .../card-validator/test/validate-cvc.test.ts | 14 ++++---- packages/card-validator/types.ts | 14 +++----- packages/ui-components/src/Card/index.tsx | 9 +---- 4 files changed, 26 insertions(+), 45 deletions(-) diff --git a/packages/card-validator/index.ts b/packages/card-validator/index.ts index 24f352757..38dbd37ed 100644 --- a/packages/card-validator/index.ts +++ b/packages/card-validator/index.ts @@ -128,40 +128,35 @@ export function validateCVC( cardNumber?: string, options?: CardNumberValidationOptions ): CardCVCValidationResult { + const { brand, localBrands } = validateNumber(cardNumber ?? "", options); + // Check if the CVC only contains numbers with 3 or 4 digits if (!/^\d{3,4}$/.test(cvc)) { return { cvc: null, isValid: false, - reason: "invalid_cvc", }; } - if (!cardNumber) { - return { - cvc, - isValid: true, - }; - } + const brands: string[] = []; - const validatedCard = validateNumber(cardNumber, options); - if (!validatedCard.isValid) { - return { - cvc: null, - isValid: false, - reason: "invalid_number", - }; + if (brand) { + brands.push(brand); } - const brands: string[] = []; - if (validatedCard.brand) { - brands.push(validatedCard.brand); + if (localBrands) { + brands.push(...localBrands); } - if (validatedCard.localBrands) { - brands.push(...validatedCard.localBrands); + + if (!brands.length) { + return { + cvc, + isValid: true, + }; } const allBrands = [...defaultBrands, ...(options?.customBrands ?? [])]; + const isCVCValid = allBrands .filter((brand) => brands.includes(brand.name)) .some((brand) => { @@ -172,7 +167,6 @@ export function validateCVC( return { cvc: null, isValid: false, - reason: "invalid_brand_cvc", }; } diff --git a/packages/card-validator/test/validate-cvc.test.ts b/packages/card-validator/test/validate-cvc.test.ts index 6db2b4084..584cb702b 100644 --- a/packages/card-validator/test/validate-cvc.test.ts +++ b/packages/card-validator/test/validate-cvc.test.ts @@ -14,31 +14,31 @@ const testCases: TestCase[] = [ scope: "Invalid card number", cardNumber: "123", cvc: "123", - expectedResult: { cvc: null, isValid: false, reason: "invalid_number" }, + expectedResult: { cvc: null, isValid: false }, }, { scope: "Non digit CVC", cardNumber: "4242424242424242", cvc: "abc", - expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, + expectedResult: { cvc: null, isValid: false }, }, { scope: "CVC with spaces", cardNumber: "4242424242424242", cvc: "123 ", - expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, + expectedResult: { cvc: null, isValid: false }, }, { scope: "CVC with wrong length mastercard", cardNumber: "5555555555554444", cvc: "1234", - expectedResult: { cvc: null, isValid: false, reason: "invalid_brand_cvc" }, + expectedResult: { cvc: null, isValid: false }, }, { scope: "CVC with wrong length amex", cardNumber: "378282246310005", cvc: "12", - expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, + expectedResult: { cvc: null, isValid: false }, }, { scope: "Valid CVC Mastercard", @@ -86,13 +86,13 @@ const testCases: TestCase[] = [ scope: "2 digit CVC", cardNumber: "", cvc: "12", - expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, + expectedResult: { cvc: null, isValid: false }, }, { scope: "5 digit CVC", cardNumber: "", cvc: "12345", - expectedResult: { cvc: null, isValid: false, reason: "invalid_cvc" }, + expectedResult: { cvc: null, isValid: false }, }, ]; diff --git a/packages/card-validator/types.ts b/packages/card-validator/types.ts index 7f790289b..0400b0651 100644 --- a/packages/card-validator/types.ts +++ b/packages/card-validator/types.ts @@ -49,13 +49,7 @@ export type CardExpiryValidationResult = { isValid: boolean; }; -export type CardCVCValidationResult = - | { - cvc: string; - isValid: true; - } - | { - cvc: null; - isValid: false; - reason: "invalid_cvc" | "invalid_number" | "invalid_brand_cvc"; - }; +export type CardCVCValidationResult = { + cvc: string | null; + isValid: boolean; +}; diff --git a/packages/ui-components/src/Card/index.tsx b/packages/ui-components/src/Card/index.tsx index 53ff78e48..89e0dc04a 100644 --- a/packages/ui-components/src/Card/index.tsx +++ b/packages/ui-components/src/Card/index.tsx @@ -112,15 +112,8 @@ export function Card({ config }: { config: CardConfig }) { const cvcValidation = validateCVC(values.cvc, values.number, { customBrands, }); - if (!cvcValidation.isValid) { - // Skip CVC validation if failed because of invalid card number - if ( - cvcValidation.reason === "invalid_number" && - !cardValidation.isValid - ) { - return undefined; - } + if (!cvcValidation.isValid) { return "invalid"; } From ee93c678adf671af78b5204a24d1e3fb5afcc468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9?= <136079302+jmorgadosoares@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:17:46 +0100 Subject: [PATCH 7/9] Remove validation when blurring empty fields --- .../ui-components/tests/cardDetails.spec.js | 23 +++++++++++++++++++ packages/shared/src/useForm.ts | 7 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/e2e-tests/ui-components/tests/cardDetails.spec.js b/e2e-tests/ui-components/tests/cardDetails.spec.js index 62721f05b..e7f53b0a0 100644 --- a/e2e-tests/ui-components/tests/cardDetails.spec.js +++ b/e2e-tests/ui-components/tests/cardDetails.spec.js @@ -285,6 +285,29 @@ test.describe("card component", () => { await expect(frame.getByText("Your card number is invalid")).toBeVisible(); }); + test("does not show errors when blurring empty fields", async ({ page }) => { + await page.evaluate(() => { + const card = window.evervault.ui.card(); + card.mount("#form"); + }); + + const frame = page.frameLocator("iframe[data-evervault]"); + await frame.getByLabel("Number").focus(); + await frame.getByLabel("Number").blur(); + await frame.getByLabel("Expiration").focus(); + await frame.getByLabel("Expiration").blur(); + await frame.getByLabel("CVC").focus(); + await frame.getByLabel("CVC").blur(); + + await expect( + frame.getByText("Your card number is invalid") + ).not.toBeVisible(); + await expect( + frame.getByText("Your expiration date is invalid") + ).not.toBeVisible(); + await expect(frame.getByText("Your CVC is invalid")).not.toBeVisible(); + }); + test("Manual validation when 3 digit amex is diabled", async ({ page }) => { let values = {}; diff --git a/packages/shared/src/useForm.ts b/packages/shared/src/useForm.ts index f8afdd906..fb22e466c 100644 --- a/packages/shared/src/useForm.ts +++ b/packages/shared/src/useForm.ts @@ -166,7 +166,12 @@ export function useForm({ } ) => { const handleBlur = (e: React.FocusEvent) => { - validateField(name); + const value = values[name]; + if (typeof value === "string" && value.length === 0) { + setError(name, undefined); + } else { + validateField(name); + } events?.onBlur?.(e); }; From 93ce1c199046603ca2d6d8baf30800d6e9307e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9?= <136079302+jmorgadosoares@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:17:43 +0100 Subject: [PATCH 8/9] replace .blur() with .validate() in tests --- e2e-tests/ui-components/tests/cardDetails.spec.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/e2e-tests/ui-components/tests/cardDetails.spec.js b/e2e-tests/ui-components/tests/cardDetails.spec.js index e7f53b0a0..b7d2caa97 100644 --- a/e2e-tests/ui-components/tests/cardDetails.spec.js +++ b/e2e-tests/ui-components/tests/cardDetails.spec.js @@ -1037,16 +1037,15 @@ test.describe("card component", () => { }); await page.evaluate(() => { - const card = window.evervault.ui.card(); - card.on("change", window.handleChange); - card.mount("#form"); + window.card = window.evervault.ui.card(); + window.card.on("change", window.handleChange); + window.card.mount("#form"); }); const frame = page.frameLocator("iframe[data-evervault]"); await frame.getByLabel("Number").fill("4242424242424242"); await frame.getByLabel("Expiration").fill(getFutureExpiration()); - await frame.getByLabel("CVC").focus(); - await frame.getByLabel("CVC").blur(); + await page.evaluate(() => window.card.validate()); await expect.poll(async () => values.isValid).toBeFalsy(); await expect.poll(async () => values.isComplete).toBeFalsy(); await expect(frame.getByText("Your CVC is invalid")).toBeVisible(); From f805ed23af3fc56c3d38afc7edae078ef689e179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9?= <136079302+jmorgadosoares@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:28:01 +0100 Subject: [PATCH 9/9] Address PR review comments --- .changeset/spicy-mayflies-give.md | 5 ++-- .../ui-components/tests/cardDetails.spec.js | 23 ------------------- packages/card-validator/index.ts | 14 +++++------ packages/shared/src/useForm.ts | 7 +----- 4 files changed, 10 insertions(+), 39 deletions(-) diff --git a/.changeset/spicy-mayflies-give.md b/.changeset/spicy-mayflies-give.md index 354f309c4..dbe3e26a1 100644 --- a/.changeset/spicy-mayflies-give.md +++ b/.changeset/spicy-mayflies-give.md @@ -1,7 +1,6 @@ --- -"@evervault/card-validator": minor +"@evervault/card-validator": patch "@evervault/ui-components": patch --- -- Adds `reason` to CVC validation failures -- Fixes bug where failed card validation forces a failed CVC validation +- Fixes bug where failed card number validation forces a failed CVC validation diff --git a/e2e-tests/ui-components/tests/cardDetails.spec.js b/e2e-tests/ui-components/tests/cardDetails.spec.js index b7d2caa97..0367ee670 100644 --- a/e2e-tests/ui-components/tests/cardDetails.spec.js +++ b/e2e-tests/ui-components/tests/cardDetails.spec.js @@ -285,29 +285,6 @@ test.describe("card component", () => { await expect(frame.getByText("Your card number is invalid")).toBeVisible(); }); - test("does not show errors when blurring empty fields", async ({ page }) => { - await page.evaluate(() => { - const card = window.evervault.ui.card(); - card.mount("#form"); - }); - - const frame = page.frameLocator("iframe[data-evervault]"); - await frame.getByLabel("Number").focus(); - await frame.getByLabel("Number").blur(); - await frame.getByLabel("Expiration").focus(); - await frame.getByLabel("Expiration").blur(); - await frame.getByLabel("CVC").focus(); - await frame.getByLabel("CVC").blur(); - - await expect( - frame.getByText("Your card number is invalid") - ).not.toBeVisible(); - await expect( - frame.getByText("Your expiration date is invalid") - ).not.toBeVisible(); - await expect(frame.getByText("Your CVC is invalid")).not.toBeVisible(); - }); - test("Manual validation when 3 digit amex is diabled", async ({ page }) => { let values = {}; diff --git a/packages/card-validator/index.ts b/packages/card-validator/index.ts index 38dbd37ed..2461e0905 100644 --- a/packages/card-validator/index.ts +++ b/packages/card-validator/index.ts @@ -128,8 +128,6 @@ export function validateCVC( cardNumber?: string, options?: CardNumberValidationOptions ): CardCVCValidationResult { - const { brand, localBrands } = validateNumber(cardNumber ?? "", options); - // Check if the CVC only contains numbers with 3 or 4 digits if (!/^\d{3,4}$/.test(cvc)) { return { @@ -138,17 +136,19 @@ export function validateCVC( }; } - const brands: string[] = []; + const { brand, localBrands } = validateNumber(cardNumber ?? "", options); + + const brands = new Set(); if (brand) { - brands.push(brand); + brands.add(brand); } if (localBrands) { - brands.push(...localBrands); + for (const b of localBrands) brands.add(b); } - if (!brands.length) { + if (!brands.size) { return { cvc, isValid: true, @@ -158,7 +158,7 @@ export function validateCVC( const allBrands = [...defaultBrands, ...(options?.customBrands ?? [])]; const isCVCValid = allBrands - .filter((brand) => brands.includes(brand.name)) + .filter((brand) => brands.has(brand.name)) .some((brand) => { return brand.securityCodeValidationRules.lengths.includes(cvc.length); }); diff --git a/packages/shared/src/useForm.ts b/packages/shared/src/useForm.ts index fb22e466c..f8afdd906 100644 --- a/packages/shared/src/useForm.ts +++ b/packages/shared/src/useForm.ts @@ -166,12 +166,7 @@ export function useForm({ } ) => { const handleBlur = (e: React.FocusEvent) => { - const value = values[name]; - if (typeof value === "string" && value.length === 0) { - setError(name, undefined); - } else { - validateField(name); - } + validateField(name); events?.onBlur?.(e); };