From 327c83f9f845ef8ad5ad129c1d9cf5f32a40fade Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Wed, 1 Jul 2026 21:33:53 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8D=B1=20app:=20add=20google=20wallet?= =?UTF-8?q?=20assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/images/google-wallet-button-es.svg | 1 + src/assets/images/google-wallet-button-pt.svg | 1 + src/assets/images/google-wallet-button.svg | 1 + src/assets/images/google-wallet-icon.svg | 1 + 4 files changed, 4 insertions(+) create mode 100644 src/assets/images/google-wallet-button-es.svg create mode 100644 src/assets/images/google-wallet-button-pt.svg create mode 100644 src/assets/images/google-wallet-button.svg create mode 100644 src/assets/images/google-wallet-icon.svg diff --git a/src/assets/images/google-wallet-button-es.svg b/src/assets/images/google-wallet-button-es.svg new file mode 100644 index 0000000000..aa19fd4f31 --- /dev/null +++ b/src/assets/images/google-wallet-button-es.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/google-wallet-button-pt.svg b/src/assets/images/google-wallet-button-pt.svg new file mode 100644 index 0000000000..69e54e736a --- /dev/null +++ b/src/assets/images/google-wallet-button-pt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/google-wallet-button.svg b/src/assets/images/google-wallet-button.svg new file mode 100644 index 0000000000..33c59ece48 --- /dev/null +++ b/src/assets/images/google-wallet-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/google-wallet-icon.svg b/src/assets/images/google-wallet-icon.svg new file mode 100644 index 0000000000..5540b44679 --- /dev/null +++ b/src/assets/images/google-wallet-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file From 8c3087a79b673aad1318e09d15da2c84ce682b23 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 20 Jul 2026 16:49:54 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20app:=20add=20card=20wallet=20pr?= =?UTF-8?q?ovisioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/gentle-cases-fold.md | 5 + .maestro/subflows/activateCard.yaml | 5 +- src/components/card/Card.tsx | 99 ++++++- .../{CardDetails.tsx => CardDetailsSheet.tsx} | 11 +- src/components/card/useWalletProvisioning.ts | 268 ++++++++++++++++++ src/components/home/Home.tsx | 2 +- src/components/settings/Settings.tsx | 3 +- src/i18n/en.json | 2 + src/i18n/es-AR.json | 3 + src/i18n/es.json | 4 + src/i18n/pt.json | 4 + src/utils/meaWallet.ts | 19 ++ src/utils/queryClient.ts | 4 +- src/utils/reportError.ts | 26 +- src/utils/server.ts | 22 +- 15 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 .changeset/gentle-cases-fold.md rename src/components/card/{CardDetails.tsx => CardDetailsSheet.tsx} (95%) create mode 100644 src/components/card/useWalletProvisioning.ts create mode 100644 src/utils/meaWallet.ts diff --git a/.changeset/gentle-cases-fold.md b/.changeset/gentle-cases-fold.md new file mode 100644 index 0000000000..d35b2f747c --- /dev/null +++ b/.changeset/gentle-cases-fold.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add wallet provisioning diff --git a/.maestro/subflows/activateCard.yaml b/.maestro/subflows/activateCard.yaml index 0d54c41001..86aa3714a6 100644 --- a/.maestro/subflows/activateCard.yaml +++ b/.maestro/subflows/activateCard.yaml @@ -8,7 +8,10 @@ appId: ${APP_ID ?? "app.exactly"} commands: - runFlow: { file: scrollTo.yaml, env: { element: Accept and enable card } } - tapOn: Accept and enable card -- assertVisible: Manually add your card to Apple Pay & Google Pay to make contactless payments. +- runFlow: + when: { platform: web } + commands: + - assertVisible: Manually add your card to Apple Pay & Google Pay to make contactless payments. - tapOn: Close - tapOn: Freeze card - tapOn: { text: Freeze card, below: "Freeze your card?" } diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index e383347731..b512e3bf18 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -1,5 +1,6 @@ -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { Platform, Pressable } from "react-native"; import { selectionAsync } from "expo-haptics"; import { useRouter } from "expo-router"; @@ -15,14 +16,19 @@ import accountInit from "@exactly/common/accountInit"; import chain, { marketUSDCAddress } from "@exactly/common/generated/chain"; import { useReadUpgradeableModularAccountGetInstalledPlugins } from "@exactly/common/generated/hooks"; -import CardDetails from "./CardDetails"; +import CardDetailsSheet from "./CardDetailsSheet"; import CardDisclaimer from "./CardDisclaimer"; import CardFreezeSheet from "./CardFreezeSheet"; import CardPIN from "./CardPIN"; import ExaCard from "./exa-card/ExaCard"; import SpendingLimits from "./SpendingLimits"; import TimeoutSheet from "./TimeoutSheet"; +import useWalletProvisioning from "./useWalletProvisioning"; import VerificationFailure from "./VerificationFailure"; +import GoogleWalletButtonEs from "../../assets/images/google-wallet-button-es.svg"; +import GoogleWalletButtonPt from "../../assets/images/google-wallet-button-pt.svg"; +import GoogleWalletButtonEn from "../../assets/images/google-wallet-button.svg"; +import GoogleWalletIcon from "../../assets/images/google-wallet-icon.svg"; import { presentArticle } from "../../utils/intercom"; import openBrowser from "../../utils/openBrowser"; import queryClient from "../../utils/queryClient"; @@ -32,7 +38,7 @@ import { createCard, setCardStatus, type CardActivity, - type CardDetails as CardDetailsData, + type CardDetails, type KYCStatus, } from "../../utils/server"; import useAccount from "../../utils/useAccount"; @@ -40,6 +46,7 @@ import useAsset from "../../utils/useAsset"; import useBeginKYC from "../../utils/useBeginKYC"; import useMarkets from "../../utils/useMarkets"; import useTabPress from "../../utils/useTabPress"; +import { saveSnapshot } from "../../utils/walletExtensionStorage"; import weeklySpend from "../../utils/weeklySpend"; import FundingAlert from "../shared/FundingAlert"; import IconButton from "../shared/IconButton"; @@ -54,6 +61,11 @@ import Text from "../shared/Text"; import View from "../shared/View"; import type { Credential } from "@exactly/common/validation"; +const googleWalletButtons = { + en: { Component: GoogleWalletButtonEn, height: 32, width: 116 }, + es: { Component: GoogleWalletButtonEs, height: 32, width: 139 }, + pt: { Component: GoogleWalletButtonPt, height: 32, width: 140 }, +} satisfies Record; export default function Card() { const toast = useToastController(); @@ -63,6 +75,12 @@ export default function Card() { t, i18n: { language }, } = useTranslation(); + const googleWalletButton = language.startsWith("es") + ? googleWalletButtons.es + : language.startsWith("pt") + ? googleWalletButtons.pt + : googleWalletButtons.en; + const GoogleWalletButton = googleWalletButton.Component; const [disclaimerShown, setDisclaimerShown] = useState(false); const [verificationFailureShown, setVerificationFailureShown] = useState(false); const [freezeConfirmOpen, setFreezeConfirmOpen] = useState(false); @@ -81,7 +99,18 @@ export default function Card() { data: cardDetails, refetch: refetchCard, isFetching: isFetchingCard, - } = useQuery({ queryKey: ["card", "details"], retry: false, gcTime: 0, staleTime: 0 }); + } = useQuery({ queryKey: ["card", "details"], retry: false, gcTime: 0, staleTime: 0 }); + + useEffect(() => { + if (!cardDetails) return; + saveSnapshot({ + displayName: cardDetails.displayName, + expirationMonth: cardDetails.expirationMonth, + expirationYear: cardDetails.expirationYear, + lastFour: cardDetails.lastFour, + productId: cardDetails.productId, + }).catch(reportError); + }, [cardDetails]); const limit = cardDetails?.limit.amount ? cardDetails.limit.amount / 100 : undefined; const totalSpent = weeklySpend(purchases); @@ -121,6 +150,7 @@ export default function Card() { address ? refetchBytecode() : undefined, address ? refetchMarkets() : undefined, address && credential ? refetchInstalledPlugins() : undefined, + syncWalletEligibility(cardDetails?.lastFour, address), queryClient.refetchQueries({ queryKey }), ]); useTabPress("card", () => { @@ -268,6 +298,15 @@ export default function Card() { }, }); + const { + AddPassButton, + addToAppleWallet, + addToGoogleWallet, + isPendingWallet, + provisioning, + syncWalletEligibility, + walletEligible, + } = useWalletProvisioning({ address, cardDetails, t }); const displayStatus = isSettingCardStatus ? optimisticCardStatus : cardDetails?.status; return ( @@ -335,6 +374,56 @@ export default function Card() { revealCard().catch(reportError); }} /> + {Platform.OS !== "web" && + cardDetails && + !isPendingWallet && + walletEligible && + (walletEligible.apple !== "hidden" || walletEligible.google !== "hidden") ? ( + + {provisioning ? ( + + ) : ( + <> + {AddPassButton && walletEligible.apple === "cta" ? ( + { + addToAppleWallet().catch(reportError); + }} + /> + ) : null} + {walletEligible.google === "cta" ? ( + { + addToGoogleWallet().catch(reportError); + }} + > + + + ) : null} + {walletEligible.google === "added" ? ( + + + + + {t("Added to Google Wallet")} + + + ) : null} + + )} + + ) : null} - { queryClient.setQueryData(["card-details-open"], false); diff --git a/src/components/card/CardDetails.tsx b/src/components/card/CardDetailsSheet.tsx similarity index 95% rename from src/components/card/CardDetails.tsx rename to src/components/card/CardDetailsSheet.tsx index a193b68cbf..12760d1569 100644 --- a/src/components/card/CardDetails.tsx +++ b/src/components/card/CardDetailsSheet.tsx @@ -29,15 +29,14 @@ import Skeleton from "../shared/Skeleton"; import Text from "../shared/Text"; import View from "../shared/View"; -import type { CardDetails as CardDetailsData } from "../../utils/server"; +import type { CardDetails } from "../../utils/server"; -export default function CardDetails({ open, onClose }: { onClose: () => void; open: boolean }) { +export default function CardDetailsSheet({ open, onClose }: { onClose: () => void; open: boolean }) { const theme = useThemeName(); const toast = useToastController(); const { t } = useTranslation(); const { data: alertShown } = useQuery({ queryKey: ["settings", "alertShown"] }); - const { data: card, isPending } = useQuery({ queryKey: ["card", "details"] }); - const banner = card?.productId === SIGNATURE_PRODUCT_ID || card?.productId === BASE_PRODUCT_ID; + const { data: card, isPending } = useQuery({ queryKey: ["card", "details"] }); const [details, setDetails] = useState({ pan: "", cvc: "" }); useEffect(() => { if (card?.encryptedPan && card.encryptedCvc) { @@ -78,7 +77,7 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op width="100%" gap="$s4" > - {banner ? ( + {card.productId === SIGNATURE_PRODUCT_ID || card.productId === BASE_PRODUCT_ID ? ( <> @@ -180,6 +179,7 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op ) : null} + {card && alertShown ? ( void; op }} /> ) : null} + diff --git a/src/components/card/useWalletProvisioning.ts b/src/components/card/useWalletProvisioning.ts new file mode 100644 index 0000000000..adaf3b647f --- /dev/null +++ b/src/components/card/useWalletProvisioning.ts @@ -0,0 +1,268 @@ +import { useEffect, useRef, useState } from "react"; +import { Alert, AppState, Platform } from "react-native"; + +import { useQuery } from "@tanstack/react-query"; +import { literal, object, safeParse, union } from "valibot"; + +import init from "../../utils/meaWallet"; +import queryClient from "../../utils/queryClient"; +import reportError, { classifyError } from "../../utils/reportError"; + +import type { CardDetails } from "../../utils/server"; +import type * as MeaWallet from "@meawallet/react-native-mpp"; +import type { TFunction } from "i18next"; + +type WalletStatus = "added" | "cta" | "hidden"; +type WalletEligibility = { + apple: WalletStatus; + google: WalletStatus; + googleToken: MeaWallet.GooglePayTokenInfo | null; +}; + +const hiddenWallet = { apple: "hidden", google: "hidden", googleToken: null } satisfies WalletEligibility; +const primaryAccountIdentifiers = new Map(); + +export default function useWalletProvisioning({ + address, + cardDetails, + t, +}: { + address: string | undefined; + cardDetails: CardDetails | undefined; + t: TFunction; +}) { + const [sdk, setSdk] = useState(null); + const [provisioning, setProvisioning] = useState(false); + const walletInFlightRef = useRef(false); + const { data: walletEligible, isPending: isPendingWallet } = useQuery({ + queryKey: ["wallet", "eligible", address, cardDetails?.lastFour], + enabled: Platform.OS !== "web" && cardDetails?.lastFour.length === 4, + queryFn: async () => { + const lastFour = cardDetails?.lastFour; + if (!lastFour || Platform.OS === "web") return hiddenWallet; + const nextWallet = await init(); + if (Platform.OS === "ios") { + try { + if (!(await nextWallet.default.ApplePay.isPassLibraryAvailable())) return hiddenWallet; + if (!(await nextWallet.default.ApplePay.canAddPaymentPass())) return hiddenWallet; + const primaryAccountIdentifierKey = `${address ?? ""}:${lastFour}`; + let primaryAccountIdentifier = primaryAccountIdentifiers.get(primaryAccountIdentifierKey); + if (primaryAccountIdentifier === undefined) { + const { cardId, cardSecret } = await queryClient.fetchQuery<{ + cardId: string; + cardSecret: string; + }>({ queryKey: ["card", "provisioning"] }); + ({ primaryAccountIdentifier } = await nextWallet.default.ApplePay.initializeOemTokenization( + nextWallet.MppCardDataParameters.withCardSecret(cardId, cardSecret), + )); + } + if (primaryAccountIdentifier) { + primaryAccountIdentifiers.set(primaryAccountIdentifierKey, primaryAccountIdentifier); + const secureElementPassExists = + await nextWallet.default.ApplePay.secureElementPassExistsWithPrimaryAccountIdentifier( + primaryAccountIdentifier, + ); + const [canAddByPrimaryAccountIdentifier, canAddSecureElement] = await Promise.all([ + nextWallet.default.ApplePay.canAddPaymentPassWithPrimaryAccountIdentifier(primaryAccountIdentifier).catch( + () => undefined, + ), + nextWallet.default.ApplePay.canAddSecureElementPassWithPrimaryAccountIdentifier( + primaryAccountIdentifier, + ).catch(() => undefined), + ]); + return { + apple: + canAddSecureElement === true || + canAddByPrimaryAccountIdentifier === true || + ((canAddSecureElement !== false || canAddByPrimaryAccountIdentifier !== false) && + !secureElementPassExists) + ? "cta" + : "hidden", + google: "hidden", + googleToken: null, + }; + } + return { apple: "cta", google: "hidden", googleToken: null }; + } catch (error) { + reportError(error); + return hiddenWallet; + } + } + if (Platform.OS !== "android") return hiddenWallet; + return nextWallet.default.GooglePay.isWalletAvailable() + .then(async (available) => { + if (!available) return hiddenWallet; + const tokens = await nextWallet.default.GooglePay.checkWalletForCardSuffix(lastFour).catch( + (error: unknown) => { + if ( + safeParse( + union([ + object({ code: literal("GOOGLE_PAY_TOKEN_NOT_FOUND") }), + object({ userInfo: object({ code: literal(702) }) }), + ]), + error, + ).success + ) + return []; + reportError(error); + }, + ); + if (!tokens) return hiddenWallet; + const { GooglePayTokenState } = nextWallet; + const googleVerificationToken = + tokens.find( + ({ tokenState }) => tokenState === GooglePayTokenState.TOKEN_STATE_NEEDS_IDENTITY_VERIFICATION, + ) ?? null; + const google = tokens.some(({ tokenState }) => tokenState === GooglePayTokenState.TOKEN_STATE_ACTIVE) + ? "added" + : googleVerificationToken || + tokens.every( + ({ tokenState }) => + tokenState === GooglePayTokenState.TOKEN_STATE_NOT_FOUND || + tokenState === GooglePayTokenState.TOKEN_STATE_UNTOKENIZED, // cspell:ignore UNTOKENIZED + ) + ? "cta" + : "hidden"; + return { + apple: "hidden", + google, + googleToken: + google === "cta" && googleVerificationToken + ? { + isSelectedAsDefault: String(googleVerificationToken.isDefaultToken), + paymentNetwork: googleVerificationToken.paymentNetwork, + tokenId: googleVerificationToken.issuerTokenId, + tokenState: googleVerificationToken.tokenState, + } + : null, + } satisfies WalletEligibility; + }) + .catch((error: unknown) => { + reportError(error); + return hiddenWallet; + }); + }, + }); + + useEffect(() => { + if (Platform.OS === "web" || cardDetails?.lastFour.length !== 4) return; + const lastFour = cardDetails.lastFour; + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "active") return; + syncWalletEligibility(lastFour, address).catch(reportError); + }); + return () => { + subscription.remove(); + }; + }, [address, cardDetails?.lastFour, walletEligible]); + + useEffect(() => { + if (Platform.OS === "web" || cardDetails?.lastFour.length !== 4) return; + const lastFour = cardDetails.lastFour; + let mounted = true; + let cleanup: (() => void) | undefined; + init() + .then((nextWallet) => { + if (!mounted) return; + setSdk((current) => current ?? nextWallet); + if (Platform.OS === "ios") { + const subscription = nextWallet.default.ApplePay.registerDataChangedListener(() => { + syncWalletEligibility(lastFour, address).catch(reportError); + }); + cleanup = () => { + nextWallet.default.ApplePay.removeDataChangedListener(subscription); + }; + return; + } + const subscription = nextWallet.default.GooglePay.registerDataChangedListener(() => { + syncWalletEligibility(lastFour, address).catch(reportError); + }); + cleanup = () => nextWallet.default.GooglePay.removeDataChangedListener(subscription); + }) + .catch(reportError); + return () => { + mounted = false; + cleanup?.(); + }; + }, [address, cardDetails?.lastFour]); + + const runWalletProvisioning = async (work: () => Promise) => { + if (walletInFlightRef.current) return; + walletInFlightRef.current = true; + setProvisioning(true); + try { + if (await work()) { + Alert.alert(t("Card added"), t("Your card was added to your wallet. Follow any remaining steps if prompted.")); + } + } catch (error) { + const classification = classifyError(error); + if (!classification.walletCancelled) { + reportError(error); + Alert.alert(t("Something went wrong. Please try again.")); + } + } finally { + walletInFlightRef.current = false; + setProvisioning(false); + } + }; + + const addToAppleWallet = () => + runWalletProvisioning(async () => { + const nextWallet = sdk ?? (await init()); + const { cardId, cardSecret } = await queryClient.fetchQuery<{ + cardId: string; + cardSecret: string; + }>({ queryKey: ["card", "provisioning"] }); + const response = await nextWallet.default.ApplePay.initializeOemTokenization( + nextWallet.MppCardDataParameters.withCardSecret(cardId, cardSecret), + ); + const activationState = await nextWallet.default.ApplePay.showAddPaymentPassView(response); + await syncWalletEligibility(cardDetails?.lastFour, address); + return [ + nextWallet.MppPassActivationState.ACTIVATED, + nextWallet.MppPassActivationState.ACTIVATING, + nextWallet.MppPassActivationState.REQUIRES_ACTIVATION, + ].includes(activationState); + }); + + const addToGoogleWallet = () => + runWalletProvisioning(async () => { + const nextWallet = sdk ?? (await init()); + if (walletEligible?.googleToken) { + await nextWallet.default.GooglePay.tokenize(walletEligible.googleToken, cardDetails?.displayName ?? ""); + await syncWalletEligibility(cardDetails?.lastFour, address); + return true; + } + const { cardId, cardSecret } = await queryClient.fetchQuery<{ + cardId: string; + cardSecret: string; + }>({ queryKey: ["card", "provisioning"] }); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- required by MeaWallet legacy push provisioning + await nextWallet.default.GooglePay.pushCard( + nextWallet.MppCardDataParameters.withCardSecret(cardId, cardSecret), + cardDetails?.displayName ?? "", + {}, + ); + await syncWalletEligibility(cardDetails?.lastFour, address); + return true; + }); + + return { + AddPassButton: Platform.OS === "ios" ? sdk?.default.ApplePay.AddPassButton : undefined, + addToAppleWallet, + addToGoogleWallet, + isPendingWallet, + provisioning, + syncWalletEligibility, + walletEligible, + }; +} + +function syncWalletEligibility(lastFour: string | undefined, address: string | undefined) { + if (Platform.OS === "web" || lastFour?.length !== 4) return Promise.resolve(); + if (Platform.OS === "ios") { + const authExpires = queryClient.getQueryData(["auth"]); + if (authExpires === undefined || authExpires <= Date.now()) return Promise.resolve(); + } + return queryClient.refetchQueries({ exact: true, queryKey: ["wallet", "eligible", address, lastFour] }); +} diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 6402f63179..589805d4f0 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -47,7 +47,7 @@ import usePortfolio from "../../utils/usePortfolio"; import useTabPress from "../../utils/useTabPress"; import weeklySpend from "../../utils/weeklySpend"; import BenefitsSection from "../benefits/BenefitsSection"; -import CardDetailsSheet from "../card/CardDetails"; +import CardDetailsSheet from "../card/CardDetailsSheet"; import ManualRepaymentSheet from "../pay/ManualRepaymentSheet"; import OverduePayments from "../pay/OverduePayments"; import PaymentSheet from "../pay/PaymentSheet"; diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 6376c69b6c..8ea3497013 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -17,6 +17,7 @@ import { logout as logoutOnesignal } from "../../utils/onesignal"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; +import { clear } from "../../utils/walletExtensionStorage"; import IconButton from "../shared/IconButton"; import SafeView from "../shared/SafeView"; import Text from "../shared/Text"; @@ -71,7 +72,7 @@ export default function Settings() { { if (!connector) return; - Promise.all([queryClient.cancelQueries(), logoutIntercom()]) + Promise.all([queryClient.cancelQueries(), logoutIntercom(), clear().catch(reportError)]) .then(() => { logoutOnesignal(); queryClient.clear(); diff --git a/src/i18n/en.json b/src/i18n/en.json index 7f3f3f989a..08a90b0e64 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -5,6 +5,8 @@ "{{count}} installments_other": "{{count}} installments", "{{count}} step remaining_one": "{{count}} step remaining", "{{count}} step remaining_other": "{{count}} steps remaining", + "Add to Google Wallet": "Add to Google Wallet", + "Added to Google Wallet": "Added to Google Wallet", "Available for {{count}} installments or less_one": "Available for {{count}} installment", "Available for {{count}} installments or less_other": "Available for {{count}} installments or less", "in {{count}} payments_one": "in {{count}} payment", diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index 5c03b6f860..efeeb3a8b9 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -7,6 +7,7 @@ "Add funds to your account to start spending with the Exa Card.": "Agregá fondos a tu cuenta para empezar a gastar con la Exa Card.", "Add funds using {{network}} only. Sending assets on any other network will cause irreversible loss of funds.": "Agregá fondos usando {{network}} solamente. Enviar activos en cualquier otra red causará pérdida irreversible de fondos.", "Add ~{{amount}} {{symbol}} on {{chain}} for network fees.": "Agregá ~{{amount}} {{symbol}} en {{chain}} para comisiones de red.", + "Added to Google Wallet": "Agregada a Google Wallet", "Almost there! Activate your Exa Card to start spending your onchain assets instantly.": "¡Ya casi! Activá tu Exa Card para empezar a gastar tus activos on-chain al instante.", "An error occurred. Please try again later.": "Ocurrió un error. Intentalo de nuevo más tarde.", "Are you sure you want to disconnect from {{name}}?": "¿Seguro que querés desconectarte de {{name}}?", @@ -17,6 +18,7 @@ "By continuing, you accept both the notice below and the Terms and Conditions of the Exa Card.": "Al continuar, aceptás tanto el aviso a continuación como los Términos y Condiciones de la Exa Card.", "Camera access is currently disabled for Exa App. In order to continue, enable camera access for Exa App from your device settings.": "El acceso a la cámara está actualmente deshabilitado para Exa App. Para continuar, habilitá el acceso a la cámara para Exa App desde la configuración de tu dispositivo.", "Can't delete this contact while a withdrawal is in progress.": "No podés eliminar este contacto mientras hay un retiro en curso.", + "Card added": "Tarjeta agregada", "Change the pay mode before each purchase and pay how you want.": "Cambiá el modo de pago antes de cada compra y pagá como quieras.", "Check out our X or Discord for updates—or report the issue so we can take a closer look.": "Revisá nuestro X o Discord para actualizaciones o reportá el problema para que podamos investigarlo más a fondo.", "Choose 0% installments": "Elegí cuotas al 0%", @@ -174,6 +176,7 @@ "You've reached 90% of your weekly card spending limit.": "Alcanzaste el 90% de tu límite de gasto semanal.", "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Tus activos aún no pueden respaldar tu tarjeta. Intercambialos por un activo compatible para empezar a gastar.", "Your card is awaiting activation. Follow the steps to enable it.": "Tu tarjeta está a la espera de activación. Seguí los pasos para habilitarla.", + "Your card was added to your wallet. Follow any remaining steps if prompted.": "Tu tarjeta fue agregada a tu billetera. Seguí cualquier paso restante si se te indica.", "Your limit increase request is under review. We'll let you know once it's been processed.": "Tu solicitud de aumento de límite está en revisión. Te vamos a avisar cuando se haya procesado.", "Your password manager does not support passkey backups. Please try a different one": "Tu gestor de contraseñas no admite copias de seguridad de llaves de acceso. Por favor, probá con otro.", "Your spending limit is the maximum amount you can spend on your Exa Card.": "Tu límite de gasto es el monto máximo que podés gastar con tu Exa Card.", diff --git a/src/i18n/es.json b/src/i18n/es.json index c677bf6a4c..7457f9b898 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -67,6 +67,8 @@ "Add funds using {{network}} only. Sending assets on any other network will cause irreversible loss of funds.": "Agrega fondos usando {{network}} solamente. Enviar activos en cualquier otra red causará pérdida irreversible de fondos.", "Add Funds": "Agregar fondos", "Add funds": "Depositar", + "Add to Google Wallet": "Agregar a Google Wallet", + "Added to Google Wallet": "Agregada a Google Wallet", "Add ~{{amount}} {{symbol}} on {{chain}} for network fees.": "Agrega ~{{amount}} {{symbol}} en {{chain}} para comisiones de red.", "Add new beneficiary": "Agregar nuevo beneficiario", "Address": "Dirección", @@ -161,6 +163,7 @@ "Card **** {{lastFour}}": "Tarjeta **** {{lastFour}}", "Card **** {{lastFour}} installments": "Cuotas de la tarjeta **** {{lastFour}}", "Card activated!": "¡Tarjeta activada!", + "Card added": "Tarjeta agregada", "Card details": "Detalles de la tarjeta", "Card number copied!": "¡Número de tarjeta copiado!", "Card upgraded successfully": "Tarjeta actualizada exitosamente", @@ -873,6 +876,7 @@ "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Tus activos aún no pueden respaldar tu tarjeta. Intercámbialos por un activo compatible para empezar a gastar.", "Your card is awaiting activation. Follow the steps to enable it.": "Tu tarjeta está a la espera de activación. Sigue los pasos para habilitarla.", "Your card is still being set up. This is taking a bit longer than expected. You can keep waiting or reach out to our support team.": "Tu tarjeta todavía se está configurando. Esto está tardando un poco más de lo esperado. Puedes seguir esperando o contactar a nuestro equipo de soporte.", + "Your card was added to your wallet. Follow any remaining steps if prompted.": "Tu tarjeta fue agregada a tu billetera. Sigue cualquier paso restante si se te indica.", "Your card will be temporarily paused. You can unfreeze it anytime.": "Tu tarjeta se pausará temporalmente. Puedes descongelarla en cualquier momento.", "Your card’s PIN may be required to confirm transactions and ensure security.": "El PIN de tu tarjeta puede ser necesario para confirmar transacciones y garantizar la seguridad.", "Your Exa account": "Tu cuenta Exa", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 775f63b54d..8a01f3ead0 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -67,6 +67,8 @@ "Add funds using {{network}} only. Sending assets on any other network will cause irreversible loss of funds.": "Adicione fundos usando {{network}} apenas. Enviar ativos em qualquer outra rede causará perda irreversível de fundos.", "Add Funds": "Adicionar fundos", "Add funds": "Depositar", + "Add to Google Wallet": "Adicionar à Carteira do Google", + "Added to Google Wallet": "Adicionado à Carteira do Google", "Add ~{{amount}} {{symbol}} on {{chain}} for network fees.": "Adicione ~{{amount}} {{symbol}} em {{chain}} para taxas de rede.", "Add new beneficiary": "Adicionar novo beneficiário", "Address": "Endereço", @@ -161,6 +163,7 @@ "Card **** {{lastFour}}": "Cartão **** {{lastFour}}", "Card **** {{lastFour}} installments": "Parcelas do cartão **** {{lastFour}}", "Card activated!": "Cartão ativado!", + "Card added": "Cartão adicionado", "Card details": "Detalhes do cartão", "Card number copied!": "Número do cartão copiado!", "Card upgraded successfully": "Cartão atualizado com sucesso", @@ -873,6 +876,7 @@ "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Seus ativos ainda não podem servir de garantia para o seu cartão. Troque-os por um ativo compatível para começar a gastar.", "Your card is awaiting activation. Follow the steps to enable it.": "Seu cartão está aguardando ativação. Siga os passos para ativá-lo.", "Your card is still being set up. This is taking a bit longer than expected. You can keep waiting or reach out to our support team.": "Seu cartão ainda está sendo configurado. Isto está demorando um pouco mais que o esperado. Você pode continuar aguardando ou falar com a nossa equipe de suporte.", + "Your card was added to your wallet. Follow any remaining steps if prompted.": "Seu cartão foi adicionado à sua carteira. Siga qualquer etapa restante se for solicitado.", "Your card will be temporarily paused. You can unfreeze it anytime.": "Seu cartão será pausado temporariamente. Você pode descongelá-lo a qualquer momento.", "Your card’s PIN may be required to confirm transactions and ensure security.": "O PIN do seu cartão pode ser necessário para confirmar transações e garantir a segurança.", "Your Exa account": "Sua conta Exa", diff --git a/src/utils/meaWallet.ts b/src/utils/meaWallet.ts new file mode 100644 index 0000000000..e637b198fe --- /dev/null +++ b/src/utils/meaWallet.ts @@ -0,0 +1,19 @@ +import { Platform } from "react-native"; + +import type * as MeaWallet from "@meawallet/react-native-mpp"; + +let walletInitPromise: Promise | undefined; + +export default function init() { + if (Platform.OS === "web") return Promise.reject(new Error("wallet unavailable on web")); + walletInitPromise ??= import("@meawallet/react-native-mpp") + .then(async (wallet) => { + await wallet.default.initialize(); + return wallet; + }) + .catch((error: unknown) => { + walletInitPromise = undefined; + throw error; + }); + return walletInitPromise; +} diff --git a/src/utils/queryClient.ts b/src/utils/queryClient.ts index 277ee83235..cb82763d1b 100644 --- a/src/utils/queryClient.ts +++ b/src/utils/queryClient.ts @@ -17,7 +17,7 @@ import type { Activity } from "./server"; import type { PersistedClient } from "@tanstack/query-persist-client-core"; import type { Address } from "viem"; -const INVALIDATE_ON_UPGRADE = new Set(["kyc", "card", "deployed", "pax"]); +const INVALIDATE_ON_UPGRADE = new Set(["kyc", "card", "deployed", "pax", "wallet"]); export const isServer = typeof window === "undefined"; export function triage(error: unknown) { @@ -75,7 +75,7 @@ export const hydrated = const dehydrateOptions = { shouldDehydrateQuery: ({ queryKey, state }: Query) => state.status === "success" && - !["activity", "externalAssets", "kyc", "card", "pax", "lifi"].includes(queryKey[0] as string) && + !["activity", "externalAssets", "kyc", "card", "pax", "lifi", "wallet"].includes(queryKey[0] as string) && !(queryKey[0] === "ramp" && queryKey[1] === "kyc-tokens"), }; diff --git a/src/utils/reportError.ts b/src/utils/reportError.ts index 41bea024f4..2710e38423 100644 --- a/src/utils/reportError.ts +++ b/src/utils/reportError.ts @@ -78,6 +78,14 @@ function parseError(error: unknown) { typeof root === "object" && root !== null && "code" in root && typeof root.code === "string" && root.code.length > 0 ? root.code : undefined; + let domain = + typeof root === "object" && + root !== null && + "domain" in root && + typeof root.domain === "string" && + root.domain.length > 0 + ? root.domain + : undefined; let status: string | undefined; for ( let cause: unknown = error; @@ -105,12 +113,15 @@ function parseError(error: unknown) { root.message.length > 0 ? normalizeMessage(root.message) : undefined; + const passKit = message?.match(/\bError Domain=([^ ]+) Code=(-?\d+)\b/); + domain ??= passKit?.[1]; + status ??= passKit?.[2]; const revert = error instanceof BaseError && error.walk((r) => r instanceof ContractFunctionRevertedError) !== null; const reason = revertReason(error, { fallback: "unknown" }); - return { code, message, name, reason, revert, status }; + return { code, domain, message, name, reason, revert, status }; } -function classify({ code, message, name, reason, revert, status }: ParsedError) { +function classify({ code, domain, message, name, reason, revert, status }: ParsedError) { const passkeyNotAllowed = name === "NotAllowedError" || (message !== undefined && authPrefixes.some((prefix) => message.startsWith(prefix))); const passkeyCancelled = @@ -122,6 +133,8 @@ function classify({ code, message, name, reason, revert, status }: ParsedError) (passkeyKnownPatterns.some((pattern) => pattern.test(message)) || authPrefixes.some((prefix) => message.startsWith(prefix)))); const passkeyWarning = passkeyKnown && !passkeyCancelled && !passkeyNotAllowed; + const walletCancelled = + code === "OPERATION_CANCELLED_BY_USER" || (domain === "PKPassKitErrorDomain" && status === "1"); const biometric = code === "ERR_BIOMETRIC"; const walletRejected = status === "4001" || status === "5000"; const bundleCancelled = status === "5730"; @@ -131,10 +144,16 @@ function classify({ code, message, name, reason, revert, status }: ParsedError) biometric || walletRejected || bundleCancelled || + walletCancelled || message === "invalid operation"; const network = classifyNetwork(message); const knownWarning = - passkeyKnown || biometric || walletRejected || bundleCancelled || message === "invalid operation"; + passkeyKnown || + walletCancelled || + biometric || + walletRejected || + bundleCancelled || + message === "invalid operation"; const knownInfo = network !== undefined; const known = knownWarning || knownInfo; const value = @@ -164,6 +183,7 @@ function classify({ code, message, name, reason, revert, status }: ParsedError) passkeyNotAllowed, passkeyWarning, revert, + walletCancelled, walletRejected, }; } diff --git a/src/utils/server.ts b/src/utils/server.ts index 70bffc6323..89f81f80ab 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -16,6 +16,7 @@ import { decrypt, decryptPIN, encryptPIN, session } from "./panda"; import queryClient, { APIError, isServer, triage, type AuthMethod } from "./queryClient"; import reportError, { classifyError } from "./reportError"; import ownerConfig from "./wagmi/owner"; +import { saveToken } from "./walletExtensionStorage"; import type { ExaAPI } from "@exactly/server/api"; // eslint-disable-line @nx/enforce-module-boundaries @@ -55,7 +56,8 @@ queryClient.setQueryDefaults(["auth"], { sessionId ? { headers: { "x-session-id": sessionId } } : undefined, ); if (!post.ok) throw new APIError(post.status, stringOrLegacy(await post.json())); - const { expires, intercomToken, credentialId, factory, x, y } = await post.json(); + const { expires, intercomToken, credentialId, factory, x, y, walletExtension } = await post.json(); + if (walletExtension) await saveToken(walletExtension.token, walletExtension.expire).catch(reportError); queryClient.setQueryData(["credential"], { credentialId, factory, x, y }); await logoutIntercom(); await loginIntercom(deriveAddress(factory, { x, y }), intercomToken); @@ -101,6 +103,21 @@ async function getCard() { queryClient.setQueryDefaults(["card", "details"], { queryFn: getCard }); export type CardDetails = Awaited>; +queryClient.setQueryDefaults(["card", "provisioning"], { + gcTime: 0, + queryFn: async () => { + await auth(); + const response = await api.card.$get({ header: {}, query: { scope: "provisioning" } }); + if (!response.ok) { + const { code } = await response.json(); + throw new APIError(response.status, code); + } + const card = await parseResponse(response); + if (!card.provisioning) throw new Error("bad card provisioning response"); + return { cardId: card.provisioning.id, cardSecret: card.provisioning.secret }; + }, +}); + async function getPIN() { const result = await getCard(); if (!result) return null; @@ -255,7 +272,8 @@ export async function createCredential() { sessionId ? { headers: { "x-session-id": sessionId } } : undefined, ); if (!post.ok) throw new APIError(post.status, stringOrLegacy(await post.json())); - const { auth: expires, intercomToken, ...credential } = await post.json(); + const { auth: expires, intercomToken, walletExtension, ...credential } = await post.json(); + if (walletExtension) await saveToken(walletExtension.token, walletExtension.expire).catch(reportError); await loginIntercom(deriveAddress(credential.factory, { x: credential.x, y: credential.y }), intercomToken); await queryClient.setQueryData(["auth"], parse(Auth, expires)); return parse(Credential, credential); From 2c51d27a79d426f130e6fcb98cfc713e3bba1db0 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 20 Jul 2026 16:50:07 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20server:=20test=20provisioning?= =?UTF-8?q?=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/api/card.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 25da177843..36c4013a79 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -253,6 +253,35 @@ describe("authenticated", () => { expect(processorDetails).toHaveBeenCalledExactlyOnceWith("543c1771-beae-4f26-b662-44ea48b40dc6"); }); + it("returns panda card provisioning without encrypted card data", async () => { + const getSecrets = vi.spyOn(panda, "getSecrets").mockRejectedValue(new Error("unexpected secrets request")); + const getPIN = vi.spyOn(panda, "getPIN").mockRejectedValue(new Error("unexpected PIN request")); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-default", + timeBasedSecret: "secret-default", + }); + + const response = await appClient.index.$get( + { header: {}, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json).toMatchObject({ + cardId: "543c1771-beae-4f26-b662-44ea48b40dc6", + provisioning: { id: "proc-default", secret: "secret-default" }, + }); + expect(json).not.toHaveProperty("encryptedPan"); + expect(json).not.toHaveProperty("encryptedCvc"); + expect(json).not.toHaveProperty("pin"); + expect(getSecrets).not.toHaveBeenCalled(); + expect(getPIN).not.toHaveBeenCalled(); + expect(processorDetails).toHaveBeenCalledExactlyOnceWith("543c1771-beae-4f26-b662-44ea48b40dc6"); + }); + it("returns panda card with signature product id", async () => { vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate);