From 5f62df43b6873243e519b09b4ed87b7d8b0f868f Mon Sep 17 00:00:00 2001 From: Hossam Date: Sun, 26 Jul 2026 05:45:02 +0300 Subject: [PATCH 1/3] fix: size and validate the CVC from the detected card brand --- src/CardUtils.res | 2 ++ src/Components/CardFields.res | 3 ++- src/Hooks/CommonCardProps.res | 41 ++++++++++++++++++++++++++++------- src/SingleLineCardPayment.res | 3 ++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/CardUtils.res b/src/CardUtils.res index 5fcde327b..476465f80 100644 --- a/src/CardUtils.res +++ b/src/CardUtils.res @@ -95,6 +95,7 @@ type cvcProps = { onCvcKeyDown: ReactEvent.Keyboard.t => unit, cvcError: string, setCvcError: (string => string) => unit, + maxCVCLength: int, } let useDefaultCvcProps = () => { @@ -110,6 +111,7 @@ let useDefaultCvcProps = () => { onCvcKeyDown: _ => (), cvcError: "", setCvcError: _ => (), + maxCVCLength: CardValidations.getobjFromCardPattern("").maxCVCLength, } } diff --git a/src/Components/CardFields.res b/src/Components/CardFields.res index d98205a1b..32aab1575 100644 --- a/src/Components/CardFields.res +++ b/src/Components/CardFields.res @@ -38,6 +38,7 @@ let make = ( handleCVCBlur, cvcRef, cvcError, + maxCVCLength, } = cvcProps let isCvcValidValue = CardUtils.getBoolOptionVal(isCVCValid) @@ -118,7 +119,7 @@ let make = ( )} type_="tel" className={`tracking-widest w-full ${compressedLayoutStyleForCvcError}`} - maxLength=4 + maxLength=maxCVCLength inputRef=cvcRef placeholder="123" name=TestUtils.cardCVVInputTestId diff --git a/src/Hooks/CommonCardProps.res b/src/Hooks/CommonCardProps.res index 37fa78619..e3325e93b 100644 --- a/src/Hooks/CommonCardProps.res +++ b/src/Hooks/CommonCardProps.res @@ -56,6 +56,7 @@ let useCardForm = ( !showPaymentMethodsScreen && isNotBancontact ? cardScheme : detectedCardBrand ) + let stateCardBrand = cardBrand let derivedCardBrand = CardUtils.getCardBrandFromStates( cardBrand, cardScheme, @@ -63,6 +64,14 @@ let useCardForm = ( ) let cardBrand = cardBrandOverride === "" ? derivedCardBrand : cardBrandOverride->CardUtils.normalizeCardBrand + + // Nothing in the card element's tree writes the scheme atom, so the brand is lost here and the CVC + // rules fall back to the permissive default. CVC-scoped: widening cardBrand changes the payload. + let cardBrandForCvc = switch (cardBrand, paymentType) { + | ("", CardThemeType.Card) => stateCardBrand + | _ => cardBrand + } + let maxCVCLength = CardValidations.getobjFromCardPattern(cardBrandForCvc).maxCVCLength let supportedCardBrands = React.useMemo(() => { switch forwardedSupportedCardBrands { | Some(brands) => Some(brands) @@ -89,12 +98,26 @@ let useCardForm = ( cardBrand->getCardType }, [cardBrand]) + // maxLength only constrains new input, so a CVC entered before the brand was known has to be + // re-judged against it, by the same rule as a blur: narrowing invalidates it, widening restores it. + React.useEffect1(() => { + if ( + cvcNumber->String.length > 0 && + cvcNumberInRange(cvcNumber, cardBrandForCvc)->Array.includes(true) + ) { + setIsCVCValid(_ => Some(true)) + } else if cvcNumber->String.length == 0 { + setIsCVCValid(_ => None) + } else { + setIsCVCValid(_ => Some(false)) + } + None + }, [cardBrandForCvc]) + React.useEffect(() => { - let obj = CardValidations.getobjFromCardPattern(cardBrand) - let cvcLength = obj.maxCVCLength if ( - cvcNumberInRange(cvcNumber, cardBrand)->Array.includes(true) && - cvcNumber->String.length == cvcLength + cvcNumberInRange(cvcNumber, cardBrandForCvc)->Array.includes(true) && + cvcNumber->String.length == maxCVCLength ) { blurRef(cvcRef) } @@ -179,13 +202,13 @@ let useCardForm = ( let changeCVCNumber = ev => { let val = ReactEvent.Form.target(ev)["value"] logInputChangeInfo("cardCVC", logger) - let cvc = val->CardValidations.formatCVCNumber(cardBrand) + let cvc = val->CardValidations.formatCVCNumber(cardBrandForCvc) setCvcNumber(_ => cvc) - if cvc->String.length > 0 && cvcNumberInRange(cvc, cardBrand)->Array.includes(true) { + if cvc->String.length > 0 && cvcNumberInRange(cvc, cardBrandForCvc)->Array.includes(true) { zipRef.current->Nullable.toOption->Option.forEach(input => input->focus)->ignore } - if cvc->String.length > 0 && cvcNumberInRange(cvc, cardBrand)->Array.includes(true) { + if cvc->String.length > 0 && cvcNumberInRange(cvc, cardBrandForCvc)->Array.includes(true) { setIsCVCValid(_ => Some(true)) } else { setIsCVCValid(_ => None) @@ -275,7 +298,8 @@ let useCardForm = ( let handleCVCBlur = ev => { let cvcNumber = ReactEvent.Focus.target(ev)["value"] if ( - cvcNumber->String.length > 0 && cvcNumberInRange(cvcNumber, cardBrand)->Array.includes(true) + cvcNumber->String.length > 0 && + cvcNumberInRange(cvcNumber, cardBrandForCvc)->Array.includes(true) ) { setIsCVCValid(_ => Some(true)) } else if cvcNumber->String.length == 0 { @@ -390,6 +414,7 @@ let useCardForm = ( onCvcKeyDown, cvcError, setCvcError, + maxCVCLength, } let zipProps: CardUtils.zipProps = { diff --git a/src/SingleLineCardPayment.res b/src/SingleLineCardPayment.res index f8dd16e46..352d4f27d 100644 --- a/src/SingleLineCardPayment.res +++ b/src/SingleLineCardPayment.res @@ -43,6 +43,7 @@ let make = ( handleCVCBlur, cvcRef, onCvcKeyDown, + maxCVCLength, } = cvcProps let { @@ -159,7 +160,7 @@ let make = ( paymentType type_="tel" className={`tracking-widest w-auto`} - maxLength=4 + maxLength=maxCVCLength inputRef=cvcRef placeholder="123" isFocus From a896c7b5aca973ae01ab12d763770fdfb6018adf Mon Sep 17 00:00:00 2001 From: Hossam Date: Thu, 30 Jul 2026 17:46:49 +0300 Subject: [PATCH 2/3] test: assert the CVC maxlength follows the detected card brand --- .../e2e/02-cards/01-card-validation.cy.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts b/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts index b168c5516..eb2f89716 100644 --- a/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts +++ b/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts @@ -201,6 +201,32 @@ describe("Card Number Validation", () => { }); }); + describe("CVC Length by Card Brand", () => { + it("should size the CVC to 3 for a Visa", () => { + const { cardNo } = stripeCards.successCard; + + getIframeBody() + .find(`[data-testid=${testIds.cardNoInputTestId}]`) + .type(cardNo); + + getIframeBody() + .find(`[data-testid=${testIds.cardCVVInputTestId}]`) + .should("have.attr", "maxlength", "3"); + }); + + it("should size the CVC to 4 for an American Express", () => { + const { cardNo } = stripeCards.amexCard15; + + getIframeBody() + .find(`[data-testid=${testIds.cardNoInputTestId}]`) + .type(cardNo); + + getIframeBody() + .find(`[data-testid=${testIds.cardCVVInputTestId}]`) + .should("have.attr", "maxlength", "4"); + }); + }); + describe("Card Brand Icons", () => { it("should display card brand icon dynamically for Visa", () => { getIframeBody() From 392b3e46e6c7970ecc8547a97b9ccc6e2d9b6199 Mon Sep 17 00:00:00 2001 From: Hossam Date: Thu, 30 Jul 2026 17:52:49 +0300 Subject: [PATCH 3/3] test: assert the CVC invalid mark clears on focus, including the expiry auto-advance --- .../e2e/02-cards/01-card-validation.cy.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts b/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts index eb2f89716..ab1740c44 100644 --- a/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts +++ b/cypress-tests/cypress/e2e/02-cards/01-card-validation.cy.ts @@ -227,6 +227,48 @@ describe("Card Number Validation", () => { }); }); + describe("CVC Validity Styling", () => { + // A field is marked invalid on blur and unmarked while focused, uniformly across the + // SDK. The second case matters because the expiry auto-advances into the CVC: focus + // must still land there, and must still clear the mark. + const typeVisaThenShortCvc = () => { + getIframeBody() + .find(`[data-testid=${testIds.cardNoInputTestId}]`) + .type(stripeCards.successCard.cardNo); + + getIframeBody() + .find(`[data-testid=${testIds.cardCVVInputTestId}]`) + .type("12"); + }; + + it("should mark an out-of-range CVC invalid on blur and unmark it on focus", () => { + typeVisaThenShortCvc(); + + getIframeBody().find(`[data-testid=${testIds.cardNoInputTestId}]`).click(); + getIframeBody() + .find(`[data-testid=${testIds.cardCVVInputTestId}]`) + .should("have.class", "Input--invalid"); + + getIframeBody().find(`[data-testid=${testIds.cardCVVInputTestId}]`).click(); + getIframeBody() + .find(`[data-testid=${testIds.cardCVVInputTestId}]`) + .should("not.have.class", "Input--invalid"); + }); + + it("should focus the CVC and unmark it when the expiry auto-advances", () => { + typeVisaThenShortCvc(); + + getIframeBody() + .find(`[data-testid=${testIds.expiryInputTestId}]`) + .type("1230"); + + getIframeBody() + .find(`[data-testid=${testIds.cardCVVInputTestId}]`) + .should("have.focus") + .and("not.have.class", "Input--invalid"); + }); + }); + describe("Card Brand Icons", () => { it("should display card brand icon dynamically for Visa", () => { getIframeBody()