From 3f12e45cbbafc5732d9c4b6113dcff5eaca6ee8c Mon Sep 17 00:00:00 2001 From: mainqueg Date: Tue, 13 Jan 2026 15:28:47 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20server:=20support=20manteca=20inqui?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/wide-colts-wonder.md | 5 + server/api/index.ts | 4 +- server/api/kyc.ts | 26 ++- server/api/{onramp.ts => ramp.ts} | 124 +++++------- server/hooks/manteca.ts | 32 +-- server/test/api/kyc.test.ts | 313 ++++++++++++++++++++++++++++++ server/test/utils/persona.test.ts | 50 +++++ server/utils/persona.ts | 20 +- server/utils/ramps/bridge.ts | 4 +- server/utils/ramps/manteca.ts | 254 +++++++++--------------- server/utils/ramps/shared.ts | 9 +- 11 files changed, 579 insertions(+), 262 deletions(-) create mode 100644 .changeset/wide-colts-wonder.md rename server/api/{onramp.ts => ramp.ts} (67%) diff --git a/.changeset/wide-colts-wonder.md b/.changeset/wide-colts-wonder.md new file mode 100644 index 000000000..ca35cfb19 --- /dev/null +++ b/.changeset/wide-colts-wonder.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support manteca inquiry diff --git a/server/api/index.ts b/server/api/index.ts index c4c489b5e..f631f7665 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -7,9 +7,9 @@ import authentication from "./auth/authentication"; import registration from "./auth/registration"; import card from "./card"; import kyc from "./kyc"; -import onramp from "./onramp"; import passkey from "./passkey"; import pax from "./pax"; +import ramp from "./ramp"; import appOrigin from "../utils/appOrigin"; const api = new Hono() @@ -24,7 +24,7 @@ const api = new Hono() .route("/activity", activity) .route("/card", card) .route("/kyc", kyc) - .route("/onramp", onramp) + .route("/ramp", ramp) .route("/passkey", passkey) .route("/pax", pax); diff --git a/server/api/kyc.ts b/server/api/kyc.ts index cf8c6f2a1..169a1a419 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -23,6 +23,7 @@ import { getPendingInquiryTemplate, PANDA_TEMPLATE, resumeInquiry, + scopeValidationErrors, } from "../utils/persona"; import publicClient from "../utils/publicClient"; import validatorHook from "../utils/validatorHook"; @@ -39,7 +40,7 @@ export default new Hono() object({ templateId: optional(picklist([CRYPTOMATE_TEMPLATE, PANDA_TEMPLATE])), // TODO remove this after deprecate templateId query parameter countryCode: optional(literal("true")), - scope: optional(picklist(["basic"])), + scope: optional(picklist(["basic", "manteca"])), }), validatorHook(), ), @@ -58,7 +59,6 @@ export default new Hono() setUser({ id: account }); setContext("exa", { credential }); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (scope === "basic" && credential.pandaId) { if (c.req.valid("query").countryCode) { const personaAccount = await getAccount(credentialId, scope).catch((error: unknown) => { @@ -95,7 +95,15 @@ export default new Hono() return c.json({ code: "legacy kyc", legacy: "legacy kyc" }, 200); } - const inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope); + let inquiryTemplateId: Awaited>; + try { + inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope); + } catch (error: unknown) { + if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) { + return c.json({ code: "not supported" }, 400); + } + throw error; + } if (!inquiryTemplateId) return c.json({ code: "ok", legacy: "ok" }, 200); const inquiry = await getInquiry(credentialId, inquiryTemplateId); if (!inquiry) return c.json({ code: "not started", legacy: "kyc not started" }, 400); @@ -128,7 +136,7 @@ export default new Hono() "json", object({ redirectURI: optional(string()), - scope: optional(picklist(["basic"])), + scope: optional(picklist(["basic", "manteca"])), templateId: optional(string()), // TODO remove this after deprecate templateId query parameter }), validatorHook({ debug }), @@ -146,7 +154,15 @@ export default new Hono() setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); - const inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope); + let inquiryTemplateId: Awaited>; + try { + inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope); + } catch (error: unknown) { + if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) { + return c.json({ code: "not supported" }, 400); + } + throw error; + } if (!inquiryTemplateId) { return c.json({ code: "already approved", legacy: "kyc already approved" }, 400); } diff --git a/server/api/onramp.ts b/server/api/ramp.ts similarity index 67% rename from server/api/onramp.ts rename to server/api/ramp.ts index 7ad798394..1d9281786 100644 --- a/server/api/onramp.ts +++ b/server/api/ramp.ts @@ -42,7 +42,7 @@ import { import { CryptoNetwork, type DepositDetails, type ProviderInfo, type RampProvider } from "../utils/ramps/shared"; import validatorHook from "../utils/validatorHook"; -const debug = createDebug("exa:onramp"); +const debug = createDebug("exa:ramp"); Object.assign(debug, { inspectOpts: { depth: undefined } }); const ErrorCodes = { @@ -92,37 +92,24 @@ export default new Hono() const redirectURL = c.req.valid("query").redirectURL; const [mantecaProvider, bridgeProvider] = await Promise.all([ - getMantecaProvider(credential.account, credentialId, templateId, countryCode, redirectURL).catch( - (error: unknown) => { - captureException(error, { contexts: { credential, params: { templateId, countryCode } } }); - return { status: "NOT_AVAILABLE" as const, currencies: [], cryptoCurrencies: [], pendingTasks: [] }; - }, - ), + getMantecaProvider(credential.account, countryCode).catch((error: unknown) => { + captureException(error, { contexts: { credential, params: { templateId, countryCode } } }); + return { onramp: { currencies: [], cryptoCurrencies: [] }, status: "NOT_AVAILABLE" as const }; + }), getBridgeProvider({ credentialId, - templateId, customerId: credential.bridgeId, countryCode, redirectURL, }).catch((error: unknown) => { captureException(error, { contexts: { credential, params: { templateId, countryCode } } }); - return { status: "NOT_AVAILABLE" as const, currencies: [], cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies: [], cryptoCurrencies: [] }, status: "NOT_AVAILABLE" as const }; }), ]); const providers: Record<(typeof RampProvider)[number], InferInput> = { - manteca: { - status: mantecaProvider.status, - currencies: mantecaProvider.currencies, - cryptoCurrencies: mantecaProvider.cryptoCurrencies, - pendingTasks: mantecaProvider.pendingTasks, - }, - bridge: { - status: bridgeProvider.status, - currencies: bridgeProvider.currencies, - cryptoCurrencies: bridgeProvider.cryptoCurrencies, - pendingTasks: bridgeProvider.pendingTasks, - }, + manteca: mantecaProvider, + bridge: bridgeProvider, }; return c.json({ providers }); }) @@ -196,62 +183,49 @@ export default new Hono() } } }) - .post( - "/onboarding", - auth(), - vValidator("query", object({ templateId: optional(string()) }), validatorHook({ code: "bad query" })), - vValidator("json", Onboarding, validatorHook({ code: "bad onboarding" })), - async (c) => { - const { credentialId } = c.req.valid("cookie"); - const onboarding = c.req.valid("json"); - const templateId = c.req.valid("query").templateId ?? PANDA_TEMPLATE; - if (templateId !== PANDA_TEMPLATE) { - return c.json({ code: "bad template", legacy: "invalid persona template" }, 400); - } - const credential = await database.query.credentials.findFirst({ - where: eq(credentials.id, credentialId), - columns: { account: true, bridgeId: true }, - }); - if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400); - setUser({ id: credential.account }); + .post("/", auth(), vValidator("json", Onboarding, validatorHook({ code: "bad onboarding" })), async (c) => { + const { credentialId } = c.req.valid("cookie"); + const onboarding = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, credentialId), + columns: { account: true, bridgeId: true }, + }); + if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400); + setUser({ id: credential.account }); - switch (onboarding.provider) { - case "manteca": - try { - await mantecaOnboarding(credential.account, credentialId, templateId); - } catch (error) { - captureException(error, { contexts: { credential } }); - if (error instanceof Error && Object.values(MantecaErrorCodes).includes(error.message)) { - switch (error.message) { - case MantecaErrorCodes.COUNTRY_NOT_ALLOWED: - case MantecaErrorCodes.ID_NOT_ALLOWED: - case MantecaErrorCodes.BAD_KYC_ADDITIONAL_DATA: - return c.json({ code: error.message }, 400); - } + switch (onboarding.provider) { + case "manteca": + try { + await mantecaOnboarding(credential.account, credentialId); + } catch (error) { + captureException(error, { contexts: { credential } }); + if (error instanceof Error && Object.values(MantecaErrorCodes).includes(error.message)) { + switch (error.message) { + case MantecaErrorCodes.NO_DOCUMENT: + return c.json({ code: error.message }, 400); } - throw error; } - break; - case "bridge": - try { - await bridgeOnboarding({ - credentialId, - customerId: credential.bridgeId, - templateId, - acceptedTermsId: onboarding.acceptedTermsId, - }); - } catch (error) { - captureException(error, { contexts: { credential } }); - if (error instanceof Error && Object.values(BridgeErrorCodes).includes(error.message)) { - switch (error.message) { - case BridgeErrorCodes.ALREADY_ONBOARDED: - return c.json({ code: error.message }, 400); - } + throw error; + } + break; + case "bridge": + try { + await bridgeOnboarding({ + credentialId, + customerId: credential.bridgeId, + acceptedTermsId: onboarding.acceptedTermsId, + }); + } catch (error) { + captureException(error, { contexts: { credential } }); + if (error instanceof Error && Object.values(BridgeErrorCodes).includes(error.message)) { + switch (error.message) { + case BridgeErrorCodes.ALREADY_ONBOARDED: + return c.json({ code: error.message }, 400); } - throw error; } - break; - } - return c.json({ code: "ok" }); - }, - ); + throw error; + } + break; + } + return c.json({ code: "ok" }); + }); diff --git a/server/hooks/manteca.ts b/server/hooks/manteca.ts index 8cbef35b0..dac62b2e5 100644 --- a/server/hooks/manteca.ts +++ b/server/hooks/manteca.ts @@ -136,6 +136,10 @@ const Payload = variant("event", [ event: literal("USER_ONBOARDING_UPDATE"), data: UserOnboardingUpdateData, }), + object({ + event: literal("USER_STATUS_UPDATE"), + data: unknown(), + }), object({ event: literal("WITHDRAW_STATUS_UPDATE"), data: WithdrawStatusUpdateData, @@ -156,21 +160,25 @@ export default new Hono().post( async (c) => { const payload = c.req.valid("json"); + if (payload.event === "USER_STATUS_UPDATE") { + return c.json({ code: "deprecated" }, 200); + } + if (payload.event === "SYSTEM_NOTICE") { captureEvent({ message: "MANTECA SYSTEM NOTICE", contexts: { payload } }); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } if (payload.event === "COMPLIANCE_NOTICE") { // TODO evaluate send a push notification captureEvent({ message: "MANTECA COMPLIANCE NOTICE", contexts: { payload } }); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } if (payload.event === "PAYMENT_REFUND") { // TODO retrieve the userExternalId from manteca to continue with the flow captureEvent({ message: "MANTECA PAYMENT REFUND", contexts: { payload } }); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } const user = await database.query.credentials.findFirst({ @@ -179,30 +187,30 @@ export default new Hono().post( }); if (!user) { captureException(new Error("user not found"), { contexts: { payload } }); - return c.json({ code: "user not found", status: 200 }); + return c.json({ code: "user not found" }, 200); } switch (payload.event) { case "DEPOSIT_DETECTED": await handleDepositDetected(payload.data, user.account); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); case "ORDER_STATUS_UPDATE": if (payload.data.status === "CANCELLED") { captureException(new Error("order cancelled"), { contexts: { payload } }); await convertBalanceToUsdc(payload.data.userNumberId, payload.data.against); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } if (payload.data.status === "COMPLETED") { await withdrawBalance(payload.data.userNumberId, payload.data.asset, user.account); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); case "WITHDRAW_STATUS_UPDATE": if (payload.data.status === "CANCELLED") { await withdrawBalance(payload.data.userNumberId, payload.data.asset, user.account); - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); case "USER_ONBOARDING_UPDATE": if (payload.data.user.status === "ACTIVE") { sendPushNotification({ @@ -211,9 +219,9 @@ export default new Hono().post( contents: { en: "Your fiat onramp account has been activated" }, }).catch((error: unknown) => captureException(error)); } - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); default: - return c.json({ code: "ok" }); + return c.json({ code: "ok" }, 200); } }, ); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index fe1a3dc23..4982099e6 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest" import app from "../../api/kyc"; import database, { credentials } from "../../database"; import * as persona from "../../utils/persona"; +import { scopeValidationErrors } from "../../utils/persona"; import publicClient from "../../utils/publicClient"; const appClient = testClient(app); @@ -706,6 +707,318 @@ describe("authenticated", () => { }); }); + describe("manteca scope", () => { + describe("getting kyc", () => { + it("returns ok when account has all manteca fields", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(response.status).toBe(200); + }); + + it("returns not supported when country is not allowed for manteca", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + vi.spyOn(persona, "getPendingInquiryTemplate").mockRejectedValueOnce( + new Error(scopeValidationErrors.NOT_SUPPORTED), + ); + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); + expect(response.status).toBe(400); + }); + + it("returns not started when manteca extra fields inquiry is not found", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + const getInquiry = vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + expect(getInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + await expect(response.json()).resolves.toStrictEqual({ code: "not started", legacy: "kyc not started" }); + expect(response.status).toBe(400); + }); + + it("returns not started when manteca with id class inquiry is not found", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_WITH_ID_CLASS); + const getInquiry = vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + expect(getInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_WITH_ID_CLASS); + await expect(response.json()).resolves.toStrictEqual({ code: "not started", legacy: "kyc not started" }); + expect(response.status).toBe(400); + }); + + it("returns ok and sends sentry error when manteca inquiry is approved but account not updated", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "approved" }, + }); + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledWith(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: persona.MANTECA_TEMPLATE_EXTRA_FIELDS, referenceId: "bob" } }, + }); + }); + + it("returns not started when manteca inquiry is pending", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "pending" }, + }); + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + await expect(response.json()).resolves.toStrictEqual({ code: "not started", legacy: "kyc not started" }); + expect(response.status).toBe(400); + }); + + it("returns bad kyc when manteca inquiry failed", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "failed" }, + }); + + const response = await appClient.index.$get( + { query: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + await expect(response.json()).resolves.toStrictEqual({ code: "bad kyc", legacy: "kyc not approved" }); + expect(response.status).toBe(400); + }); + }); + + describe("posting kyc", () => { + it("returns already approved when account has all manteca fields", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.index.$post( + { json: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + await expect(response.json()).resolves.toStrictEqual({ + code: "already approved", + legacy: "kyc already approved", + }); + expect(response.status).toBe(400); + }); + + it("returns otl and session token when creating manteca extra fields inquiry", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + + const otl = "https://new-manteca-url.com"; + const sessionToken = "manteca-session-token"; + + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + vi.spyOn(persona, "generateOTL").mockResolvedValueOnce({ + ...OTLTemplate, + meta: { ...OTLTemplate.meta, "one-time-link": otl }, + }); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + const createInquiry = vi.spyOn(persona, "createInquiry").mockResolvedValueOnce(inquiry); + + const response = await appClient.index.$post( + { json: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + expect(createInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_EXTRA_FIELDS, undefined); + await expect(response.json()).resolves.toStrictEqual({ + otl, + sessionToken, + legacy: otl, + inquiryId: resumeTemplate.data.id, + }); + expect(response.status).toBe(200); + }); + + it("returns otl and session token when creating manteca with id class inquiry", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + + const otl = "https://new-manteca-id-url.com"; + const sessionToken = "manteca-id-session-token"; + + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + vi.spyOn(persona, "generateOTL").mockResolvedValueOnce({ + ...OTLTemplate, + meta: { ...OTLTemplate.meta, "one-time-link": otl }, + }); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_WITH_ID_CLASS); + const createInquiry = vi.spyOn(persona, "createInquiry").mockResolvedValueOnce(inquiry); + + const response = await appClient.index.$post( + { json: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + expect(createInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_WITH_ID_CLASS, undefined); + await expect(response.json()).resolves.toStrictEqual({ + otl, + sessionToken, + legacy: otl, + inquiryId: resumeTemplate.data.id, + }); + expect(response.status).toBe(200); + }); + + it("returns otl and session token when resuming pending manteca inquiry", async () => { + const otl = "https://resume-manteca-url.com"; + const sessionToken = "resume-manteca-session-token"; + + vi.spyOn(persona, "generateOTL").mockResolvedValueOnce({ + ...OTLTemplate, + meta: { ...OTLTemplate.meta, "one-time-link": otl }, + }); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + const getInquiry = vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "pending" }, + }); + + const response = await appClient.index.$post( + { json: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + expect(getInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + await expect(response.json()).resolves.toStrictEqual({ + otl, + sessionToken, + legacy: otl, + inquiryId: resumeTemplate.data.id, + }); + expect(response.status).toBe(200); + }); + + it("returns failed when manteca inquiry failed", async () => { + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + const getInquiry = vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "failed" }, + }); + + const response = await appClient.index.$post( + { json: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + expect(getInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + await expect(response.json()).resolves.toStrictEqual({ code: "failed", legacy: "kyc failed" }); + expect(response.status).toBe(400); + }); + + it("returns already approved and sends sentry error when manteca inquiry is approved but account not updated", async () => { + const getPendingInquiryTemplate = vi + .spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(persona.MANTECA_TEMPLATE_EXTRA_FIELDS); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "approved" }, + }); + + const response = await appClient.index.$post( + { json: { scope: "manteca" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); + await expect(response.json()).resolves.toStrictEqual({ + code: "already approved", + legacy: "kyc already approved", + }); + expect(response.status).toBe(400); + expect(captureException).toHaveBeenCalledWith(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: persona.MANTECA_TEMPLATE_EXTRA_FIELDS, referenceId: "bob" } }, + }); + }); + }); + }); + describe("legacy kyc flow", () => { it("returns ok kyc approved with country code", async () => { await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, "bob")); diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index 6abba2dd8..c24ff5cd4 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -301,6 +301,56 @@ describe("evaluateAccount", () => { }); }); +describe("get document for manteca", () => { + it("returns undefined when no document is found", () => { + const result = persona.getDocumentForManteca([], "US"); + + expect(result).toBeUndefined(); + }); + + it("returns document when found and id class is allowed", () => { + const document = { + id_class: { value: "id" }, + id_number: { value: "1234567890" }, + id_issuing_country: { value: "AR" }, + id_document_id: { value: "1234567890" }, + }; + const result = persona.getDocumentForManteca([{ value: document }], "AR"); + + expect(result).toBe(document); + }); + + it("returns undefined when id class is not allowed", () => { + const document = { + id_class: { value: "dl" }, + id_number: { value: "1234567890" }, + id_issuing_country: { value: "AR" }, + id_document_id: { value: "1234567890" }, + }; + const result = persona.getDocumentForManteca([{ value: document }], "AR"); + + expect(result).toBeUndefined(); + }); + + it("returns document by id class priority when multiple documents are found", () => { + const document1 = { + id_class: { value: "id" }, + id_number: { value: "1234567890" }, + id_issuing_country: { value: "AR" }, + id_document_id: { value: "1234567890" }, + }; + const document2 = { + id_class: { value: "pp" }, + id_number: { value: "1234567890" }, + id_issuing_country: { value: "AR" }, + id_document_id: { value: "1234567890" }, + }; + const result = persona.getDocumentForManteca([{ value: document2 }, { value: document1 }], "AR"); + + expect(result).toBe(document1); + }); +}); + const emptyAccount = { data: [ { diff --git a/server/utils/persona.ts b/server/utils/persona.ts index 53fb9f572..a74de2880 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -312,7 +312,10 @@ function getUnknownAccount(referenceId: string) { return request(UnknownAccount, `/accounts?page[size]=1&filter[reference-id]=${referenceId}`); } -export async function getPendingInquiryTemplate(referenceId: string, scope: AccountScope): Promise { +export async function getPendingInquiryTemplate( + referenceId: string, + scope: AccountScope, +): Promise>> { const unknownAccount = await getUnknownAccount(referenceId); return evaluateAccount(unknownAccount, scope); } @@ -465,7 +468,7 @@ type Country = (typeof MantecaCountryCode)[number]; type Allowed = { allowedIds: readonly IdClass[] }; const allowedMantecaCountries = new Map([ ["AR", { allowedIds: ["id", "pp"] }], - ["BR", { allowedIds: ["id", "dl", "pp"] }], + ["BR", { allowedIds: ["id", "pp", "dl"] }], ...(DevelopmentChainIds.includes(chain.id as DevelopmentChain) ? ([["US", { allowedIds: ["dl"] }]] as const) : ([] as const)), @@ -475,6 +478,19 @@ export function getAllowedMantecaIds(country: string): readonly IdClass[] | unde return allowedMantecaCountries.get(country as Country)?.allowedIds; } +export function getDocumentForManteca( + documents: InferOutput["documents"]["value"], + country: string, +): InferOutput | undefined { + const allowedIds = getAllowedMantecaIds(country); + if (!allowedIds) return undefined; + for (const idClass of allowedIds) { + const document = documents.find((id) => id.value.id_class.value === idClass); + if (document) return document.value; + } + return undefined; +} + export const scopeValidationErrors = { INVALID_SCOPE_VALIDATION: "invalid scope validation", INVALID_ACCOUNT: "invalid account", diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 7953c95b5..69bacab97 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -107,18 +107,16 @@ type GetProvider = { credentialId: string; customerId?: null | string; redirectURL?: string; - templateId: string; }; export async function getProvider(_data: GetProvider): Promise> { - return await Promise.resolve({ status: "NOT_AVAILABLE", currencies: [], cryptoCurrencies: [], pendingTasks: [] }); + return await Promise.resolve({ status: "NOT_AVAILABLE", onramp: { currencies: [], cryptoCurrencies: [] } }); } type Onboarding = { acceptedTermsId: string; credentialId: string; customerId: null | string; - templateId: string; }; export async function onboarding(_data: Onboarding): Promise { diff --git a/server/utils/ramps/manteca.ts b/server/utils/ramps/manteca.ts index f02b1c391..44a2eda9e 100644 --- a/server/utils/ramps/manteca.ts +++ b/server/utils/ramps/manteca.ts @@ -2,7 +2,6 @@ import { captureException, captureMessage } from "@sentry/core"; import { array, boolean, - literal, number, object, optional, @@ -19,14 +18,7 @@ import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; -import { - getAccount, - getInquiry, - resumeOrCreateMantecaInquiryOTL, - type MantecaCountryCode as CountryCode, - type IdentificationClasses, - type Inquiry, -} from "../persona"; +import { getAccount, getDocument, getDocumentForManteca, type MantecaCountryCode as CountryCode } from "../persona"; import type * as shared from "./shared"; @@ -225,93 +217,107 @@ export async function withdrawBalance(userNumberId: string, asset: string, addre export async function getProvider( account: string, - credentialId: string, - templateId: string, countryCode?: string, - redirectURL?: string, -): Promise<{ - cryptoCurrencies: { - cryptoCurrency: (typeof shared.Cryptocurrency)[number]; - network: (typeof shared.CryptoNetwork)[number]; - }[]; - currencies: string[]; - pendingTasks: InferOutput[]; - status: "ACTIVE" | "MISSING_INFORMATION" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING"; -}> { - const allowedCountry = countryCode && allowedCountries.get(countryCode as (typeof CountryCode)[number]); - if (countryCode && !allowedCountry) { - return { status: "NOT_AVAILABLE", currencies: [], cryptoCurrencies: [], pendingTasks: [] }; - } - +): Promise> { const supportedChainId = SupportedOnRampChainId[chain.id as (typeof shared.SupportedChainId)[number]]; if (!supportedChainId) { captureMessage("manteca_not_supported_chain_id", { contexts: { chain }, level: "error" }); - return { status: "NOT_AVAILABLE", currencies: [], cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies: [], cryptoCurrencies: [] }, status: "NOT_AVAILABLE" }; } const currencies = getSupportedByCountry(countryCode); const mantecaUser = await getUser(account.replace("0x", "")); if (!mantecaUser) { - const [inquiry, personaAccount] = await Promise.all([ - getInquiry(credentialId, templateId), - getAccount(credentialId, "manteca"), - ]); - if (!inquiry || !personaAccount) throw new Error(ErrorCodes.NO_KYC); - if (inquiry.attributes.status !== "approved" && inquiry.attributes.status !== "completed") { - throw new Error(ErrorCodes.KYC_NOT_APPROVED); - } - - const country = personaAccount.attributes["country-code"]; - - try { - validateIdentification(inquiry); - } catch (error) { - if (error instanceof Error && Object.values(ErrorCodes).includes(error.message)) { - switch (error.message) { - case ErrorCodes.COUNTRY_NOT_ALLOWED: - case ErrorCodes.ID_NOT_ALLOWED: - return { status: "NOT_AVAILABLE", currencies: [], cryptoCurrencies: [], pendingTasks: [] }; - case ErrorCodes.BAD_KYC_ADDITIONAL_DATA: { - let mantecaRedirectURL: undefined | URL = undefined; - if (redirectURL) { - mantecaRedirectURL = new URL(redirectURL); - mantecaRedirectURL.searchParams.set("provider", "manteca" satisfies (typeof shared.RampProvider)[number]); - } - const inquiryTask: InferOutput = { - type: "INQUIRY", - link: await resumeOrCreateMantecaInquiryOTL(credentialId, mantecaRedirectURL?.toString()), - displayText: "We need more information to complete your KYC", - currencies: getSupportedByCountry(country), - cryptoCurrencies: [], - }; - return { status: "MISSING_INFORMATION", currencies, cryptoCurrencies: [], pendingTasks: [inquiryTask] }; - } - } - captureException(error, { contexts: { inquiry } }); - } - throw error; - } - return { status: "NOT_STARTED", currencies, cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies, cryptoCurrencies: [] }, status: "NOT_STARTED" }; } if (mantecaUser.status === "ACTIVE") { const exchange = mantecaUser.exchange; - return { status: "ACTIVE", currencies: CurrenciesByExchange[exchange], cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies: CurrenciesByExchange[exchange], cryptoCurrencies: [] }, status: "ACTIVE" }; } if (mantecaUser.status === "INACTIVE") { - return { status: "NOT_AVAILABLE", currencies: [], cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies: [], cryptoCurrencies: [] }, status: "NOT_AVAILABLE" }; } const hasPendingTasks = Object.values(mantecaUser.onboarding).some( (task) => task.required && task.status === "PENDING", ); if (hasPendingTasks) { captureException(new Error("has pending tasks"), { contexts: { mantecaUser } }); - return { status: "ONBOARDING", currencies, cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies, cryptoCurrencies: [] }, status: "ONBOARDING" }; } - return { status: "ONBOARDING", currencies, cryptoCurrencies: [], pendingTasks: [] }; + return { onramp: { currencies, cryptoCurrencies: [] }, status: "NOT_AVAILABLE" }; } -export async function mantecaOnboarding(_account: string, _credentialId: string, _templateId: string) { - await Promise.reject(new Error("not implemented")); +export async function mantecaOnboarding(account: string, credentialId: string) { + const supportedChainId = SupportedOnRampChainId[chain.id as (typeof shared.SupportedChainId)[number]]; + if (!supportedChainId) { + captureMessage("manteca_not_supported_chain_id", { contexts: { chain }, level: "error" }); + throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID); + } + + const mantecaUser = await getUser(account.replace("0x", "")); + if (mantecaUser?.status === "ACTIVE") return; + if (mantecaUser?.status === "INACTIVE") throw new Error(ErrorCodes.MANTECA_USER_INACTIVE); + const personaAccount = await getAccount(credentialId, "manteca"); + if (!personaAccount) throw new Error(ErrorCodes.NO_PERSONA_ACCOUNT); + const countryCode = personaAccount.attributes["country-code"]; + + if (!mantecaUser) { + await initiateOnboarding({ + email: personaAccount.attributes["email-address"], + legalId: personaAccount.attributes.fields.tin.value, + externalId: account.replace("0x", ""), + type: "INDIVIDUAL", + exchange: getExchange(countryCode), + personalData: { + birthDate: personaAccount.attributes.fields.birthdate.value, + nationality: getNationality(countryCode), + phoneNumber: personaAccount.attributes.fields.phone_number.value, + surname: personaAccount.attributes.fields.name.value.last.value, + name: personaAccount.attributes.fields.name.value.first.value, + maritalStatus: "Soltero", // cspell:ignore soltero + sex: + personaAccount.attributes.fields.sex_1.value === "Male" + ? "M" + : personaAccount.attributes.fields.sex_1.value === "Female" + ? "F" + : "X", + isFacta: !personaAccount.attributes.fields.isnotfacta.value, // cspell:ignore isnotfacta + isPep: false, + isFep: false, + work: personaAccount.attributes.fields.economic_activity.value, + }, + }); + } + + const identityDocument = getDocumentForManteca(personaAccount.attributes.fields.documents.value, countryCode); + if (!identityDocument) { + captureException(new Error("no identity document"), { contexts: { personaAccount } }); + throw new Error(ErrorCodes.NO_DOCUMENT); + } + + const document = await getDocument(identityDocument.id_document_id.value); + const frontDocumentURL = document.attributes["front-photo"]?.url; + const backDocumentURL = document.attributes["back-photo"]?.url; + + const results = await Promise.allSettled([ + uploadIdentityFile( + account.replace("0x", ""), + "FRONT", + document.attributes["front-photo"]?.filename ?? "front-photo.jpg", + frontDocumentURL, + ), + uploadIdentityFile( + account.replace("0x", ""), + "BACK", + document.attributes["back-photo"]?.filename ?? "back-photo.jpg", + backDocumentURL, + ), + acceptTermsAndConditions(account.replace("0x", "")), + ]); + + for (const result of results) { + result.status === "rejected" && captureException(result.reason, { extra: { account } }); + } } // #endregion services @@ -326,13 +332,6 @@ const SupportedOnRampChainId: Record<(typeof shared.SupportedChainId)[number], ( [optimismSepolia.id]: "OPTIMISM", } as const; -export const MantecaOnboarding = object({ - gender: picklist(["Male", "Female", "Prefer not to say"]), - isnotfacta: literal(true), // cspell:ignore isnotfacta - tin: string(), - termsAccepted: boolean(), -}); - export const WithdrawStatus = ["PENDING", "EXECUTED", "CANCELLED"] as const; export const Withdraw = object({ userAnyId: string(), @@ -500,7 +499,7 @@ export const BalancesResponse = object({ updatedAt: string(), }); -const onboardingTaskStatus = ["PENDING", "COMPLETED"] as const; +const onboardingTaskStatus = ["PENDING", "COMPLETED", "IN_PROGRESS"] as const; const OnboardingTaskInfo = optional( object({ required: boolean(), @@ -595,25 +594,6 @@ export const CurrenciesByExchange: Record<(typeof Exchange)[number], (typeof Man BOLIVIA: ["BOB"], }; -export const allowedCountries = new Map< - (typeof CountryCode)[number], - { allowedIds: (typeof IdentificationClasses)[number][] } ->([ - ["AR", { allowedIds: ["id", "pp"] }], - ["BR", { allowedIds: ["id", "dl", "pp"] }], - // ["CL", { allowedIds: [] }], - // ["CO", { allowedIds: ["id", "dl", "pp"] }], - // ["PA", { allowedIds: [] }], - // ["CR", { allowedIds: [] }], - // ["GT", { allowedIds: [] }], - // ["MX", { allowedIds: [] }], - // ["PH", { allowedIds: [] }], - // ["BO", { allowedIds: [] }], - - // TODO for testing, remove - ["US", { allowedIds: ["dl"] }], -]); - export const NewUserResponse = object({ user: object({ id: string(), @@ -627,47 +607,6 @@ export const NewUserResponse = object({ creationTime: string(), updatedAt: string(), }), - person: optional( - object({ - legalId: optional(string()), - email: optional(string()), - flags: object({ - isDead: boolean(), - isPEP: boolean(), - isFACTA: boolean(), - isFEP: boolean(), - }), - personalData: optional( - object({ - name: optional(string()), - surname: optional(string()), - sex: optional(string()), - work: optional(string()), - birthDate: optional(string()), - phoneNumber: optional(string()), - nationality: optional(string()), - maritalStatus: optional(string()), - cleanName: optional(string()), - address: optional( - object({ - postalCode: optional(string()), - locality: optional(string()), - province: optional(string()), - street: optional(string()), - floor: optional(string()), - numeration: optional(string()), - }), - ), - document: optional( - object({ - type: optional(string()), - id: optional(string()), - }), - ), - }), - ), - }), - ), }); export const UserStatus = ["ONBOARDING", "ACTIVE", "INACTIVE"] as const; @@ -753,10 +692,6 @@ async function request>( return parse(schema, JSON.parse(new TextDecoder().decode(rawBody))); } -export function validateIdentification(_inquiry: InferOutput) { - throw new Error("not implemented"); -} - function getSupportedByCountry(countryCode?: string): (typeof MantecaCurrency)[number][] { if (!countryCode) return []; const exchange = ExchangeByCountry[countryCode as (typeof CountryCode)[number]]; @@ -765,6 +700,19 @@ function getSupportedByCountry(countryCode?: string): (typeof MantecaCurrency)[n return CurrenciesByExchange[exchange]; } +const getExchange = (countryCode: string): (typeof Exchange)[number] => { + const exchange = ExchangeByCountry[countryCode as (typeof CountryCode)[number]]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!exchange) throw new Error(`Invalid country: ${countryCode}`); + return exchange; +}; + +const getNationality = (countryCode: string): string => { + const nationality = Nationality[countryCode as (typeof CountryCode)[number]]; + if (!nationality) throw new Error(`Invalid country: ${countryCode}`); + return nationality; +}; + async function forwardFileToURL(sourceURL: string, destinationURL: string): Promise { const abort = new AbortController(); const timeout = setTimeout(() => { @@ -813,24 +761,12 @@ async function safeText(response: Response): Promise { // #endregion utils export const ErrorCodes = { - MULTIPLE_IDENTIFICATION_NUMBERS: "multiple identification numbers", - NO_IDENTIFICATION_NUMBER: "no identification number", - NO_IDENTIFICATION_CLASS: "no identification class", - BAD_KYC_ADDITIONAL_DATA: "bad kyc additional data", - NOT_SUPPORTED_CURRENCY: "not supported currency", NOT_SUPPORTED_CHAIN_ID: "not supported chain id", + NOT_SUPPORTED_CURRENCY: "not supported currency", MANTECA_USER_INACTIVE: "manteca user inactive", - COUNTRY_NOT_ALLOWED: "country not allowed", - MULTIPLE_DOCUMENTS: "multiple documents", - NO_PERSONA_ACCOUNT: "no persona account", INVALID_ORDER_SIZE: "invalid order size", - KYC_NOT_APPROVED: "kyc not approved", - BAD_MANTECA_KYC: "bad manteca kyc", - ID_NOT_ALLOWED: "id not allowed", - NO_NON_FACTA: "no non facta", + NO_PERSONA_ACCOUNT: "no persona account", NO_DOCUMENT: "no document", - NO_GENDER: "no gender", - NO_KYC: "no kyc", }; const MantecaApiErrorCodes = { diff --git a/server/utils/ramps/shared.ts b/server/utils/ramps/shared.ts index 092d1d4dc..8f7a19a9b 100644 --- a/server/utils/ramps/shared.ts +++ b/server/utils/ramps/shared.ts @@ -24,7 +24,7 @@ export const CryptoNetwork = ["TRON", "SOLANA", "STELLAR"] as const; export type OnRampNetworkType = (typeof CryptoNetwork)[number] | (typeof FiatNetwork)[number]; -export const ProviderStatus = ["NOT_STARTED", "ACTIVE", "ONBOARDING", "NOT_AVAILABLE", "MISSING_INFORMATION"] as const; +export const ProviderStatus = ["NOT_STARTED", "ACTIVE", "ONBOARDING", "NOT_AVAILABLE"] as const; export const DepositDetails = variant("network", [ object({ @@ -130,8 +130,9 @@ export const PendingTask = variant("type", [ ]); export const ProviderInfo = object({ + onramp: object({ + currencies: array(string()), + cryptoCurrencies: array(object({ cryptoCurrency: picklist(Cryptocurrency), network: picklist(CryptoNetwork) })), + }), status: picklist(ProviderStatus), - currencies: array(string()), - cryptoCurrencies: array(object({ cryptoCurrency: picklist(Cryptocurrency), network: picklist(CryptoNetwork) })), - pendingTasks: optional(array(PendingTask)), });