diff --git a/.changeset/clear-cobras-sip.md b/.changeset/clear-cobras-sip.md new file mode 100644 index 000000000..20a0a13c6 --- /dev/null +++ b/.changeset/clear-cobras-sip.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add card limit case update diff --git a/.changeset/full-meteors-warn.md b/.changeset/full-meteors-warn.md new file mode 100644 index 000000000..7302d5ec5 --- /dev/null +++ b/.changeset/full-meteors-warn.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ return processing for card-limit review states in kyc-api diff --git a/.changeset/huge-maps-sleep.md b/.changeset/huge-maps-sleep.md new file mode 100644 index 000000000..89d745502 --- /dev/null +++ b/.changeset/huge-maps-sleep.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add card limit support to persona hook diff --git a/.changeset/thick-facts-happen.md b/.changeset/thick-facts-happen.md new file mode 100644 index 000000000..d60cd7e4f --- /dev/null +++ b/.changeset/thick-facts-happen.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add card limit inquiry flow to kyc api diff --git a/server/api/card.ts b/server/api/card.ts index b4ba346c6..be1d4d95a 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -345,7 +345,21 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str } if (cardCount > 0) return c.json({ code: "already created" }, 400); try { - const card = await createCard(credential.pandaId, SIGNATURE_PRODUCT_ID); + const card = await createCard( + credential.pandaId, + SIGNATURE_PRODUCT_ID, + await getAccount(credentialId, "cardLimit") + .then((personaAccount) => { + const value = personaAccount?.attributes.fields.card_limit_usd?.value; + return value == null ? undefined : value * 100; + }) + .catch((error: unknown): undefined => { + captureException(error, { + level: "error", + contexts: { details: { credentialId, scope: "cardLimit" } }, + }); + }), + ); let mode = 0; try { if (await autoCredit(account)) mode = 1; diff --git a/server/api/kyc.ts b/server/api/kyc.ts index f864858aa..ed399b10f 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -18,12 +18,16 @@ import database, { credentials } from "../database/index"; import auth from "../middleware/auth"; import decodePublicKey from "../utils/decodePublicKey"; import { + CARD_LIMIT_TEMPLATE, createInquiry, CRYPTOMATE_TEMPLATE, getAccount, + getCardLimitStatus, getInquiry, getPendingInquiryTemplate, + getUnknownAccount, PANDA_TEMPLATE, + parseAccount, resumeInquiry, scopeValidationErrors, } from "../utils/persona"; @@ -41,7 +45,7 @@ export default new Hono() "query", object({ countryCode: optional(literal("true")), - scope: optional(picklist(["basic", "bridge", "manteca"])), + scope: optional(picklist(["basic", "bridge", "cardLimit", "manteca"])), }), validatorHook(), ), @@ -59,6 +63,45 @@ export default new Hono() setUser({ id: account }); setContext("exa", { credential }); + if (scope === "cardLimit") { + const unknownAccount = c.req.valid("query").countryCode + ? await getUnknownAccount(credentialId).catch((error: unknown): undefined => { + captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } }); + }) + : undefined; + if (unknownAccount) { + const countryCode = parseAccount(unknownAccount, "basic")?.attributes["country-code"]; + countryCode && c.header("User-Country", countryCode); + } + const cardLimit = await getCardLimitStatus(credentialId, unknownAccount); + + switch (cardLimit.status) { + case "resolved": + return c.json({ code: "ok" }, 200); + case "approved": + captureException(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: CARD_LIMIT_TEMPLATE, referenceId: credentialId } }, + }); + return c.json({ code: "ok" }, 200); + case "noTemplate": + return c.json({ code: "no kyc" }, 400); + case "noInquiry": + case "created": + case "pending": + case "expired": + return c.json({ code: "not started" }, 400); + case "completed": + case "needs_review": + return c.json({ code: "processing" }, 400); + case "failed": + case "declined": + return c.json({ code: "bad kyc" }, 400); + default: + throw new Error("unknown inquiry status"); + } + } + if (scope === "basic" && credential.pandaId) { if (c.req.valid("query").countryCode) { const personaAccount = await getAccount(credentialId, scope).catch((error: unknown) => { @@ -108,12 +151,12 @@ export default new Hono() return c.json({ code: "not started", legacy: "kyc not started" }, 400); case "completed": case "needs_review": - return c.json({ code: "bad kyc", legacy: "kyc not approved" }, 400); // TODO send a different response for this transitory statuses + return c.json({ code: "processing", legacy: "kyc not approved" }, 400); case "failed": case "declined": return c.json({ code: "bad kyc", legacy: "kyc not approved" }, 400); default: - throw new Error("Unknown inquiry status"); + throw new Error("unknown inquiry status"); } }, ) @@ -124,7 +167,7 @@ export default new Hono() "json", object({ redirectURI: optional(string()), - scope: optional(picklist(["basic", "bridge", "manteca"])), + scope: optional(picklist(["basic", "bridge", "cardLimit", "manteca"])), }), validatorHook({ debug }), ), @@ -141,6 +184,51 @@ export default new Hono() setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); + if (scope === "cardLimit") { + const cardLimit = await getCardLimitStatus(credentialId); + switch (cardLimit.status) { + case "resolved": + return c.json({ code: "already approved" }, 400); + case "approved": + captureException(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: CARD_LIMIT_TEMPLATE, referenceId: credentialId } }, + }); + return c.json({ code: "already approved" }, 400); + case "noTemplate": + return c.json({ code: "not started" }, 400); + case "noInquiry": { + const basicAccount = await getAccount(credentialId, "basic").catch((error: unknown) => { + captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } }); + }); + const { data } = await createInquiry( + credentialId, + CARD_LIMIT_TEMPLATE, + redirectURI, + basicAccount + ? { + "name-first": basicAccount.attributes["name-first"], + "name-last": basicAccount.attributes["name-last"], + } + : undefined, + ); + return c.json(await generateInquiryTokens(data.id), 200); + } + case "completed": + case "needs_review": + return c.json({ code: "processing" }, 400); + case "pending": + case "created": + case "expired": + return c.json(await generateInquiryTokens(cardLimit.id), 200); + case "failed": + case "declined": + return c.json({ code: "failed" }, 400); + default: + throw new Error("unknown inquiry status"); + } + } + let inquiryTemplateId: Awaited>; try { inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope); @@ -157,8 +245,7 @@ export default new Hono() const inquiry = await getInquiry(credentialId, inquiryTemplateId); if (!inquiry) { const { data } = await createInquiry(credentialId, inquiryTemplateId, redirectURI); - const { inquiryId, sessionToken } = await generateInquiryTokens(data.id); - return c.json({ inquiryId, sessionToken }, 200); + return c.json(await generateInquiryTokens(data.id), 200); } switch (inquiry.attributes.status) { @@ -173,15 +260,13 @@ export default new Hono() return c.json({ code: "failed", legacy: "kyc failed" }, 400); case "completed": case "needs_review": - return c.json({ code: "failed", legacy: "kyc failed" }, 400); // TODO send a different response + return c.json({ code: "processing", legacy: "kyc failed" }, 400); case "pending": case "created": - case "expired": { - const { inquiryId, sessionToken } = await generateInquiryTokens(inquiry.id); - return c.json({ inquiryId, sessionToken }, 200); - } + case "expired": + return c.json(await generateInquiryTokens(inquiry.id), 200); default: - throw new Error("Unknown inquiry status"); + throw new Error("unknown inquiry status"); } }, ); diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 1252d71fa..b979deead 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -1,16 +1,19 @@ import { vValidator } from "@hono/valibot-validator"; import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, setContext, setUser } from "@sentry/node"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import { array, check, + integer, ip, isoTimestamp, literal, looseObject, minLength, + minValue, nullable, + number, object, optional, picklist, @@ -23,17 +26,21 @@ import { import { Address } from "@exactly/common/validation"; -import database, { credentials } from "../database/index"; -import { createUser } from "../utils/panda"; +import database, { cards, credentials } from "../database/index"; +import { createUser, updateCard } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { addDocument, ADDRESS_TEMPLATE, + CARD_LIMIT_CASE_TEMPLATE, + CARD_LIMIT_TEMPLATE, CRYPTOMATE_TEMPLATE, + getInquiryById, headerValidator, MANTECA_TEMPLATE_EXTRA_FIELDS, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE, + updateCardLimit, } from "../utils/persona"; import { customer } from "../utils/sardine"; import validatorHook from "../utils/validatorHook"; @@ -184,6 +191,29 @@ export default new Hono().post( }), transform((payload) => ({ template: "manteca" as const, ...payload })), ), + pipe( + object({ + data: object({ + type: literal("case"), + id: string(), + attributes: object({ + status: picklist(["Approved", "Declined", "Open", "Pending"]), + fields: looseObject({ + cardLimitUsd: optional( + object({ type: literal("integer"), value: nullable(pipe(number(), integer(), minValue(1))) }), + ), + }), + }), + relationships: object({ + caseTemplate: object({ data: object({ id: literal(CARD_LIMIT_CASE_TEMPLATE) }) }), + inquiries: object({ + data: array(object({ type: literal("inquiry"), id: string() })), + }), + }), + }), + }), + transform((payload) => ({ template: "cardLimit" as const, ...payload })), + ), pipe( object({ data: object({ @@ -192,7 +222,12 @@ export default new Hono().post( relationships: object({ inquiryTemplate: object({ data: object({ - id: picklist([ADDRESS_TEMPLATE, CRYPTOMATE_TEMPLATE, MANTECA_TEMPLATE_EXTRA_FIELDS]), + id: picklist([ + ADDRESS_TEMPLATE, + CARD_LIMIT_TEMPLATE, + CRYPTOMATE_TEMPLATE, + MANTECA_TEMPLATE_EXTRA_FIELDS, + ]), }), }), }), @@ -210,7 +245,58 @@ export default new Hono().post( const payload = c.req.valid("json").data.attributes.payload; if (payload.template === "ignored") return c.json({ code: "ok" }, 200); - + if (payload.template === "cardLimit") { + getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, "persona.case.card-limit"); + if (payload.data.attributes.status !== "Approved") return c.json({ code: "ok" }, 200); + const limitUsd = payload.data.attributes.fields.cardLimitUsd?.value; + if (limitUsd == null) return c.json({ code: "no limit" }, 200); + const inquiryId = payload.data.relationships.inquiries.data[0]?.id; + if (!inquiryId) return c.json({ code: "no inquiry" }, 200); + const referenceId = await getInquiryById(inquiryId).then((r) => r.data.attributes["reference-id"]); + const credential = await database.query.credentials.findFirst({ + columns: { pandaId: true }, + where: eq(credentials.id, referenceId), + with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]), limit: 1 } }, + }); + if (!credential) { + captureException(new Error("no credential"), { level: "error", contexts: { credential: { referenceId } } }); + return c.json({ code: "ok" }, 200); + } + await updateCardLimit(referenceId, limitUsd).catch((error: unknown) => { + captureException(error, { + level: "error", + contexts: { + cardLimitDrift: { + referenceId, + limitUsd, + pandaId: credential.pandaId ?? null, + cardId: credential.cards[0]?.id ?? null, + }, + }, + }); + throw error; + }); + if (credential.pandaId && credential.cards[0]) { + await updateCard({ + id: credential.cards[0].id, + limit: { amount: limitUsd * 100, frequency: "per7DayPeriod" }, + }).catch((error: unknown) => { + captureException(error, { + level: "error", + contexts: { + cardLimitDrift: { + referenceId, + limitUsd, + pandaId: credential.pandaId, + cardId: credential.cards[0]?.id ?? null, + }, + }, + }); + throw error; + }); + } + return c.json({ code: "ok" }, 200); + } if (payload.template === "manteca") { getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, "persona.inquiry.manteca"); await addDocument(payload.data.attributes.referenceId, { diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c24490..2849646fa 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -11,7 +11,7 @@ import { testClient } from "hono/testing"; import { parse } from "valibot"; import { hexToBigInt, padHex, parseEther, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; -import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; import { exaAccountFactoryAbi, exaPluginAbi } from "@exactly/common/generated/chain"; @@ -93,6 +93,9 @@ describe("authenticated", () => { }); afterEach(() => vi.resetAllMocks()); + beforeEach(() => { + vi.spyOn(persona, "getAccount").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + }); it("returns 404 card not found", async () => { const response = await appClient.index.$get( @@ -517,6 +520,7 @@ describe("authenticated", () => { it("creates a panda credit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCreditCard", last4: "1224" }); + const ethCredential = await database.query.credentials.findFirst({ columns: { account: true }, where: eq(credentials.id, "eth"), @@ -616,7 +620,7 @@ describe("authenticated", () => { }, }; - vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(undefined).mockResolvedValueOnce(mockAccount); // eslint-disable-line unicorn/no-useless-undefined vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" }); @@ -734,7 +738,7 @@ describe("authenticated", () => { }, }; - vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(undefined).mockResolvedValueOnce(mockAccount); // eslint-disable-line unicorn/no-useless-undefined vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" }); @@ -797,6 +801,87 @@ describe("authenticated", () => { expect(card?.status).toBe("DELETED"); }); + describe("card limit sync", () => { + it("passes persona card limit to createCard", async () => { + const credentialId = "limit-sync-test"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xaaa1", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "limit-sync-panda", + }); + + const createCardSpy = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "limit-sync-card", last4: "1111" }); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce({ + id: "acc_limit", + type: "account", + attributes: { fields: { card_limit_usd: { value: 20_000 } } }, + }); + + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(createCardSpy).toHaveBeenCalledWith("limit-sync-panda", SIGNATURE_PRODUCT_ID, 2_000_000); + }); + + it("uses default limit when persona account has no card limit", async () => { + const credentialId = "limit-null-test"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xaaa2", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "limit-null-panda", + }); + + const createCardSpy = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "limit-null-card", last4: "2222" }); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce({ + id: "acc_no_limit", + type: "account", + attributes: { fields: { card_limit_usd: { value: null } } }, + }); + + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(createCardSpy).toHaveBeenCalledWith("limit-null-panda", SIGNATURE_PRODUCT_ID, undefined); + }); + + it("falls back to default limit and captures when getAccount fails", async () => { + const credentialId = "limit-fail-test"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xaaa3", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "limit-fail-panda", + }); + + const createCardSpy = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "limit-fail-card", last4: "3333" }); + const error = new Error("persona api error"); + vi.spyOn(persona, "getAccount").mockRejectedValueOnce(error); + + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(createCardSpy).toHaveBeenCalledWith("limit-fail-panda", SIGNATURE_PRODUCT_ID, undefined); + expect(captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + level: "error", + contexts: { details: { credentialId, scope: "cardLimit" } }, + }), + ); + }); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 0855ea299..c5190b993 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -179,7 +179,7 @@ describe("authenticated", () => { expect(response.status).toBe(400); }); - it("returns bad kyc when inquiry is completed", async () => { + it("returns processing when inquiry is completed", async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); const getPendingInquiryTemplate = vi .spyOn(persona, "getPendingInquiryTemplate") @@ -196,11 +196,11 @@ describe("authenticated", () => { expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "basic"); expect(getInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE); - await expect(response.json()).resolves.toStrictEqual({ code: "bad kyc", legacy: "kyc not approved" }); + await expect(response.json()).resolves.toStrictEqual({ code: "processing", legacy: "kyc not approved" }); expect(response.status).toBe(400); }); - it("returns bad kyc when inquiry needs review", async () => { + it("returns processing when inquiry needs review", async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); const getPendingInquiryTemplate = vi .spyOn(persona, "getPendingInquiryTemplate") @@ -217,7 +217,7 @@ describe("authenticated", () => { expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "basic"); expect(getInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE); - await expect(response.json()).resolves.toStrictEqual({ code: "bad kyc", legacy: "kyc not approved" }); + await expect(response.json()).resolves.toStrictEqual({ code: "processing", legacy: "kyc not approved" }); expect(response.status).toBe(400); }); @@ -459,7 +459,7 @@ describe("authenticated", () => { expect(response.status).toBe(400); }); - it("returns failed kyc when inquiry is completed", async () => { + it("returns processing when inquiry is completed", async () => { const getPendingInquiryTemplate = vi .spyOn(persona, "getPendingInquiryTemplate") .mockResolvedValueOnce(persona.PANDA_TEMPLATE); @@ -475,11 +475,11 @@ describe("authenticated", () => { expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "basic"); expect(getInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE); - await expect(response.json()).resolves.toStrictEqual({ code: "failed", legacy: "kyc failed" }); + await expect(response.json()).resolves.toStrictEqual({ code: "processing", legacy: "kyc failed" }); expect(response.status).toBe(400); }); - it("returns failed kyc when inquiry needs review", async () => { + it("returns processing when inquiry needs review", async () => { const getPendingInquiryTemplate = vi .spyOn(persona, "getPendingInquiryTemplate") .mockResolvedValueOnce(persona.PANDA_TEMPLATE); @@ -494,7 +494,7 @@ describe("authenticated", () => { expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "basic"); expect(getInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE); - await expect(response.json()).resolves.toStrictEqual({ code: "failed", legacy: "kyc failed" }); + await expect(response.json()).resolves.toStrictEqual({ code: "processing", legacy: "kyc failed" }); expect(response.status).toBe(400); }); }); @@ -1262,10 +1262,360 @@ describe("authenticated", () => { }); }); }); + + describe("cardLimit scope", () => { + beforeEach(async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, "bob")); + }); + + describe("getting kyc", () => { + it("returns ok when persona account has card_limit_usd set", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "resolved" }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(response.status).toBe(200); + }); + + it("returns ok with country code header when persona account has card_limit_usd set", async () => { + const unknownAccount = { data: [basicAccount] } satisfies persona.UnknownAccountOutput; + vi.spyOn(persona, "getUnknownAccount").mockResolvedValueOnce(unknownAccount); + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "resolved" }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit", countryCode: "true" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + expect(persona.getUnknownAccount).toHaveBeenCalledWith("bob"); + expect(persona.getCardLimitStatus).toHaveBeenCalledWith("bob", unknownAccount); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(response.headers.get("User-Country")).toBe("AR"); + expect(response.status).toBe(200); + }); + + it("omits country code header when basic account lookup fails", async () => { + vi.spyOn(persona, "getUnknownAccount").mockRejectedValueOnce(new Error("network error")); + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "resolved" }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit", countryCode: "true" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + expect(persona.getUnknownAccount).toHaveBeenCalledWith("bob"); + expect(persona.getCardLimitStatus).toHaveBeenCalledWith("bob", undefined); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(response.headers.get("User-Country")).toBeNull(); + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledWith(expect.objectContaining({ message: "network error" }), { + level: "error", + contexts: { details: { credentialId: "bob", scope: "cardLimit" } }, + }); + }); + + it("returns basic kyc required when basic kyc is not approved", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noTemplate" }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "no kyc" }); + expect(response.status).toBe(400); + }); + + it("returns not started when no inquiry exists", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noInquiry" }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + expect(response.status).toBe(400); + }); + + it("returns ok and sends sentry error when inquiry is approved but account not updated", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ + status: "approved", + id: personaTemplate.id, + }); + vi.mocked(captureException).mockClear(); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + new Error("inquiry approved but account not updated"), + { level: "error", contexts: { inquiry: { templateId: persona.CARD_LIMIT_TEMPLATE, referenceId: "bob" } } }, + ); + }); + + it.each(["created", "expired", "pending"] as const)("returns not started when inquiry is %s", async (status) => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status, id: personaTemplate.id }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + expect(response.status).toBe(400); + }); + + it.each(["completed", "needs_review"] as const)("returns processing when inquiry is %s", async (status) => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status, id: personaTemplate.id }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "processing" }); + expect(response.status).toBe(400); + }); + + it.each(["declined", "failed"] as const)("returns bad kyc when inquiry is %s", async (status) => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status, id: personaTemplate.id }); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "bad kyc" }); + expect(response.status).toBe(400); + }); + }); + + describe("posting kyc", () => { + it("returns already approved when persona account has card_limit_usd set", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "resolved" }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "already approved" }); + expect(response.status).toBe(400); + }); + + it("returns not started when basic kyc is not approved", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noTemplate" }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + expect(response.status).toBe(400); + }); + + it("creates a new inquiry when none exists", async () => { + const sessionToken = "persona-session-token"; + + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noInquiry" }); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(basicAccount); + vi.spyOn(persona, "createInquiry").mockResolvedValueOnce(inquiry); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, undefined, { + "name-first": "ALEXANDER J", + "name-last": "SAMPLE", + }); + await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); + expect(response.status).toBe(200); + }); + + it("creates inquiry without name when account is not found", async () => { + const sessionToken = "persona-session-token"; + + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noInquiry" }); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + vi.spyOn(persona, "createInquiry").mockResolvedValueOnce(inquiry); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, undefined, undefined); + await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); + expect(response.status).toBe(200); + }); + + it("creates inquiry without name when getAccount fails", async () => { + const sessionToken = "persona-session-token"; + + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noInquiry" }); + vi.spyOn(persona, "getAccount").mockRejectedValueOnce(new Error("network error")); + vi.spyOn(persona, "createInquiry").mockResolvedValueOnce(inquiry); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); + expect(captureException).toHaveBeenCalledWith(expect.objectContaining({ message: "network error" }), { + level: "error", + contexts: { details: { credentialId: "bob", scope: "cardLimit" } }, + }); + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, undefined, undefined); + await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); + expect(response.status).toBe(200); + }); + + it.each(["created", "expired", "pending"] as const)("resumes a %s inquiry", async (status) => { + const sessionToken = "persona-session-token"; + + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status, id: personaTemplate.id }); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ inquiryId: personaTemplate.id, sessionToken }); + expect(response.status).toBe(200); + }); + + it.each(["declined", "failed"] as const)("returns failed for %s inquiry", async (status) => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status, id: personaTemplate.id }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "failed" }); + expect(response.status).toBe(400); + }); + + it("returns already approved for approved inquiry", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ + status: "approved", + id: personaTemplate.id, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "already approved" }); + expect(response.status).toBe(400); + expect(captureException).toHaveBeenCalledWith(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: persona.CARD_LIMIT_TEMPLATE, referenceId: "bob" } }, + }); + }); + + it("returns processing for completed inquiry", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ + status: "completed", + id: personaTemplate.id, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "processing" }); + expect(response.status).toBe(400); + }); + + it("returns processing for needs_review inquiry", async () => { + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ + status: "needs_review", + id: personaTemplate.id, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "processing" }); + expect(response.status).toBe(400); + }); + + it("returns no credential for missing credential", async () => { + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "unknown" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ code: "no credential", legacy: "no credential" }); + expect(response.status).toBe(500); + }); + + it("passes redirect uri to create inquiry", async () => { + const sessionToken = "persona-session-token"; + + vi.spyOn(persona, "getCardLimitStatus").mockResolvedValueOnce({ status: "noInquiry" }); + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(basicAccount); + vi.spyOn(persona, "createInquiry").mockResolvedValueOnce(inquiry); + vi.spyOn(persona, "resumeInquiry").mockResolvedValueOnce({ + ...resumeTemplate, + meta: { ...resumeTemplate.meta, "session-token": sessionToken }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit", redirectURI: "https://example.com" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, "https://example.com", { + "name-first": "ALEXANDER J", + "name-last": "SAMPLE", + }); + await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); + expect(response.status).toBe(200); + }); + }); + }); }); const basicAccount = { - type: "account", + type: "account" as const, id: "test-account-id", attributes: { "reference-id": "test-reference-id", diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 84b86b0a0..4b147e746 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -173,7 +173,7 @@ vi.mock("../utils/persona", async (importOriginal: () => Promise attributes: { "back-photo": null, "front-photo": null, "selfie-photo": null, "id-class": "dl" }, }), addDocument: vi.fn().mockResolvedValue({ data: { id: "acc_mock" } }), - getAccounts: vi.fn().mockResolvedValue({ data: [] }), + getCardLimitStatus: vi.fn().mockResolvedValue({ status: "noTemplate" as const }), getAccount: vi.fn().mockResolvedValue(null), }; }); diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 87599f912..65547a0e7 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -7,11 +7,11 @@ import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; import { hexToBytes, padHex, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; -import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; -import database, { credentials } from "../../database"; +import database, { cards, credentials } from "../../database"; import app from "../../hooks/persona"; import * as panda from "../../utils/panda"; import * as pax from "../../utils/pax"; @@ -35,6 +35,7 @@ describe("with reference", () => { afterEach(async () => { vi.resetAllMocks(); + await database.delete(cards).where(eq(cards.credentialId, referenceId)); await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, referenceId)); }); @@ -264,7 +265,11 @@ describe("with reference", () => { 'data/attributes/fields/currentGovernmentId1 Invalid key: Expected "currentGovernmentId1" but received undefined', 'data/attributes/fields/selectedIdClass1 Invalid key: Expected "selectedIdClass1" but received undefined', 'data/relationships/inquiryTemplate/data/id Invalid type: Expected "itmpl_TjaqJdQYkht17v645zNFUfkaWNan" but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', - 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', + 'data/type Invalid type: Expected "case" but received "inquiry"', + 'data/attributes/status Invalid type: Expected ("Approved" | "Declined" | "Open" | "Pending") but received "approved"', + 'data/relationships/caseTemplate Invalid key: Expected "caseTemplate" but received undefined', + 'data/relationships/inquiries Invalid key: Expected "inquiries" but received undefined', + 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', ], }); expect(panda.createUser).not.toHaveBeenCalled(); @@ -313,7 +318,11 @@ describe("with reference", () => { 'data/attributes/fields/currentGovernmentId1 Invalid key: Expected "currentGovernmentId1" but received undefined', 'data/attributes/fields/selectedIdClass1 Invalid key: Expected "selectedIdClass1" but received undefined', 'data/relationships/inquiryTemplate/data/id Invalid type: Expected "itmpl_TjaqJdQYkht17v645zNFUfkaWNan" but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', - 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', + 'data/type Invalid type: Expected "case" but received "inquiry"', + 'data/attributes/status Invalid type: Expected ("Approved" | "Declined" | "Open" | "Pending") but received "approved"', + 'data/relationships/caseTemplate Invalid key: Expected "caseTemplate" but received undefined', + 'data/relationships/inquiries Invalid key: Expected "inquiries" but received undefined', + 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', ], }); expect(panda.createUser).not.toHaveBeenCalled(); @@ -362,7 +371,11 @@ describe("with reference", () => { 'data/attributes/fields/currentGovernmentId1 Invalid key: Expected "currentGovernmentId1" but received undefined', 'data/attributes/fields/selectedIdClass1 Invalid key: Expected "selectedIdClass1" but received undefined', 'data/relationships/inquiryTemplate/data/id Invalid type: Expected "itmpl_TjaqJdQYkht17v645zNFUfkaWNan" but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', - 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', + 'data/type Invalid type: Expected "case" but received "inquiry"', + 'data/attributes/status Invalid type: Expected ("Approved" | "Declined" | "Open" | "Pending") but received "approved"', + 'data/relationships/caseTemplate Invalid key: Expected "caseTemplate" but received undefined', + 'data/relationships/inquiries Invalid key: Expected "inquiries" but received undefined', + 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', ], }); expect(panda.createUser).not.toHaveBeenCalled(); @@ -390,6 +403,7 @@ describe("persona hook", () => { vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); vi.spyOn(pax, "addCapita").mockResolvedValue({}); vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_123" } }); const response = await appClient.index.$post({ header: { "persona-signature": "t=1,v1=sha256" }, @@ -469,7 +483,7 @@ describe("manteca template", () => { }); describe("ignored template", () => { - beforeEach(() => { + beforeAll(() => { vi.resetAllMocks(); }); @@ -510,6 +524,318 @@ describe("ignored template", () => { }); }); +const cardLimitUpdateResponse = { data: { id: "acct_case" } }; +const cardUpdateResponse = { + id: "case-card", + userId: "pandaId", + type: "virtual", + status: "active", + limit: { amount: 2_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "9", + expirationYear: "2029", +} as const; + +describe("card limit case", () => { + const referenceId = "case-persona-ref"; + const owner = privateKeyToAddress(padHex("0x456")); + const factory = inject("ExaAccountFactory"); + const account = deriveAddress(factory, { x: padHex(owner), y: zeroHash }); + + beforeAll(async () => { + await database + .insert(credentials) + .values([{ id: referenceId, publicKey: new Uint8Array(hexToBytes(owner)), account, factory, pandaId: null }]); + }); + + afterEach(async () => { + vi.resetAllMocks(); + await database.delete(cards).where(eq(cards.credentialId, referenceId)); + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, referenceId)); + }); + + afterAll(async () => { + await database.delete(credentials).where(eq(credentials.id, referenceId)); + }); + + it("updates card with dynamic limit when approved", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, referenceId)); + await database.insert(cards).values([{ id: "case-card", credentialId: referenceId, lastFour: "1234" }]); + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockResolvedValueOnce(cardLimitUpdateResponse); + vi.spyOn(panda, "updateCard").mockResolvedValueOnce(cardUpdateResponse); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).toHaveBeenCalledExactlyOnceWith({ + id: "case-card", + limit: { amount: 2_000_000, frequency: "per7DayPeriod" }, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("updates frozen card limit on panda", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, referenceId)); + await database + .insert(cards) + .values([{ id: "case-card", credentialId: referenceId, lastFour: "1234", status: "FROZEN" }]); + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockResolvedValueOnce(cardLimitUpdateResponse); + vi.spyOn(panda, "updateCard").mockResolvedValueOnce(cardUpdateResponse); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).toHaveBeenCalledExactlyOnceWith({ + id: "case-card", + limit: { amount: 2_000_000, frequency: "per7DayPeriod" }, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("updates persona account when credential has no pandaId", async () => { + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockResolvedValueOnce(cardLimitUpdateResponse); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("updates persona account when no active card exists", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, referenceId)); + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockResolvedValueOnce(cardLimitUpdateResponse); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 500 and captures drift context when updateCard fails after updateCardLimit succeeds", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, referenceId)); + await database.insert(cards).values([{ id: "case-card", credentialId: referenceId, lastFour: "1234" }]); + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockResolvedValueOnce(cardLimitUpdateResponse); + vi.spyOn(panda, "updateCard").mockRejectedValueOnce(new Error("panda api error")); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(500); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).toHaveBeenCalledExactlyOnceWith({ + id: "case-card", + limit: { amount: 2_000_000, frequency: "per7DayPeriod" }, + }); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "panda api error" }), + expect.objectContaining({ + level: "error", + contexts: { + cardLimitDrift: { referenceId, limitUsd: 20_000, pandaId: "pandaId", cardId: "case-card" }, + }, + }), + ); + }); + + it("returns 500 when updateCardLimit fails before updateCard", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, referenceId)); + await database.insert(cards).values([{ id: "case-card", credentialId: referenceId, lastFour: "1234" }]); + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockRejectedValueOnce(new Error("persona api error")); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(500); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "persona api error" }), + { + level: "error", + contexts: { + cardLimitDrift: { referenceId, limitUsd: 20_000, pandaId: "pandaId", cardId: "case-card" }, + }, + }, + ); + }); + + it("returns ok without updating card when declined", async () => { + const response = await postCase(casePayload({ status: "Declined", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns no limit when card-limit-usd field is missing", async () => { + const response = await postCase(casePayload({ status: "Approved" })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "no limit" }); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns no limit when card-limit-usd value is null", async () => { + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: null })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "no limit" }); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("captures exception when no credential found", async () => { + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": "nonexistent" } }, + }); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + expect(persona.updateCardLimit).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "no credential" }), + expect.objectContaining({ level: "error", contexts: { credential: { referenceId: "nonexistent" } } }), + ); + expect(panda.updateCard).not.toHaveBeenCalled(); + }); + + it("skips panda when pandaId set but no active card exists", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, referenceId)); + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + vi.spyOn(persona, "updateCardLimit").mockResolvedValueOnce(cardLimitUpdateResponse); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(persona.updateCardLimit).toHaveBeenCalledExactlyOnceWith(referenceId, 20_000); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("falls through to bad persona for unknown case template", async () => { + const payload = casePayload({ status: "Approved", cardLimitUsd: 20_000 }); + payload.data.attributes.payload.data.relationships.caseTemplate.data.id = "ctmpl_unknown"; // cspell:ignore ctmpl + const response = await postCase(payload); + + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "bad persona" }), + expect.anything(), + ); + expect(panda.updateCard).not.toHaveBeenCalled(); + }); + + it("returns no inquiry when inquiries array is empty", async () => { + const payload = casePayload({ status: "Approved", cardLimitUsd: 20_000 }); + payload.data.attributes.payload.data.relationships.inquiries.data = []; + const response = await postCase(payload); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "no inquiry" }); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("rejects fractional card-limit-usd values as bad persona", async () => { + const payload = casePayload({ status: "Approved", cardLimitUsd: 1234 }); + payload.data.attributes.payload.data.attributes.fields.cardLimitUsd = { + type: "integer" as const, + value: 1234.56, + }; + const response = await postCase(payload); + + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "bad persona" }), + expect.anything(), + ); + expect(panda.updateCard).not.toHaveBeenCalled(); + }); + + it("returns 500 when getInquiryById fails", async () => { + vi.spyOn(persona, "getInquiryById").mockRejectedValueOnce(new Error("persona api error")); + const updateCardLimit = vi.spyOn(persona, "updateCardLimit"); + const response = await postCase(casePayload({ status: "Approved", cardLimitUsd: 20_000 })); + + expect(response.status).toBe(500); + expect(updateCardLimit).not.toHaveBeenCalled(); + expect(panda.updateCard).not.toHaveBeenCalled(); + }); +}); + +describe("ignored card limit inquiry template", () => { + beforeAll(() => vi.resetAllMocks()); + + it("returns ok for card limit inquiry template", async () => { + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: ignoredPayload(persona.CARD_LIMIT_TEMPLATE), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(panda.createUser).not.toHaveBeenCalled(); + expect(persona.addDocument).not.toHaveBeenCalled(); + }); +}); + +function casePayload({ + status, + cardLimitUsd, +}: { + cardLimitUsd?: null | number; + status: "Approved" | "Declined" | "Open" | "Pending"; +}) { + return { + data: { + attributes: { + payload: { + data: { + type: "case" as const, + id: "case_abc123", + attributes: { + status, + fields: + cardLimitUsd === undefined ? {} : { cardLimitUsd: { type: "integer" as const, value: cardLimitUsd } }, + }, + relationships: { + caseTemplate: { data: { id: persona.CARD_LIMIT_CASE_TEMPLATE } }, + inquiries: { data: [{ type: "inquiry" as const, id: "inq_case_inquiry_123" }] }, + }, + }, + }, + }, + }, + }; +} +function postCase(json: ReturnType) { + // @ts-expect-error hono client can't discriminate nested union for case payloads + return appClient.index.$post({ header: { "persona-signature": "t=1,v1=sha256" }, json }); +} function ignoredPayload(templateId: T) { return { data: { diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index 2df084035..b5bc7e391 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -1,6 +1,7 @@ import "../mocks/persona"; import "../mocks/sentry"; +import { captureException } from "@sentry/node"; import { array, minLength, number, object, optional, pipe, safeParse, string, union } from "valibot"; import { baseSepolia, optimism } from "viem/chains"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; @@ -756,6 +757,186 @@ describe("evaluateAccount", () => { }); }); +describe("updateCardLimit", () => { + let fetchSpy: MockInstance; + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("patches persona account with card_limit_usd", async () => { + fetchSpy + .mockResolvedValueOnce( + Response.json({ + data: [{ id: "acct_123", type: "account", attributes: { fields: { card_limit_usd: { value: null } } } }], + }), + ) + .mockResolvedValueOnce(Response.json({ data: { id: "acct_123" } })); + + await persona.updateCardLimit("ref_123", 20_000); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + const patchCall = fetchSpy.mock.calls[1]; + expect(patchCall?.[0]).toContain("/accounts/acct_123"); + expect(patchCall?.[1]).toMatchObject({ + method: "PATCH", + body: JSON.stringify({ data: { attributes: { fields: { card_limit_usd: 20_000 } } } }), + }); + }); + + it("throws when account not found", async () => { + fetchSpy.mockResolvedValueOnce(Response.json({ data: [] })); + + await expect(persona.updateCardLimit("ref_123", 20_000)).rejects.toThrow("account not found"); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("sends only card_limit_usd in patch body", async () => { + fetchSpy + .mockResolvedValueOnce( + Response.json({ + data: [{ id: "acct_456", type: "account", attributes: { fields: { card_limit_usd: { value: 10_000 } } } }], + }), + ) + .mockResolvedValueOnce(Response.json({ data: { id: "acct_456" } })); + + await persona.updateCardLimit("ref_456", 30_000); + + expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ + body: JSON.stringify({ data: { attributes: { fields: { card_limit_usd: 30_000 } } } }), + }); + }); +}); + +describe("getUnknownAccount", () => { + let fetchSpy: MockInstance; + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fetches accounts filtered by reference id and returns parsed result", async () => { + const account = { id: "acct_123", type: "account" as const, attributes: {} }; + fetchSpy.mockResolvedValueOnce(Response.json({ data: [account] })); + + const result = await persona.getUnknownAccount("ref_123"); + + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0]?.[0]).toContain("/accounts?page[size]=1&filter[reference-id]=ref_123"); + expect(result).toStrictEqual({ data: [account] }); + }); +}); + +describe("getCardLimitStatus", () => { + let fetchSpy: MockInstance; + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns resolved when card_limit_usd value is set", async () => { + fetchSpy.mockResolvedValueOnce( + Response.json({ + data: [{ id: "acct_123", type: "account", attributes: { fields: { card_limit_usd: { value: 20_000 } } } }], + }), + ); + + const status = await persona.getCardLimitStatus("ref_123"); + + expect(status).toStrictEqual({ status: "resolved" }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("reuses preloaded account when card_limit_usd value is set", async () => { + const status = await persona.getCardLimitStatus("ref_123", { + data: [{ id: "acct_123", type: "account", attributes: { fields: { card_limit_usd: { value: 20_000 } } } }], + }); + + expect(status).toStrictEqual({ status: "resolved" }); + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + + it("returns noTemplate when basic kyc is not approved", async () => { + fetchSpy.mockResolvedValueOnce(Response.json({ data: [] })); + + const status = await persona.getCardLimitStatus("ref_123"); + + expect(status).toStrictEqual({ status: "noTemplate" }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("returns noInquiry when template matches but inquiry is missing", async () => { + fetchSpy + .mockResolvedValueOnce(Response.json(basicAccount)) + .mockResolvedValueOnce(Response.json({ data: [] })) + .mockResolvedValueOnce(Response.json({ data: [] })); + + const status = await persona.getCardLimitStatus("ref_123"); + + expect(status).toStrictEqual({ status: "noInquiry" }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("reuses preloaded account and only fetches inquiries when card limit is pending", async () => { + fetchSpy.mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(Response.json({ data: [] })); + + const status = await persona.getCardLimitStatus("ref_123", basicAccount); + + expect(status).toStrictEqual({ status: "noInquiry" }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("returns inquiry with status when inquiry exists", async () => { + const inquiry = { + id: "inq_1", + type: "inquiry", + attributes: { status: "pending", "reference-id": "ref_123" }, + }; + fetchSpy + .mockResolvedValueOnce(Response.json(basicAccount)) + .mockResolvedValueOnce(Response.json({ data: [] })) + .mockResolvedValueOnce(Response.json({ data: [inquiry] })); + + const status = await persona.getCardLimitStatus("ref_123"); + + expect(status).toStrictEqual({ status: "pending", id: inquiry.id }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("captures and rethrows exception when fetch rejects", async () => { + const error = new Error("network error"); + fetchSpy.mockRejectedValueOnce(error); + + await expect(persona.getCardLimitStatus("ref_123")).rejects.toThrow("network error"); + expect(captureException).toHaveBeenCalledWith(error, { + level: "error", + contexts: { details: { referenceId: "ref_123", scope: "cardLimit" } }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("evaluateAccount cardLimit", () => { + it("returns panda template when basic is not done", async () => { + const result = await persona.evaluateAccount(emptyAccount, "cardLimit"); + + expect(result).toBe(persona.PANDA_TEMPLATE); + }); + + it("returns card limit template when basic is done", async () => { + const result = await persona.evaluateAccount(basicAccount, "cardLimit"); + + expect(result).toBe(persona.CARD_LIMIT_TEMPLATE); + }); +}); + describe("getDocumentForBridge", () => { it("returns undefined for empty documents", () => { expect(persona.getDocumentForBridge([])).toBeUndefined(); @@ -1832,6 +2013,36 @@ const accountWithIdDocument = { ], }; +describe("parseAccount", () => { + it("returns the first account when basic scope matches", () => { + const result = persona.parseAccount(basicAccount, "basic"); + expect(result?.id).toBe("test-account-id"); + expect(result?.attributes["country-code"]).toBeDefined(); + }); + + it("returns undefined when basic scope does not match", () => { + expect(persona.parseAccount({ data: [{ id: "x", type: "account", attributes: {} }] }, "basic")).toBeUndefined(); + }); + + it("returns undefined when data array is empty", () => { + expect(persona.parseAccount({ data: [] }, "basic")).toBeUndefined(); + expect(persona.parseAccount({ data: [] }, "cardLimit")).toBeUndefined(); + }); + + it("returns the first account when cardLimit scope matches", () => { + const account = { + data: [{ id: "cl-id", type: "account" as const, attributes: { fields: { card_limit_usd: { value: 5000 } } } }], + }; + const result = persona.parseAccount(account, "cardLimit"); + expect(result?.id).toBe("cl-id"); + expect(result?.attributes.fields.card_limit_usd?.value).toBe(5000); + }); + + it("returns undefined when cardLimit scope does not match", () => { + expect(persona.parseAccount({ data: [{ id: "x", type: "account", attributes: {} }] }, "cardLimit")).toBeUndefined(); + }); +}); + const accountWithIdAndPpDocuments = { data: [ { diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bc..ee1ba5693 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -49,7 +49,11 @@ if (!process.env.PANDA_API_KEY) throw new Error("missing panda api key"); const key = process.env.PANDA_API_KEY; export default key; -export async function createCard(userId: string, productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID) { +export async function createCard( + userId: string, + productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID, + amount = 1_000_000, +) { return await request( CardResponse, `/issuing/users/${userId}/cards`, @@ -57,7 +61,7 @@ export async function createCard(userId: string, productId: typeof PLATINUM_PROD parse(CreateCardRequest, { type: "virtual", status: "active", - limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + limit: { amount, frequency: "per7DayPeriod" }, configuration: { productId, virtualCardArt: { diff --git a/server/utils/persona.ts b/server/utils/persona.ts index 3e00721f9..0c57c02db 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -1,5 +1,6 @@ import { vValidator } from "@hono/valibot-validator"; import { captureEvent, setContext } from "@sentry/core"; +import { captureException } from "@sentry/node"; import { createHmac, timingSafeEqual } from "node:crypto"; import { array, @@ -7,7 +8,9 @@ import { flatten, literal, nullable, + number, object, + optional, picklist, safeParse, string, @@ -30,6 +33,8 @@ if (!process.env.PERSONA_API_KEY) throw new Error("missing persona api key"); if (!process.env.PERSONA_URL) throw new Error("missing persona url"); if (!process.env.PERSONA_WEBHOOK_SECRET) throw new Error("missing persona webhook secret"); +export const CARD_LIMIT_CASE_TEMPLATE = "ctmpl_5cCoj56PD6NpsX3H3ZoMynZVfXbF"; // cspell:ignore ctmpl_5cCoj56PD6NpsX3H3ZoMynZVfXbF +export const CARD_LIMIT_TEMPLATE = "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2"; // cspell:ignore itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2 export const CRYPTOMATE_TEMPLATE = "itmpl_8uim4FvD5P3kFpKHX37CW817"; export const PANDA_TEMPLATE = "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"; export const MANTECA_TEMPLATE_EXTRA_FIELDS = "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo"; @@ -55,13 +60,31 @@ export async function getInquiry(referenceId: string, templateId: string) { return inquiries[0]; } +export function getInquiryById(inquiryId: string) { + return request( + object({ data: object({ attributes: object({ "reference-id": string() }) }) }), + `/inquiries/${inquiryId}`, + ); +} + export function resumeInquiry(inquiryId: string) { return request(ResumeInquiryResponse, `/inquiries/${inquiryId}/resume`, undefined, "POST"); } -export function createInquiry(referenceId: string, templateId: string, redirectURI?: string) { +export function createInquiry( + referenceId: string, + templateId: string, + redirectURI?: string, + fields?: { "name-first": string; "name-last": string }, +) { return request(CreateInquiryResponse, "/inquiries", { - data: { attributes: { "inquiry-template-id": templateId, "redirect-uri": `${redirectURI ?? appOrigin}/card` } }, + data: { + attributes: { + "inquiry-template-id": templateId, + "redirect-uri": `${redirectURI ?? appOrigin}/card`, + ...(fields && { fields }), + }, + }, meta: { "auto-create-account": true, "auto-create-account-reference-id": referenceId }, }); } @@ -260,22 +283,28 @@ const MantecaAccount = object({ const UnknownAccount = object({ data: array(object({ id: string(), type: literal("account"), attributes: unknown() })), }); +export type UnknownAccountOutput = InferOutput; + +const CardLimitAccount = object({ + id: string(), + type: literal("account"), + attributes: object({ + fields: object({ card_limit_usd: optional(object({ value: nullable(number()) })) }), + }), +}); const accountScopeSchemas = { bridge: object({ data: array(BaseAccount) }), basic: object({ data: array(BaseAccount) }), manteca: object({ data: array(MantecaAccount) }), document: object({ data: array(DocumentAccount) }), + cardLimit: object({ data: array(CardLimitAccount) }), } as const; export type AccountScope = keyof typeof accountScopeSchemas; type AccountResponse = InferOutput<(typeof accountScopeSchemas)[T]>; export type AccountOutput = AccountResponse["data"][number]; -export function getAccounts(referenceId: string, scope: T): Promise> { - return request(accountScopeSchemas[scope], `/accounts?page[size]=1&filter[reference-id]=${referenceId}`); -} - export async function searchAccounts(email: string) { const { data } = await request( object({ data: array(object({ attributes: object({ "reference-id": string() }) })) }), @@ -286,6 +315,13 @@ export async function searchAccounts(email: string) { return data; } +export function getAccounts(referenceId: string, scope: T) { + return request, BaseIssue>( + accountScopeSchemas[scope], + `/accounts?page[size]=1&filter[reference-id]=${referenceId}`, + ); +} + export async function getAccount( referenceId: string, scope: T, @@ -294,14 +330,48 @@ export async function getAccount( return data[0]; } -function getUnknownAccount(referenceId: string) { +export async function updateCardLimit(referenceId: string, limitUsd: number) { + const account = await getAccount(referenceId, "cardLimit"); + if (!account) throw new Error("account not found"); + return request( + object({ data: object({ id: string() }) }), + `/accounts/${account.id}`, + { data: { attributes: { fields: { card_limit_usd: limitUsd } } } }, + "PATCH", + ); +} + +export function parseAccount(unknownAccount: UnknownAccountOutput, scope: "basic"): AccountOutput<"basic"> | undefined; +export function parseAccount( + unknownAccount: UnknownAccountOutput, + scope: "cardLimit", +): AccountOutput<"cardLimit"> | undefined; +export function parseAccount(unknownAccount: UnknownAccountOutput, scope: T) { + const result = safeParse(accountScopeSchemas[scope], unknownAccount); + return result.success ? result.output.data[0] : undefined; +} + +export async function getCardLimitStatus(referenceId: string, account?: UnknownAccountOutput) { + const unknownAccount = + account ?? + (await getUnknownAccount(referenceId).catch((error: unknown) => { + captureException(error, { level: "error", contexts: { details: { referenceId, scope: "cardLimit" } } }); + throw error; + })); + if (parseAccount(unknownAccount, "cardLimit")?.attributes.fields.card_limit_usd?.value != null) + return { status: "resolved" as const }; + if ((await evaluateAccount(unknownAccount, "cardLimit")) !== CARD_LIMIT_TEMPLATE) + return { status: "noTemplate" as const }; + const inquiry = await getInquiry(referenceId, CARD_LIMIT_TEMPLATE); + if (!inquiry) return { status: "noInquiry" as const }; + return { status: inquiry.attributes.status, id: inquiry.id }; +} + +export 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) { const unknownAccount = await getUnknownAccount(referenceId); return evaluateAccount(unknownAccount, scope); } @@ -310,11 +380,17 @@ export async function evaluateAccount( unknownAccount: InferOutput, scope: AccountScope, ): Promise< - typeof MANTECA_TEMPLATE_EXTRA_FIELDS | typeof MANTECA_TEMPLATE_WITH_ID_CLASS | typeof PANDA_TEMPLATE | undefined + | typeof CARD_LIMIT_TEMPLATE + | typeof MANTECA_TEMPLATE_EXTRA_FIELDS + | typeof MANTECA_TEMPLATE_WITH_ID_CLASS + | typeof PANDA_TEMPLATE + | undefined > { switch (scope) { case "document": throw new Error("document account scope not supported"); + case "cardLimit": + return (await evaluateAccount(unknownAccount, "basic")) ?? CARD_LIMIT_TEMPLATE; case "basic": { const result = safeParse(accountScopeSchemas[scope], unknownAccount); if (!result.success) {