Skip to content

Commit

Permalink
🚧
Browse files Browse the repository at this point in the history
  • Loading branch information
dieguezguille committed Nov 6, 2024
1 parent 24ef6a1 commit 92d6dc6
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 126 deletions.
24 changes: 13 additions & 11 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Mutex>();
Expand Down Expand Up @@ -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 },
Expand Down
90 changes: 38 additions & 52 deletions server/api/kyc.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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"]);
});
98 changes: 72 additions & 26 deletions server/utils/persona.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(ResumeInquiryResponse, `/inquiries/${inquiryId}/resume`, undefined, "POST");
}

export function createInquiry(referenceId: string) {
Expand All @@ -36,41 +52,71 @@ async function request<TInput, TOutput, TIssue extends BaseIssue<unknown>>(
headers: { authorization, accept: "application/json", "content-type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
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({
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 ResumeInquiryResponse = object({
data: object({
id: string(),
type: literal("inquiry"),
attributes: object({ status: literal("created"), "reference-id": string() }),
attributes: object({
status: picklist([
"created",
"pending",
"expired",
"failed",
"needs_review",
"declined",
"completed",
"approved",
]),
"reference-id": string(),
fields: object({
"name-first": object({ type: literal("string"), value: nullable(string()) }),
"name-middle": object({ type: literal("string"), value: nullable(string()) }),
"name-last": object({ type: literal("string"), value: nullable(string()) }),
"email-address": object({ type: literal("string"), value: nullable(string()) }),
"phone-number": object({ type: literal("string"), value: nullable(string()) }),
}),
}),
}),
meta: object({ "session-token": string() }),
});
const GetInquiryResponse = object({
const CreateInquiryResponse = object({
data: 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()),
}),
]),
attributes: object({ status: literal("created"), "reference-id": string() }),
}),
});
const GenerateOTLResponse = object({
Expand Down
39 changes: 25 additions & 14 deletions src/components/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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);
}
},
Expand Down
21 changes: 19 additions & 2 deletions src/components/getting-started/GettingStarted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ 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";
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);
Expand Down Expand Up @@ -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"] });
Expand Down
Loading

0 comments on commit 92d6dc6

Please sign in to comment.