diff --git a/server/api/card.ts b/server/api/card.ts index a31aad29..89af45c6 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -11,7 +11,7 @@ import { integer, maxValue, minValue, number, parse, picklist, pipe, strictObjec import database, { cards, credentials } from "../database"; import auth from "../middleware/auth"; import { createCard, getPAN } from "../utils/cryptomate"; -import { getInquiry } from "../utils/persona"; +import { getInquirires } from "../utils/persona"; import { track } from "../utils/segment"; const mutexes = new Map(); @@ -63,22 +63,24 @@ export default app if (!credential) return c.json("credential not found", 401); const account = parse(Address, credential.account); setUser({ id: account }); - if (!credential.kycId) return c.json("kyc required", 403); - const { data } = await getInquiry(credential.kycId); - if (data.attributes.status !== "approved") return c.json("kyc not approved", 403); + const { data: inquiries } = await getInquirires(credentialId); + console.log(inquiries); // TODO remove logs and comments + const inquiry = inquiries[0]; + if (!inquiry) return c.json("kyc not found", 404); + if (inquiry.attributes.status !== "approved") return c.json("kyc not approved", 403); if (credential.cards.length > 0) return c.json("card already exists", 400); const phone = parsePhoneNumberWithError( - data.attributes["phone-number"].startsWith("+") - ? data.attributes["phone-number"] - : `+${data.attributes["phone-number"]}`, + inquiry.attributes["phone-number"].startsWith("+") + ? inquiry.attributes["phone-number"] + : `+${inquiry.attributes["phone-number"]}`, ); const card = await createCard({ account, - email: data.attributes["email-address"], + email: inquiry.attributes["email-address"], name: { - first: data.attributes["name-first"], - middle: data.attributes["name-middle"], - last: data.attributes["name-last"], + first: inquiry.attributes["name-first"], + middle: inquiry.attributes["name-middle"], + last: inquiry.attributes["name-last"], }, phone: { countryCode: phone.countryCallingCode, number: phone.nationalNumber }, limits: { daily: 1000, weekly: 3000, monthly: 5000 }, diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 59cf9f17..befe843d 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -1,13 +1,12 @@ import { Address } from "@exactly/common/validation"; -import { vValidator } from "@hono/valibot-validator"; import { setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { object, optional, parse, string } from "valibot"; +import { parse } from "valibot"; import database, { credentials } from "../database/index"; import auth from "../middleware/auth"; -import { createInquiry, generateOTL, getInquiry } from "../utils/persona"; +import { createInquiry, generateOTL, getInquirires, resumeInquiry } from "../utils/persona"; const app = new Hono(); app.use(auth); @@ -21,55 +20,42 @@ export default app }); if (!credential) return c.json("credential not found", 404); setUser({ id: parse(Address, credential.account) }); - if (!credential.kycId) return c.json("kyc not found", 404); - const { data } = await getInquiry(credential.kycId); - if (data.attributes.status !== "approved") return c.json("kyc not approved", 403); + const { data: inquiries } = await getInquirires(credentialId); + console.log("inquiries >>", inquiries); // TODO remove logs and comments + const inquiry = inquiries[0]; + if (!inquiry) return c.json("kyc not found", 404); + console.log("first inq >>>", inquiries[0]); // TODO remove logs and comments + if (inquiry.attributes.status === "created") return c.json("kyc not started", 400); + if (inquiry.attributes.status === "pending" || inquiry.attributes.status === "expired") { + console.log("inquiry status >>>", inquiry.attributes.status); // TODO remove logs and comments + const { meta } = await resumeInquiry(inquiry.id); + console.log("meta >>", meta); // TODO remove logs and comments + const result = { inquiryId: inquiry.id, sessionToken: meta["session-token"] }; + console.log("result >>>", result); // TODO remove logs and comments + return c.json(result, 200); + } + if (inquiry.attributes.status !== "approved") return c.json("kyc not approved", 400); return c.json("ok", 200); }) - .post( - "/", - vValidator("json", object({ inquiryId: optional(string()) }), ({ success }, c) => { - if (!success) return c.json("invalid body", 400); - }), - async (c) => { - const credentialId = c.get("credentialId"); - const credential = await database.query.credentials.findFirst({ - columns: { id: true, account: true, kycId: true }, - where: eq(credentials.id, credentialId), - }); - if (!credential) return c.json("credential not found", 404); - setUser({ id: parse(Address, credential.account) }); - const { inquiryId } = c.req.valid("json"); - if (inquiryId) { - const { data } = await getInquiry(inquiryId); - if (data.attributes["reference-id"] !== credentialId) return c.json("unauthorized", 403); - if (credential.kycId !== data.id) { - await database.update(credentials).set({ kycId: data.id }).where(eq(credentials.id, credentialId)); - } - return c.json("ok", 200); - } - if (credential.kycId) { - const { data } = await getInquiry(credential.kycId); - if (data.attributes["reference-id"] !== credentialId) return c.json("unauthorized", 403); - switch (data.attributes.status) { - case "created": { - const { meta } = await generateOTL(credential.kycId); - return c.json(meta["one-time-link"]); - } - case "expired": { - const { data: inquiry } = await createInquiry(credentialId); - const { meta } = await generateOTL(inquiry.id); - return c.json(meta["one-time-link"]); - } - case "approved": - return c.json("ok", 200); - default: - return c.json("kyc not approved", 403); - } + .post("/", async (c) => { + const credentialId = c.get("credentialId"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, kycId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json("credential not found", 404); + setUser({ id: parse(Address, credential.account) }); + const { data: inquiries } = await getInquirires(credentialId); + console.log(inquiries); // TODO remove logs and comments + if (inquiries[0]) { + if (inquiries[0].attributes.status === "approved") return c.json("kyc already approved", 400); + if (inquiries[0].attributes.status === "created" || inquiries[0].attributes.status === "expired") { + const { meta } = await generateOTL(inquiries[0].id); + return c.json(meta["one-time-link"]); } - const { data } = await createInquiry(credentialId); // TODO check for existing persona inquiries - const { meta } = await generateOTL(data.id); - await database.update(credentials).set({ kycId: data.id }).where(eq(credentials.id, credentialId)); - return c.json(meta["one-time-link"]); - }, - ); + return c.json("kyc failed", 400); + } + const { data } = await createInquiry(credentialId); + const { meta } = await generateOTL(data.id); + return c.json(meta["one-time-link"]); + }); diff --git a/server/utils/persona.ts b/server/utils/persona.ts index 0e1d0d85..97710c3e 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -1,4 +1,15 @@ -import { type BaseIssue, type BaseSchema, literal, nullable, object, parse, picklist, string, variant } from "valibot"; +import { + array, + type BaseIssue, + type BaseSchema, + literal, + nullable, + object, + parse, + picklist, + string, + variant, +} from "valibot"; import appOrigin from "./appOrigin"; @@ -10,8 +21,13 @@ const authorization = `Bearer ${process.env.PERSONA_API_KEY}`; const templateId = process.env.PERSONA_TEMPLATE_ID; const baseURL = process.env.PERSONA_URL; -export function getInquiry(inquiryId: string) { - return request(GetInquiryResponse, `/inquiries/${inquiryId}`); +export function getInquirires(referenceId: string) { + return request(GetInquiriesResponse, `/inquiries?filter[reference-id]=${referenceId}`); +} + +export function resumeInquiry(inquiryId: string) { + console.log("resumeInquiry >>", inquiryId); // TODO remove logs and comments + return request(GetSessionTokenResponse, `/inquiries/${inquiryId}/resume`); } export function createInquiry(referenceId: string) { @@ -36,18 +52,41 @@ async function request>( headers: { authorization, accept: "application/json", "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); + console.log("body >>", JSON.stringify(body)); // TODO remove logs and comments + console.log("response >>", response); // TODO remove logs and comments if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); return parse(schema, await response.json()); } -const CreateInquiryResponse = object({ - data: object({ - id: string(), - type: literal("inquiry"), - attributes: object({ status: literal("created"), "reference-id": string() }), - }), +const GetInquiriesResponse = object({ + data: array( + object({ + id: string(), + type: literal("inquiry"), + attributes: variant("status", [ + object({ + status: picklist(["completed", "approved"]), + "reference-id": string(), + "name-first": string(), + "name-middle": nullable(string()), + "name-last": string(), + "email-address": string(), + "phone-number": string(), + }), + object({ + status: picklist(["created", "pending", "expired", "failed", "needs_review", "declined"]), + "reference-id": string(), + "name-first": nullable(string()), + "name-middle": nullable(string()), + "name-last": nullable(string()), + "email-address": nullable(string()), + "phone-number": nullable(string()), + }), + ]), + }), + ), }); -const GetInquiryResponse = object({ +const GetSessionTokenResponse = object({ data: object({ id: string(), type: literal("inquiry"), @@ -72,6 +111,14 @@ const GetInquiryResponse = object({ }), ]), }), + meta: object({ "session-token": string() }), +}); +const CreateInquiryResponse = object({ + data: object({ + id: string(), + type: literal("inquiry"), + attributes: object({ status: literal("created"), "reference-id": string() }), + }), }); const GenerateOTLResponse = object({ data: object({ diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index 76821777..cd8914e7 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -19,9 +19,9 @@ import { useReadUpgradeableModularAccountGetInstalledPlugins, } from "../../generated/contracts"; import handleError from "../../utils/handleError"; -import { verifyIdentity } from "../../utils/persona"; +import { createInquiry, resumeInquiry } from "../../utils/persona"; import queryClient from "../../utils/queryClient"; -import { APIError, getActivity, getCard, createCard, kycStatus } from "../../utils/server"; +import { APIError, getActivity, getCard, createCard, getKYCStatus } from "../../utils/server"; import useIntercom from "../../utils/useIntercom"; import useMarketAccount from "../../utils/useMarketAccount"; import InfoBadge from "../shared/InfoBadge"; @@ -51,7 +51,10 @@ export default function Card() { const { queryKey } = useMarketAccount(marketUSDCAddress); const { address } = useAccount(); - const { data: KYCStatus, refetch: refetchKYCStatus } = useQuery({ queryKey: ["kyc", "status"], queryFn: kycStatus }); + const { data: KYCStatus, refetch: refetchKYCStatus } = useQuery({ + queryKey: ["kyc", "status"], + queryFn: getKYCStatus, + }); const { refetch: refetchInstalledPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: address ?? zeroAddress, }); @@ -86,31 +89,39 @@ export default function Card() { } = useMutation({ mutationKey: ["card", "reveal"], mutationFn: async function handleReveal() { - if (!passkey || isRevealing) return; + if (!isRevealing) return; + if (!passkey) return; try { + // if card exists, open details const { isSuccess, data } = await refetchCard(); if (isSuccess && data.url) { setCardDetailsOpen(true); return; } - await kycStatus(); - await createCard(); - const { data: card } = await refetchCard(); - if (card?.url) setCardDetailsOpen(true); + // if card doesn't exist, check the KYC status + const result = await getKYCStatus(); + if (result === "ok") { + // if KYC is ok, create a card and open details + await createCard(); + const { data: card } = await refetchCard(); + if (card?.url) setCardDetailsOpen(true); + } else { + // if KYC is able to resume, resume existing inquiry + resumeInquiry(result.inquiryId, result.sessionToken); + } } catch (error) { + // if unknown error, report to sentry and return if (!(error instanceof APIError)) { handleError(error); return; } + // handle expected kyc errors const { code, text } = error; + // if no kyc, create a new inquiry if ((code === 403 && text === "kyc required") || (code === 404 && text === "kyc not found")) { - await verifyIdentity(passkey); - } - if (code === 404 && text === "card not found") { - await createCard(); - const { data: card } = await refetchCard(); - if (card?.url) setCardDetailsOpen(true); + createInquiry(passkey); } + // report unexpected errors handleError(error); } }, diff --git a/src/components/getting-started/GettingStarted.tsx b/src/components/getting-started/GettingStarted.tsx index e7c93687..2cc333bb 100644 --- a/src/components/getting-started/GettingStarted.tsx +++ b/src/components/getting-started/GettingStarted.tsx @@ -9,7 +9,6 @@ import { ScrollView, XStack, YStack } from "tamagui"; import Step from "./Step"; import handleError from "../../utils/handleError"; -import { verifyIdentity } from "../../utils/persona"; import queryClient from "../../utils/queryClient"; import useIntercom from "../../utils/useIntercom"; import { OnboardingContext } from "../context/OnboardingProvider"; @@ -17,6 +16,8 @@ import ActionButton from "../shared/ActionButton"; import SafeView from "../shared/SafeView"; import Text from "../shared/Text"; import View from "../shared/View"; +import { APIError, getKYCStatus } from "../../utils/server"; +import { createInquiry, resumeInquiry } from "../../utils/persona"; export default function GettingStarted() { const { steps } = useContext(OnboardingContext); @@ -124,7 +125,23 @@ function CurrentStep() { mutationKey: ["kyc"], mutationFn: async () => { if (!passkey) throw new Error("missing passkey"); - await verifyIdentity(passkey); + try { + const result = await getKYCStatus(); + if (result === "ok") return; + resumeInquiry(result.inquiryId, result.sessionToken); + } catch (error) { + if (!(error instanceof APIError)) { + handleError(error); + return; + } + const { code, text } = error; + // if no kyc, create a new inquiry + if ((code === 403 && text === "kyc required") || (code === 404 && text === "kyc not found")) { + createInquiry(passkey); + } + // report unexpected errors + handleError(error); + } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }); diff --git a/src/components/home/GettingStarted.tsx b/src/components/home/GettingStarted.tsx index c04e0fa8..8a0537e4 100644 --- a/src/components/home/GettingStarted.tsx +++ b/src/components/home/GettingStarted.tsx @@ -8,8 +8,9 @@ import { ms } from "react-native-size-matters"; import { XStack, YStack } from "tamagui"; import handleError from "../../utils/handleError"; -import { verifyIdentity } from "../../utils/persona"; +import { createInquiry, resumeInquiry } from "../../utils/persona"; import queryClient from "../../utils/queryClient"; +import { APIError, getKYCStatus } from "../../utils/server"; import { OnboardingContext } from "../context/OnboardingProvider"; import Text from "../shared/Text"; import View from "../shared/View"; @@ -21,7 +22,27 @@ export default function GettingStarted({ hasFunds, hasKYC }: { hasFunds: boolean mutationKey: ["kyc"], mutationFn: async () => { if (!passkey) throw new Error("missing passkey"); - await verifyIdentity(passkey); + try { + const result = await getKYCStatus(); + if (result === "ok") return; + resumeInquiry(result.inquiryId, result.sessionToken); + } catch (error) { + if (!(error instanceof APIError)) { + handleError(error); + return; + } + const { code, text } = error; + // if no kyc, create a new inquiry + if ( + (code === 403 && text === "kyc required") || + (code === 404 && text === "kyc not found") || + (code === 400 && text === "kyc not started") + ) { + createInquiry(passkey); + } + // report unexpected errors + handleError(error); + } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }); diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 5df7b4fa..17b66325 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -12,7 +12,7 @@ import GettingStarted from "./GettingStarted"; import HomeActions from "./HomeActions"; import { useReadPreviewerExactly } from "../../generated/contracts"; import handleError from "../../utils/handleError"; -import { getActivity, kycStatus } from "../../utils/server"; +import { getActivity, getKYCStatus } from "../../utils/server"; import AlertBadge from "../shared/AlertBadge"; import LatestActivity from "../shared/LatestActivity"; import ProfileHeader from "../shared/ProfileHeader"; @@ -38,7 +38,10 @@ export default function Home() { account: address, args: [address ?? zeroAddress], }); - const { data: KYCStatus, refetch: refetchKYCStatus } = useQuery({ queryKey: ["kyc", "status"], queryFn: kycStatus }); + const { data: KYCStatus, refetch: refetchKYCStatus } = useQuery({ + queryKey: ["kyc", "status"], + queryFn: getKYCStatus, + }); let usdBalance = 0n; if (markets) { for (const market of markets) { diff --git a/src/utils/persona.ts b/src/utils/persona.ts index d51b2aab..00facf07 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -1,34 +1,36 @@ import type { Passkey } from "@exactly/common/validation"; -import { Platform } from "react-native"; import { Environment, Inquiry } from "react-native-persona"; import handleError from "./handleError"; import queryClient from "./queryClient"; -import { kyc } from "./server"; export const templateId = "itmpl_8uim4FvD5P3kFpKHX37CW817"; export const environment = __DEV__ ? Environment.SANDBOX : Environment.PRODUCTION; -export async function verifyIdentity(passkey: Passkey) { - if (Platform.OS === "web") { - const otl = await kyc(); - window.open(otl, "_self"); - return; - } +export function createInquiry(passkey: Passkey) { Inquiry.fromTemplate(templateId) .environment(environment) .referenceId(passkey.credentialId) - .onCanceled((inquiryId) => { - if (!inquiryId) throw new Error("no inquiry id"); - kyc(inquiryId).catch(handleError); + .onCanceled(() => { queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(handleError); }) - .onComplete((inquiryId) => { - if (!inquiryId) throw new Error("no inquiry id"); - kyc(inquiryId).catch(handleError); + .onComplete(() => { queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(handleError); }) .onError(handleError) .build() .start(); } + +export function resumeInquiry(inquiryId: string, sessionToken: string) { + Inquiry.fromInquiry(inquiryId) + .sessionToken(sessionToken) + .onCanceled(() => { + queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(handleError); + }) + .onComplete(() => { + queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(handleError); + }) + .build() + .start(); +} diff --git a/src/utils/server.ts b/src/utils/server.ts index 61f2faf5..c0a4dedc 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -86,14 +86,14 @@ export async function setCardMode(mode: number) { return response.json(); } -export async function kyc(inquiryId?: string) { +export async function generateOTL() { await auth(); - const response = await client.api.kyc.$post({ json: { inquiryId } }); + const response = await client.api.kyc.$post(); if (!response.ok) throw new APIError(response.status, await response.json()); return response.json(); } -export async function kycStatus() { +export async function getKYCStatus() { await auth(); const response = await client.api.kyc.$get(); if (!response.ok) throw new APIError(response.status, await response.json());