From f4e3f75a182a5f1a182272d9ab3a36c20874388f Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Fri, 20 Mar 2026 13:19:18 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20app:=20add=20card=20limit=20kyc=20f?= =?UTF-8?q?low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit co-authored-by: franm --- src/components/card/SpendingLimits.tsx | 58 +++++++++++++++++++++----- src/components/home/Home.tsx | 44 ++++++++++++++++++- src/components/shared/InfoAlert.tsx | 52 +++++++++++++++-------- src/i18n/es-AR.json | 2 + src/i18n/es.json | 2 + src/i18n/pt.json | 2 + src/utils/persona.ts | 49 ++++++++++++---------- src/utils/server.ts | 9 ++++ 8 files changed, 168 insertions(+), 50 deletions(-) diff --git a/src/components/card/SpendingLimits.tsx b/src/components/card/SpendingLimits.tsx index fadf1e2dc4..75a808544a 100644 --- a/src/components/card/SpendingLimits.tsx +++ b/src/components/card/SpendingLimits.tsx @@ -3,17 +3,24 @@ import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; import { Plus } from "@tamagui/lucide-icons"; +import { useToastController } from "@tamagui/toast"; import { ScrollView, XStack, YStack } from "tamagui"; +import { useQuery } from "@tanstack/react-query"; + import SpendingLimit from "./SpendingLimit"; import { newMessage } from "../../utils/intercom"; +import { startCardLimitKYC } from "../../utils/persona"; import reportError from "../../utils/reportError"; +import InfoAlert from "../shared/InfoAlert"; import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; import Button from "../shared/StyledButton"; import Text from "../shared/Text"; import View from "../shared/View"; +import type { KYCStatus } from "../../utils/server"; + export default function SpendingLimits({ open, onClose, @@ -26,6 +33,10 @@ export default function SpendingLimits({ totalSpent: number; }) { const { t } = useTranslation(); + const toast = useToastController(); + const { data: cardLimitStatus } = useQuery({ queryKey: ["kyc", "cardLimit"], enabled: limit != null }); + const processing = cardLimitStatus?.code === "processing"; + const alreadyApproved = cardLimitStatus?.code === "ok"; return ( @@ -44,17 +55,42 @@ export default function SpendingLimits({ - + {processing ? ( + + ) : ( + + )} diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 4e77f00e32..aff899b249 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -33,6 +33,8 @@ import PortfolioSummary from "./PortfolioSummary"; import SpendingLimitSheet from "./SpendingLimitSheet"; import VisaSignatureBanner from "./VisaSignatureBanner"; import VisaSignatureModal from "./VisaSignatureSheet"; +import { newMessage } from "../../utils/intercom"; +import { startCardLimitKYC } from "../../utils/persona"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import { cardModeMutationOptions } from "../../utils/server"; @@ -55,7 +57,7 @@ import SafeView from "../shared/SafeView"; import View from "../shared/View"; import type { ActivityItem } from "../../utils/queryClient"; -import type { CardDetails, KYCStatus } from "../../utils/server"; +import type { CardActivity, CardDetails, KYCStatus } from "../../utils/server"; import type { Credential } from "@exactly/common/validation"; const HEALTH_FACTOR_THRESHOLD = (WAD * 11n) / 10n; @@ -131,6 +133,20 @@ export default function Home() { kycStatus && "code" in kycStatus && (kycStatus.code === "ok" || kycStatus.code === "legacy kyc"), ); const { data: card } = useQuery({ queryKey: ["card", "details"], enabled: !!account && !!bytecode }); + const { data: cardActivity } = useQuery({ queryKey: ["activity", "card"] }); + const { data: cardLimitStatus } = useQuery({ queryKey: ["kyc", "cardLimit"], enabled: !!card }); + const cardLimitProcessing = cardLimitStatus?.code === "processing"; + const cardLimitApproved = cardLimitStatus?.code === "ok"; + const spendingLimitReached = useMemo(() => { + if (!card?.limit.amount || !cardActivity) return false; + const limit = card.limit.amount / 100; + const totalSpent = cardActivity.reduce((sum, item) => { + if (item.type !== "panda") return sum; + const elapsed = (Date.now() - new Date(item.timestamp).getTime()) / 1000; + return elapsed <= 604_800 ? sum + item.usdAmount : sum; + }, 0); + return totalSpent / limit >= 0.9; + }, [card?.limit.amount, cardActivity]); const { data: spotlightShown } = useQuery({ queryKey: ["settings", "installments-spotlight"] }); const toast = useToastController(); const { mutate: mutateMode } = useMutation({ @@ -207,6 +223,32 @@ export default function Home() { {markets && healthFactor(markets) < HEALTH_FACTOR_THRESHOLD && } + {spendingLimitReached && !cardLimitProcessing && ( + { + if (cardLimitApproved) newMessage(t("I want to increase my spending limit")).catch(reportError); + else + startCardLimitKYC() + .then((result) => { + if (result.status === "error") + toast.show(t("Something went wrong. Please try again."), { + native: true, + burntOptions: { haptic: "error", preset: "error" }, + }); + }) + .catch((error: unknown) => { + reportError(error); + toast.show(t("Something went wrong. Please try again."), { + native: true, + burntOptions: { haptic: "error", preset: "error" }, + }); + }); + }} + /> + )} {(showKYCMigration || showPluginOutdated) && ( void; title: string; + variant?: keyof typeof variants; }) { + const { bg, iconBg, icon: Icon, color, text } = variants[variant]; return ( - - - + + + - + {title} {actionText && ( - + {actionText} - {loading ? ( - - ) : ( - - )} + {loading ? : } )} diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index 43132353cd..f65301cb77 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -118,7 +118,9 @@ "You can repay early and save on interest. The final amount updates automatically before you confirm.": "Podés pagar antes y ahorrar en intereses. El monto final se actualiza automáticamente antes de confirmar.", "You must repay each installment manually before its due date.": "Debés pagar cada cuota manualmente antes de su fecha de vencimiento.", "You send": "Enviás", + "You've reached 90% of your weekly card spending limit.": "Alcanzaste el 90% de tu límite de gasto semanal.", "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 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.", "Your transactions will show up here once you get started. Add funds to begin!": "Tus transacciones aparecerán aquí una vez que comiences. ¡Agregá fondos para comenzar!", diff --git a/src/i18n/es.json b/src/i18n/es.json index de1b55ce5a..2da7aa80ce 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -656,10 +656,12 @@ "You’re all caught up! Start using your card in Pay Later mode to see payments listed here.": "¡Estás al día! Empieza a usar tu tarjeta en modo Pagar Después para ver los pagos aquí.", "You’re all set!": "¡Todo listo!", "You’re trying to borrow more than your collateral allows. Please enter a lower amount.": "Estás intentando pedir prestado más de lo que tu garantía permite. Por favor, introduce un monto menor.", + "You've reached 90% of your weekly card spending limit.": "Has alcanzado el 90% de tu límite de gasto semanal.", "Your {{chain}} address": "Tu dirección en {{chain}}", "Your address needs to be verified": "Tu dirección necesita ser verificada", "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’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 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 avisaremos cuando se haya procesado.", "Your Exa account": "Tu cuenta Exa", "Your Exa Card is now upgraded to Visa Signature.": "Tu Exa Card ha sido actualizada a Visa Signature.", "Your funds serve as collateral to increase your spending limits.": "Tus fondos sirven como garantía para aumentar tus límites de gasto.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e5e7fa71cd..fdc2ae37eb 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -656,10 +656,12 @@ "You’re all caught up! Start using your card in Pay Later mode to see payments listed here.": "Você está em dia! Comece a usar seu cartão no modo Pagar Depois para ver os pagamentos aqui.", "You’re all set!": "Tudo pronto!", "You’re trying to borrow more than your collateral allows. Please enter a lower amount.": "Você está tentando emprestar mais do que sua garantia permite. Por favor, insira um valor menor.", + "You've reached 90% of your weekly card spending limit.": "Você atingiu 90% do seu limite de gastos semanal.", "Your {{chain}} address": "Seu endereço na {{chain}}", "Your address needs to be verified": "Seu endereço precisa ser verificado", "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’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 limit increase request is under review. We'll let you know once it's been processed.": "Sua solicitação de aumento de limite está em análise. Avisaremos quando for processada.", "Your Exa account": "Sua conta Exa", "Your Exa Card is now upgraded to Visa Signature.": "Seu Exa Card foi atualizado para Visa Signature.", "Your funds serve as collateral to increase your spending limits.": "Seus fundos servem como garantia para aumentar seus limites de gastos.", diff --git a/src/utils/persona.ts b/src/utils/persona.ts index 5e7aad06e1..dd88e7757a 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -14,20 +14,20 @@ import type { UseMutationOptions } from "@tanstack/react-query"; export const environment = (__DEV__ || process.env.EXPO_PUBLIC_ENV === "e2e" ? "sandbox" : "production") as Environment; type KYCResult = { status: "cancel" } | { status: "complete" }; -type RampKYCResult = KYCResult | { status: "error" }; +type InquiryResult = KYCResult | { status: "error" }; let current: | undefined - | { controller: AbortController; promise: Promise; type: "basic" } | { controller: AbortController; - promise: Promise; + promise: Promise; + scope: "bridge" | "cardLimit" | "manteca"; tokens?: { inquiryId: string; sessionToken: string }; - type: "bridge" | "manteca"; - }; + } + | { controller: AbortController; promise: Promise; scope: "basic" }; export function startKYC() { - if (current && !current.controller.signal.aborted && current.type === "basic") return current.promise; + if (current && !current.controller.signal.aborted && current.scope === "basic") return current.promise; current?.controller.abort(new Error("persona inquiry aborted")); const controller = new AbortController(); @@ -115,7 +115,7 @@ export function startKYC() { if (current?.controller === controller) current = undefined; }); - current = { type: "basic", controller, promise }; + current = { scope: "basic", controller, promise }; return promise; } @@ -124,20 +124,27 @@ export function cancelKYC() { } export function startMantecaKYC(tokens?: { inquiryId: string; sessionToken: string }) { - return startRampKYC("manteca", tokens); + return startScopedInquiry("manteca", ["kyc", "manteca"], tokens); } export function startAddressKYC(tokens?: { inquiryId: string; sessionToken: string }) { - return startRampKYC("bridge", tokens); + return startScopedInquiry("bridge", ["kyc", "bridge"], tokens); +} + +export function startCardLimitKYC() { + return startScopedInquiry("cardLimit", ["kyc", "cardLimit"]); } -function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; sessionToken: string }) { - if (current && !current.controller.signal.aborted && current.type === type && current.tokens === tokens) +function startScopedInquiry( + scope: "bridge" | "cardLimit" | "manteca", + queryKey: readonly unknown[], + tokens?: { inquiryId: string; sessionToken: string }, +) { + if (current && !current.controller.signal.aborted && current.scope === scope && current.tokens === tokens) return current.promise; current?.controller.abort(new Error("persona inquiry aborted")); const controller = new AbortController(); - const invalidationKey = ["kyc", type]; const promise = (async () => { const { signal } = controller; @@ -151,11 +158,11 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; if (Platform.OS === "web") { const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([ import("persona"), - tokens ?? getKYCTokens(type, await getRedirectURI()), + tokens ?? getKYCTokens(scope, await getRedirectURI()), ]); if (signal.aborted) throw signal.reason; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const onAbort = () => { client.destroy(); reject(new Error("persona inquiry aborted", { cause: signal.reason })); @@ -169,14 +176,14 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; signal.removeEventListener("abort", onAbort); globalThis.removeEventListener("pagehide", onPageHide); client.destroy(); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey: [...queryKey] }).catch(reportError); resolve({ status: "complete" }); }, onCancel: () => { signal.removeEventListener("abort", onAbort); globalThis.removeEventListener("pagehide", onPageHide); client.destroy(); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey: [...queryKey] }).catch(reportError); resolve({ status: "cancel" }); }, onError: (error) => { @@ -191,23 +198,23 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; }); } - const { inquiryId, sessionToken } = tokens ?? (await getKYCTokens(type, await getRedirectURI())); + const { inquiryId, sessionToken } = tokens ?? (await getKYCTokens(scope, await getRedirectURI())); if (signal.aborted) throw signal.reason; const { Inquiry } = await import("react-native-persona"); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const onAbort = () => reject(new Error("persona inquiry aborted", { cause: signal.reason })); signal.addEventListener("abort", onAbort, { once: true }); Inquiry.fromInquiry(inquiryId) .sessionToken(sessionToken) .onCanceled(() => { signal.removeEventListener("abort", onAbort); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey: [...queryKey] }).catch(reportError); resolve({ status: "cancel" }); }) .onComplete(() => { signal.removeEventListener("abort", onAbort); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey: [...queryKey] }).catch(reportError); resolve({ status: "complete" }); }) .onError((error) => { @@ -222,7 +229,7 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; if (current?.controller === controller) current = undefined; }); - current = { type, controller, promise, tokens }; + current = { scope, controller, promise, tokens }; return promise; } diff --git a/src/utils/server.ts b/src/utils/server.ts index 52dc1139f7..ccf527da88 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -206,6 +206,15 @@ queryClient.setQueryDefaults(["kyc", "status"], { meta: { warnError: (error) => triage(error) === "warn" }, queryFn: () => getKYCStatus("basic", true), }); +queryClient.setQueryDefaults(["kyc", "cardLimit"], { + staleTime: 5 * 60_000, + gcTime: 60 * 60_000, + queryFn: () => + getKYCStatus("cardLimit").catch((error: unknown) => { + if (error instanceof APIError && error.code === 400) return { code: error.text }; + throw error; + }), +}); export type KYCStatus = Awaited>; export async function getCredential() {