Skip to content

Commit 8afe79b

Browse files
authored
feat: hs external vault flow and return user support (#1643)
1 parent 3939ef6 commit 8afe79b

15 files changed

Lines changed: 296 additions & 65 deletions

.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ SDK_TAG_VERSION=""
77
SENTRY_DSN=""
88
VISA_API_KEY_ID=""
99
VISA_API_CERTIFICATE_PEM=""
10-
PORT= 9050
10+
IS_PCI_COMPLIANT=""
11+
PORT= 9050

src/CardCVCElement.res

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
11
open RecoilAtoms
22
open Utils
33

4+
// `isVaultCvcFlow` reuses this CVC element for the Hyperswitch-vault saved-card
5+
// (return user) flow. The single submit handler below has two mutually-exclusive
6+
// paths:
7+
// • default (Elements CVC widget): listens for `requestCVCConfirm` and confirms
8+
// the payment-session intent itself with the entered CVC.
9+
// • vault saved-card flow: listens for the forwarded doSubmit, tokenises the CVC
10+
// through the payment-method-session update call, and posts `savedCardCvcTokenEvent`
11+
// back to SavedMethods, which owns the confirm (mirrors the VGS saved-card flow).
412
@react.component
5-
let make = (~cvcProps: CardUtils.cvcProps, ~paymentType: CardThemeType.mode) => {
13+
let make = (
14+
~cvcProps: CardUtils.cvcProps,
15+
~paymentType: CardThemeType.mode,
16+
~isVaultCvcFlow=false,
17+
) => {
618
let {config, localeString} = Recoil.useRecoilValueFromAtom(configAtom)
719
let emitter = SubscriptionEventHooks.useSubscriptionEventEmitter()
820
let {innerLayout} = config.appearance
921
let keys = Recoil.useRecoilValueFromAtom(keys)
1022
let customPodUri = Recoil.useRecoilValueFromAtom(customPodUri)
1123
let loggerState = Recoil.useRecoilValueFromAtom(loggerAtom)
1224
let redirectionFlags = Recoil.useRecoilValueFromAtom(RecoilAtoms.redirectionFlagsAtom)
25+
// Vault credentials (pmSessionId / sdkAuthorization) for the saved-card tokenise
26+
// call; populated by PaymentMethodsSDK in the inner iframe. Only used in vault mode.
27+
let vaultCredentials = Recoil.useRecoilValueFromAtom(RecoilAtoms.vaultCredentials)
1328

1429
let {
1530
isCVCValid,
@@ -26,9 +41,67 @@ let make = (~cvcProps: CardUtils.cvcProps, ~paymentType: CardThemeType.mode) =>
2641
let compressedLayoutStyleForCvcError =
2742
innerLayout === Compressed && cvcError->String.length > 0 ? "!border-l-0" : ""
2843

29-
React.useEffect(() => {
44+
// Single submit handler for both modes (mutually exclusive on `isVaultCvcFlow`):
45+
// • vault saved-card flow: SavedMethods forwards the validated doSubmit (carrying
46+
// the selected card's payment_token); we tokenise the CVC via the
47+
// payment-method-session update call and post the token back as
48+
// `savedCardCvcTokenEvent`. SavedMethods builds the vault_card_token_data confirm
49+
// body and calls intent. Validation / tokenisation failures post a failed-submit
50+
// response, which SavedMethods forwards to Hyper.res to reject confirmPayment().
51+
// • Elements CVC widget: confirms the payment-session intent itself on the
52+
// `requestCVCConfirm` message, posting the response back to the parent window.
53+
let submitCallback = React.useCallback((ev: Window.event) => {
3054
open Promise
31-
let handleRequestCVCConfirm = (ev: Window.event) => {
55+
if isVaultCvcFlow {
56+
let confirmDict = ev.data->safeParse->getDictFromJson
57+
let confirm = confirmDict->ConfirmType.itemToObjMapper
58+
let isOuterValid = confirmDict->getBool("isOuterValid", true)
59+
60+
if confirm.doSubmit {
61+
let paymentMethodToken = confirmDict->getString("paymentToken", "")
62+
let (pmSessionId, sdkAuthorization) = switch vaultCredentials {
63+
| HyperswitchVault(creds) => (creds.pmSessionId, creds.sdkAuthorization)
64+
| _ => ("", "")
65+
}
66+
let isCvcComplete = cvcNumber->String.length >= 3
67+
68+
if isCvcComplete && isOuterValid {
69+
setCvcError(_ => "")
70+
PaymentHelpersV2.updatePaymentMethod(
71+
~bodyArr=PaymentManagementBody.vaultUpdateCVVBody(~cvcNumber),
72+
~pmSessionId,
73+
~logger=loggerState,
74+
~customPodUri,
75+
~sdkAuthorization,
76+
)
77+
->then(res => {
78+
let vaultTokenData = VaultHelpers.decodeVaultTokenData(res)
79+
if vaultTokenData.token !== "" {
80+
messageParentWindow([
81+
("savedCardCvcTokenEvent", true->JSON.Encode.bool),
82+
("cvcToken", vaultTokenData.token->JSON.Encode.string),
83+
])
84+
} else {
85+
postFailedSubmitResponse(~errortype="server_error", ~message="Something went wrong")
86+
}
87+
resolve()
88+
})
89+
->catch(_ => {
90+
postFailedSubmitResponse(~errortype="server_error", ~message="Something went wrong")
91+
resolve()
92+
})
93+
->ignore
94+
} else if isOuterValid {
95+
// Only the CVC is invalid/empty (outer fields are validated upstream).
96+
let errorMsg =
97+
cvcNumber->String.length == 0
98+
? localeString.cvcNumberEmptyText
99+
: localeString.inCompleteCVCErrorText
100+
setCvcError(_ => errorMsg)
101+
postFailedSubmitResponse(~errortype="validation_error", ~message=errorMsg)
102+
}
103+
}
104+
} else {
32105
let json = ev.data->safeParse
33106
try {
34107
let dict = json->getDictFromJson
@@ -127,13 +200,18 @@ let make = (~cvcProps: CardUtils.cvcProps, ~paymentType: CardThemeType.mode) =>
127200
])
128201
}
129202
}
130-
Window.addEventListener("message", handleRequestCVCConfirm)
131-
Some(
132-
() => {
133-
Window.removeEventListener("message", handleRequestCVCConfirm)
134-
},
135-
)
136-
}, (cvcNumber, keys, paymentType, loggerState, customPodUri, redirectionFlags, localeString))
203+
}, (
204+
isVaultCvcFlow,
205+
cvcNumber,
206+
keys,
207+
paymentType,
208+
loggerState,
209+
customPodUri,
210+
redirectionFlags,
211+
localeString,
212+
vaultCredentials,
213+
))
214+
useSubmitPaymentData(submitCallback)
137215

138216
React.useEffect(() => {
139217
SubscriptionEventHooks.emitReady(
@@ -144,18 +222,19 @@ let make = (~cvcProps: CardUtils.cvcProps, ~paymentType: CardThemeType.mode) =>
144222
}, (keys.iframeId, paymentType))
145223

146224
React.useEffect(() => {
147-
let cvcInfoDict = [("isCvcEmpty", isCvcEmpty->JSON.Encode.bool)]->Dict.fromArray
148-
Utils.messageParentWindow([("cvcInfo", cvcInfoDict->JSON.Encode.object)])
225+
if !isVaultCvcFlow {
226+
let cvcInfoDict = [("isCvcEmpty", isCvcEmpty->JSON.Encode.bool)]->Dict.fromArray
227+
Utils.messageParentWindow([("cvcInfo", cvcInfoDict->JSON.Encode.object)])
228+
}
149229
None
150230
}, [isCvcEmpty])
151-
152231
React.useEffect(() => {
153232
emitter.emitCvcStatus(~iframeId=keys.iframeId, ~isCvcEmpty, ~isCvcComplete)
154233
None
155234
}, (isCvcEmpty, isCvcComplete, keys.iframeId))
156235

157236
<PaymentInputField
158-
fieldName=localeString.cvcTextLabel
237+
fieldName={isVaultCvcFlow ? "" : localeString.cvcTextLabel}
159238
isValid=isCVCValid
160239
setIsValid=setIsCVCValid
161240
value=cvcNumber
@@ -167,6 +246,7 @@ let make = (~cvcProps: CardUtils.cvcProps, ~paymentType: CardThemeType.mode) =>
167246
maxLength=4
168247
inputRef=cvcRef
169248
placeholder="123"
249+
height={isVaultCvcFlow ? "1.8rem" : ""}
170250
name=TestUtils.cardCVVInputTestId
171251
autocomplete="cc-csc"
172252
/>

src/Components/SavedCardItem.res

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,12 @@ let make = (
6262
~installmentsError,
6363
~setInstallmentsError,
6464
~eligibilitySurchargeDetails: option<EligibilityHelpers.eligibilitySurchargeDetails>,
65-
// VGS saved-card (return user) flow: when true, this card's CVC is collected
66-
// inside the nested iframe (ParentCardComponent saved-card mode) instead of the
67-
// plain input. `setCvcIframeRef` lifts the iframe ref to SavedMethods, which
68-
// owns submit. Defaults keep other call sites (e.g. ClickToPayAuthenticate) intact.
69-
~isVgsCvcFlow=false,
65+
// Vault saved-card (return user) flow (VGS or Hyperswitch): when true, this card's
66+
// CVC is collected inside the nested iframe (ParentCardComponent saved-card mode)
67+
// instead of the plain input. `setCvcIframeRef` lifts the iframe ref to
68+
// SavedMethods, which owns submit. Defaults keep other call sites
69+
// (e.g. ClickToPayAuthenticate) intact.
70+
~isVaultCvcFlow=false,
7071
~setCvcIframeRef=_ => (),
7172
) => {
7273
let {themeObj, config, localeString} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom)
@@ -147,14 +148,14 @@ let make = (
147148
}, (isActive, paymentItem, country, state, pinCode, isCvcEmpty))
148149

149150
React.useEffect(() => {
151+
open CardUtils
150152
if isActive {
151153
// * Focus CVC
152154
focusCVC()
153-
154155
// * Sending card expiry to handle cases where the card expires before the use date.
155156
`${expiryMonth}${String.substring(~start=2, ~end=4, expiryYear)}`
156157
->CardValidations.formatCardExpiryNumber
157-
->CardUtils.emitExpiryDate
158+
->emitExpiryDate
158159
}
159160
None
160161
}, (isActive, paymentItem, country, state, pinCode))
@@ -219,7 +220,7 @@ let make = (
219220
// VGS saved-card flow renders the secure CVC iframe in place of the plain input;
220221
// ParentCardComponent (saved-card mode) hosts it and lifts its ref to SavedMethods.
221222
let makeCvcField = (~fieldName="", ~height="1.8rem", ~inputFieldClassName="flex justify-start") =>
222-
isVgsCvcFlow
223+
isVaultCvcFlow
223224
? <ParentCardComponent
224225
isSavedCardFlow=true containerId=cvcIframeContainerId setExternalIframeRef=setCvcIframeRef
225226
/>

src/Components/SavedMethods.res

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,22 @@ let make = (
111111
() => VaultHelpers.getVaultCredentialsFromSessions(sessionToken),
112112
[sessionToken],
113113
)
114-
let isVgsCvcFlow =
114+
// Either vault (VGS or Hyperswitch) collects + tokenises the saved-card CVC inside
115+
// the nested iframe (ParentCardComponent saved-card mode); SavedMethods stays the
116+
// submit owner. The vault provider only changes what renders inside that iframe —
117+
// SavedMethods' forward-doSubmit / await-token / confirm logic is identical — so a
118+
// single flag covers both.
119+
let isVaultCvcFlow =
115120
isTokenize &&
116121
switch vaultCredentials {
117-
| VGS(_) => true
118-
| _ => false
122+
| VGS(_) | HyperswitchVault(_) => true
123+
| NoVault => false
119124
}
125+
126+
let isHyperswitchVault = switch vaultCredentials {
127+
| HyperswitchVault(_) => true
128+
| _ => false
129+
}
120130
let cvcIframeRef = React.useRef(Nullable.null)
121131
let setCvcIframeRef = React.useCallback(ref => {
122132
cvcIframeRef.current = ref
@@ -152,7 +162,7 @@ let make = (
152162
eligibilitySurchargeDetails={paymentTokenVal == obj.paymentToken
153163
? eligibilitySurchargeDetails
154164
: None}
155-
isVgsCvcFlow
165+
isVaultCvcFlow
156166
setCvcIframeRef
157167
/>
158168
)
@@ -189,7 +199,7 @@ let make = (
189199
}
190200
// VGS collects + validates the CVC inside the iframe, so the outer plain-CVC
191201
// "empty" signal is not meaningful — treat it as filled.
192-
let empty = isVgsCvcFlow ? false : cvcNumber == ""
202+
let empty = isVaultCvcFlow ? false : cvcNumber == ""
193203
let customerMethod = React.useMemo(_ =>
194204
savedMethods
195205
->Array.concat(customerMethods)
@@ -203,7 +213,9 @@ let make = (
203213
// submit), so outer validity does not depend on the plain CVC field — mirroring
204214
// how the new-card flow lets the inner iframe gate the card fields.
205215
let isCardPaymentMethodValid =
206-
isVgsCvcFlow && isCardPaymentMethod ? true : !customerMethod.requiresCvv || (complete && !empty)
216+
isVaultCvcFlow && isCardPaymentMethod
217+
? true
218+
: !customerMethod.requiresCvv || (complete && !empty)
207219
let isInstallmentValid = !showInstallments || selectedInstallmentPlan->Option.isSome
208220

209221
let shouldDoEligibility = paymentMethodListValue.sdk_next_action === Some("eligibility_check")
@@ -467,25 +479,38 @@ let make = (
467479
)
468480
}
469481
| _ =>
470-
if isVgsCvcFlow && customerMethod.paymentMethod === "card" && customerMethod.requiresCvv {
471-
// VGS saved-card flow: forward the validated doSubmit to the CVC iframe.
472-
// It tokenises the CVC and posts `vgsSavedCardCvcTokenEvent` back; we
473-
// then confirm with the vault_card_token_data body (otherwise identical
474-
// to the plain saved-card confirm — same business-logic merge).
482+
if (
483+
isVaultCvcFlow && customerMethod.paymentMethod === "card" && customerMethod.requiresCvv
484+
) {
485+
// Vault saved-card flow (VGS or Hyperswitch): forward the validated
486+
// doSubmit to the CVC iframe. It tokenises the CVC and posts
487+
// `savedCardCvcTokenEvent` back; we then confirm with the
488+
// vault_card_token_data body (otherwise identical to the plain saved-card
489+
// confirm — same business-logic merge). The Hyperswitch tokeniser needs
490+
// the selected card's payment_token, so forward it in the message.
475491
let innerMsg = json->getDictFromJson
476492
innerMsg->Dict.set("isOuterValid", true->JSON.Encode.bool)
493+
innerMsg->Dict.set("paymentToken", paymentTokenVal->JSON.Encode.string)
477494
cvcIframeRef.current->Window.iframePostMessage(innerMsg)
478495
let handle = (ev: Types.event) => {
479496
let dict = ev.data->Identity.anyTypeToJson->getDictFromJson
480-
if dict->Dict.get("vgsSavedCardCvcTokenEvent")->Option.isSome {
497+
if dict->Dict.get("savedCardCvcTokenEvent")->Option.isSome {
481498
let cvcToken = dict->getString("cvcToken", "")
482-
let vaultBody =
483-
PaymentBody.savedCardVaultCvcBody(
484-
~paymentToken=paymentTokenVal,
485-
~customerId,
486-
~cvcToken,
487-
~isCustomerAcceptanceRequired,
488-
)->Array.concat(installmentBody)
499+
let cvcConfirmBody =
500+
isHyperswitchVault && GlobalVars.isPciCompliant
501+
? PaymentBody.savedCardVaultCvcBody(
502+
~paymentToken=paymentTokenVal,
503+
~customerId,
504+
~cvcToken,
505+
~isCustomerAcceptanceRequired,
506+
)
507+
: PaymentBody.externalSavedCardVaultCvcBody(
508+
~paymentToken=paymentTokenVal,
509+
~customerId,
510+
~cvcToken,
511+
~isCustomerAcceptanceRequired,
512+
)
513+
let vaultBody = cvcConfirmBody->Array.concat(installmentBody)
489514
intent(
490515
~bodyArr=vaultBody->mergeAndFlattenToTuples(requiredFieldsBody),
491516
~confirmParam=confirm.confirmParams,
@@ -552,7 +577,8 @@ let make = (
552577
showInstallments,
553578
sdkAuthorization,
554579
isEligibilityPending,
555-
isVgsCvcFlow,
580+
isHyperswitchVault,
581+
isVaultCvcFlow,
556582
paymentTokenVal,
557583
customerId,
558584
))

src/GlobalVars.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ let targetOrigin: string = "*"
1515
@val external isSandbox: bool = "isSandboxEnv"
1616
@val external isProd: bool = "isProductionEnv"
1717
@val external isLocal: bool = "isLocal"
18+
@val external isPciCompliant: bool = "isPciCompliant"

src/LoaderController.res

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,10 @@ let make = (~children, ~paymentMode, ~setIntegrateErrorError, ~logger, ~initTime
456456
// PaymentMethodsSDK reads this atom to render only the vault CVC field.
457457
if dict->Dict.get("isSavedCardCvcFlow")->Option.isSome {
458458
setIsSavedCardCvcFlow(_ => dict->Utils.getBool("isSavedCardCvcFlow", false))
459+
switch dict->getString("endpoint", "") {
460+
| "" => ()
461+
| endpoint => ApiEndpoint.setApiEndPoint(endpoint)
462+
}
459463
}
460464
if dict->getDictIsSome("sessions") {
461465
setSessions(_ => Loaded(dict->getJsonObjectFromDict("sessions")))

src/Payments/CardsSDK.res

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ let make = (~cvcOnly=false) => {
2121

2222
switch vaultCredentials {
2323
| HyperswitchVault(_) =>
24-
// TODO: render a Hyperswitch-vault CVC-only component for the saved-card flow.
25-
// Until then, don't fall back to a full card form in the CVC-only slot.
26-
<RenderIf condition={!cvcOnly}>
27-
<CardPayment cardProps expiryProps cvcProps isInsideCardSDK=true />
28-
</RenderIf>
24+
// Saved-card (return user) flow renders the CVC-only widget, which tokenises the
25+
// CVC via the payment-method-session update call; the new-card flow renders the
26+
// full card form. CardCVCElement is reused here in its vault-tokenise mode.
27+
cvcOnly
28+
? <CardCVCElement cvcProps paymentType=CardThemeType.CardCVCElement isVaultCvcFlow=true />
29+
: <CardPayment cardProps expiryProps cvcProps isInsideCardSDK=true />
2930
| VGS(_) => <VGSVault cvcOnly />
3031
| NoVault =>
3132
// Vault details not yet loaded. For the new-card flow render the form so the

0 commit comments

Comments
 (0)