Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dark-bars-feel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

♻️ use new kyc flow
4 changes: 3 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
"natspec",
"nfmelendez",
"nomiclabs",
"noopener",
"noreferrer",
"nystrom",
"oneline",
"onesignal",
Expand Down Expand Up @@ -192,4 +194,4 @@
"\\b(w|stat)?aBas\\w+\\b",
"\\baOpt\\w+\\b"
]
}
}
35 changes: 23 additions & 12 deletions src/components/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import SpendingLimits from "./SpendingLimits";
import VerificationFailure from "./VerificationFailure";
import { presentArticle } from "../../utils/intercom";
import openBrowser from "../../utils/openBrowser";
import { createInquiry, KYC_TEMPLATE_ID, resumeInquiry } from "../../utils/persona";
import { startKYC } from "../../utils/persona";
import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import {
Expand Down Expand Up @@ -98,13 +98,16 @@ export default function Card() {
isFetching: isFetchingKYC,
} = useQuery({
queryKey: ["kyc", "status"],
queryFn: async () => getKYCStatus(KYC_TEMPLATE_ID),
queryFn: async () => getKYCStatus(),
meta: {
suppressError: (error) =>
error instanceof APIError &&
(error.text === "kyc not found" || error.text === "kyc not started" || error.text === "kyc not approved"),
(error.text === "no kyc" || error.text === "not started" || error.text === "bad kyc"),
},
});
const isKYCApproved = Boolean(
KYCStatus && "code" in KYCStatus && (KYCStatus.code === "ok" || KYCStatus.code === "legacy kyc"),
);
const { data: bytecode } = useBytecode({ address: address ?? zeroAddress, query: { enabled: !!address } });
const { refetch: refetchInstalledPlugins, isFetching: isFetchingPlugins } =
useReadUpgradeableModularAccountGetInstalledPlugins({
Expand Down Expand Up @@ -159,39 +162,47 @@ export default function Card() {
return;
}
if (isRevealing) return;
if (!credential) return;
try {
const { data, error } = await refetchCard();
if (error && error instanceof APIError && error.code === 500) throw error;
if (data) {
queryClient.setQueryData(["card-details-open"], true);
return;
}
const result = await getKYCStatus(KYC_TEMPLATE_ID);
if (result === "ok") {
const status = await getKYCStatus();
if ("code" in status && (status.code === "ok" || status.code === "legacy kyc")) {
setDisclaimerShown(true);
return;
}
if (typeof result !== "string") await resumeInquiry(result.inquiryId, result.sessionToken);
} catch (error) {
if (!(error instanceof APIError)) {
reportError(error);
return;
}
const { text } = error;
if (text === "kyc not approved") {
if (text === "bad kyc") {
setVerificationFailureShown(true);
return;
}
if (text === "kyc required" || text === "kyc not found" || text === "kyc not started") {
await createInquiry(credential);
if (text !== "not started" && text !== "no kyc") {
reportError(error);
toast.show(t("An error occurred. Please try again later."), {
native: true,
duration: 1000,
burntOptions: { haptic: "error", preset: "error" },
});
return;
}
reportError(error);
}
try {
await startKYC();
} catch (error) {
toast.show(t("An error occurred. Please try again later."), {
native: true,
duration: 1000,
burntOptions: { haptic: "error", preset: "error" },
});
reportError(error);
}
},
});
Expand Down Expand Up @@ -291,7 +302,7 @@ export default function Card() {
</Pressable>
</View>
</XStack>
{(usdBalance === 0n || KYCStatus !== "ok") && (
{(usdBalance === 0n || !isKYCApproved) && (
<InfoAlert
title={t("Your card is awaiting activation. Follow the steps to enable it.")}
actionText={t("Get started")}
Expand Down
38 changes: 3 additions & 35 deletions src/components/getting-started/GettingStarted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,16 @@ import { useRouter } from "expo-router";
import { ArrowDownToLine, ArrowLeft, Check, IdCard } from "@tamagui/lucide-icons";
import { ScrollView, XStack, YStack } from "tamagui";

import { useMutation, useQuery } from "@tanstack/react-query";

import Step from "./Step";
import { presentArticle } from "../../utils/intercom";
import { createInquiry, KYC_TEMPLATE_ID, resumeInquiry } from "../../utils/persona";
import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import { APIError, getKYCStatus } from "../../utils/server";
import useBeginKYC from "../../utils/useBeginKYC";
import useOnboardingSteps from "../../utils/useOnboardingSteps";
import ActionButton from "../shared/ActionButton";
import SafeView from "../shared/SafeView";
import Text from "../shared/Text";
import View from "../shared/View";

import type { Credential } from "@exactly/common/validation";

export default function GettingStarted() {
const { t } = useTranslation();
const router = useRouter();
Expand Down Expand Up @@ -127,40 +121,14 @@ function CurrentStep() {
const { t } = useTranslation();
const router = useRouter();
const { currentStep, completedSteps } = useOnboardingSteps();
const { data: credential } = useQuery<Credential>({ queryKey: ["credential"] });
const { mutateAsync: startKYC } = useMutation({
mutationKey: ["kyc"],
mutationFn: async () => {
if (!credential) throw new Error("missing credential");
try {
const result = await getKYCStatus(KYC_TEMPLATE_ID);
if (result === "ok") return;
if (typeof result !== "string") {
await resumeInquiry(result.inquiryId, result.sessionToken);
}
} catch (error) {
if (!(error instanceof APIError)) {
reportError(error);
return;
}
if (error.text === "kyc required" || error.text === "kyc not found" || error.text === "kyc not started") {
await createInquiry(credential);
return;
}
reportError(error);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["kyc", "status"] });
},
});
const { mutate: beginKYC } = useBeginKYC();
function handleAction() {
switch (currentStep?.id) {
case "add-funds":
router.push("/add-funds/add-crypto");
break;
case "verify-identity":
startKYC().catch(reportError);
beginKYC();
break;
}
}
Expand Down
39 changes: 3 additions & 36 deletions src/components/home/GettingStarted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,24 @@ import { useRouter } from "expo-router";
import { ArrowRight, ChevronRight, IdCard } from "@tamagui/lucide-icons";
import { Spinner, XStack, YStack } from "tamagui";

import { useMutation, useQuery } from "@tanstack/react-query";

import { createInquiry, KYC_TEMPLATE_ID, resumeInquiry } from "../../utils/persona";
import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import { APIError, getKYCStatus } from "../../utils/server";
import useBeginKYC from "../../utils/useBeginKYC";
import useOnboardingSteps from "../../utils/useOnboardingSteps";
import Text from "../shared/Text";
import View from "../shared/View";

import type { Credential } from "@exactly/common/validation";

export default function GettingStarted({ isDeployed, hasKYC }: { hasKYC: boolean; isDeployed: boolean }) {
const router = useRouter();
const { t } = useTranslation();
const { currentStep, completedSteps, setSteps } = useOnboardingSteps();
const { data: credential } = useQuery<Credential>({ queryKey: ["credential"] });
const { mutateAsync: startKYC, isPending } = useMutation({
mutationKey: ["kyc"],
mutationFn: async () => {
if (!credential) throw new Error("missing credential");
try {
const result = await getKYCStatus(KYC_TEMPLATE_ID);
if (result === "ok") return;
if (typeof result !== "string") {
resumeInquiry(result.inquiryId, result.sessionToken).catch(reportError);
}
} catch (error) {
if (!(error instanceof APIError)) {
reportError(error);
return;
}
if (error.text === "kyc required" || error.text === "kyc not found" || error.text === "kyc not started") {
await createInquiry(credential);
return;
}
reportError(error);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["kyc", "status"] });
},
});
const { mutate: beginKYC, isPending } = useBeginKYC();
function handleStepPress() {
if (isPending) return;
switch (currentStep?.id) {
case "add-funds":
router.push("/add-funds/add-crypto");
break;
case "verify-identity":
startKYC().catch(reportError);
beginKYC();
break;
}
}
Expand Down
33 changes: 11 additions & 22 deletions src/components/home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import PortfolioSummary from "./PortfolioSummary";
import SpendingLimitsSheet from "./SpendingLimitsSheet";
import VisaSignatureBanner from "./VisaSignatureBanner";
import VisaSignatureModal from "./VisaSignatureSheet";
import { KYC_TEMPLATE_ID, LEGACY_KYC_TEMPLATE_ID } from "../../utils/persona";
import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import { APIError, getActivity, getKYCStatus, type CardDetails } from "../../utils/server";
Expand Down Expand Up @@ -100,27 +99,17 @@ export default function Home() {
refetch: refetchKYCStatus,
} = useQuery({
queryKey: ["kyc", "status"],
queryFn: async () => getKYCStatus(KYC_TEMPLATE_ID),
queryFn: async () => getKYCStatus(),
meta: {
suppressError: (error) =>
error instanceof APIError &&
(error.text === "kyc not found" || error.text === "kyc not started" || error.text === "kyc not approved"),
},
});
const {
data: legacyKYCStatus,
isFetched: isLegacyKYCFetched,
refetch: refetchLegacyKYCStatus,
} = useQuery({
queryKey: ["legacy", "kyc", "status"],
queryFn: async () => getKYCStatus(LEGACY_KYC_TEMPLATE_ID),
enabled: isKYCFetched && KYCStatus !== "ok",
meta: {
suppressError: (error) =>
error instanceof APIError &&
(error.text === "kyc not found" || error.text === "kyc not started" || error.text === "kyc not approved"),
(error.text === "no kyc" || error.text === "not started" || error.text === "bad kyc"),
},
});
const needsMigration = Boolean(KYCStatus && "code" in KYCStatus && KYCStatus.code === "legacy kyc");
const isKYCApproved = Boolean(
KYCStatus && "code" in KYCStatus && (KYCStatus.code === "ok" || KYCStatus.code === "legacy kyc"),
);
const { data: card } = useQuery<CardDetails>({ queryKey: ["card", "details"], enabled: !!account && !!bytecode });

const scrollRef = useRef<ScrollView>(null);
Expand All @@ -129,7 +118,6 @@ export default function Home() {
refetchBytecode().catch(reportError);
refetchMarkets().catch(reportError);
refetchKYCStatus().catch(reportError);
refetchLegacyKYCStatus().catch(reportError);
refetchPendingProposals().catch(reportError);
};
useTabPress("index", () => {
Expand All @@ -138,6 +126,8 @@ export default function Home() {
});

const isPending = isPendingActivity || isPendingPreviewer;
const showKycMigration = isKYCFetched && needsMigration;
const showPluginOutdated = !!bytecode && !!installedPlugins && !isLatestPlugin;
return (
<SafeView fullScreen tab backgroundColor="$backgroundSoft">
<View fullScreen backgroundColor="$backgroundMild">
Expand All @@ -153,8 +143,7 @@ export default function Home() {
<View flex={1}>
<YStack backgroundColor="$backgroundSoft" padding="$s4" gap="$s4">
{markets && healthFactor(markets) < HEALTH_FACTOR_THRESHOLD && <LiquidationAlert />}
{((isKYCFetched && isLegacyKYCFetched && legacyKYCStatus === "ok" && KYCStatus !== "ok") ||
(!!bytecode && !!installedPlugins && !isLatestPlugin)) && (
{(showKycMigration || showPluginOutdated) && (
<InfoAlert
title={t(
"We're upgrading all Exa Cards by migrating them to a new and improved card issuer. Existing cards will work until {{deadline}}, and upgrading will be required after this date.",
Expand Down Expand Up @@ -193,8 +182,8 @@ export default function Home() {
}}
/>
)}
<GettingStarted isDeployed={!!bytecode} hasKYC={KYCStatus === "ok"} />
{KYCStatus === "ok" && <BenefitsSection />}
<GettingStarted isDeployed={!!bytecode} hasKYC={isKYCApproved} />
{isKYCApproved && <BenefitsSection />}
<OverduePayments
onSelect={(maturity) => {
router.setParams({ ...parameters, maturity: String(maturity) });
Expand Down
35 changes: 13 additions & 22 deletions src/components/home/card-upgrade/VerifyIdentity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,52 +5,43 @@ import { IdCard } from "@tamagui/lucide-icons";
import { useToastController } from "@tamagui/toast";
import { Spinner, YStack } from "tamagui";

import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";

import Progression from "./Progression";
import { createInquiry, KYC_TEMPLATE_ID, resumeInquiry } from "../../../utils/persona";
import { startKYC } from "../../../utils/persona";
import queryClient from "../../../utils/queryClient";
import reportError from "../../../utils/reportError";
import { APIError, getKYCStatus } from "../../../utils/server";
import Button from "../../shared/Button";
import Text from "../../shared/Text";
import View from "../../shared/View";

import type { Credential } from "@exactly/common/validation";

export default function VerifyIdentity() {
const toast = useToastController();
const { t } = useTranslation();
const { data: credential } = useQuery<Credential>({ queryKey: ["credential"] });
const { mutateAsync: startKYC, isPending } = useMutation({
const { mutate: beginKYC, isPending } = useMutation({
mutationKey: ["kyc"],
mutationFn: async () => {
if (!credential) throw new Error("missing credential");
async mutationFn() {
try {
const result = await getKYCStatus(KYC_TEMPLATE_ID);
if (result === "ok") {
const status = await getKYCStatus();
if ("code" in status && (status.code === "ok" || status.code === "legacy kyc")) {
queryClient.setQueryData(["card-upgrade"], 1);
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (typeof result !== "string") {
await resumeInquiry(result.inquiryId, result.sessionToken);
}
} catch (error) {
if (!(error instanceof APIError)) {
reportError(error);
return;
throw error;
}
if (error.text === "kyc required" || error.text === "kyc not found" || error.text === "kyc not started") {
await createInquiry(credential);
return;
if (error.text !== "not started" && error.text !== "no kyc") {
throw error;
}
reportError(error);
}
Comment on lines 31 to 38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The reportError calls within the catch block of the mutationFn are redundant. Since the errors are re-thrown, they will be handled by the onError callback of the useMutation hook, which also calls reportError. This results in double reporting of the same error.

To fix this, you should remove the reportError calls from within the mutationFn's catch block.

      } catch (error) {
        if (!(error instanceof APIError)) {
          throw error;
        }
        if (error.text !== "not started" && error.text !== "no kyc") {
          throw error;
        }
      }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai review this finding, especially considering the latest force-pushed code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cruzdanilo The finding from gemini-code-assist has been addressed in the latest force-pushed code.

Looking at the current implementation (lines 31-38), the catch block no longer contains any reportError calls:

} catch (error) {
  if (!(error instanceof APIError)) {
    throw error;
  }
  if (error.text !== "not started" && error.text !== "no kyc") {
    throw error;
  }
}

All errors are now properly thrown and handled exclusively by the onError callback (line 48), which calls reportError(error) once. This eliminates the double reporting issue that was flagged. ✅


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:50.250Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:46.740Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Learnt from: CR
Repo: exactly/exa PR: 0
File: .cursor/rules/app.mdc:0-0
Timestamp: 2025-12-23T19:56:31.043Z
Learning: Applies to src/**/*.{ts,tsx} : Use TanStack Query mutations for any server-side effects (creating, updating, deleting data). Handle `onSuccess` and `onError` to invalidate relevant queries and provide user feedback.

await startKYC();
},
onSettled: async () => {
async onSettled() {
await queryClient.invalidateQueries({ queryKey: ["kyc", "status"] });
},
onError: (error) => {
onError(error) {
toast.show(t("Error verifying identity"), {
native: true,
duration: 1000,
Expand Down Expand Up @@ -79,7 +70,7 @@ export default function VerifyIdentity() {
<Button
disabled={isPending}
onPress={() => {
startKYC().catch(reportError);
beginKYC();
}}
flexBasis={60}
contained
Expand Down
Loading
Loading