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/server/api/kyc.ts b/server/api/kyc.ts index f864858aa..9e33d4bdf 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -41,7 +41,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 +59,40 @@ export default new Hono() setUser({ id: account }); setContext("exa", { credential }); + if (scope === "cardLimit") { + if (!credential.pandaId) return c.json({ code: "bad kyc" }, 400); + if (await getPendingInquiryTemplate(credentialId, "basic")) return c.json({ code: "not started" }, 200); + let templateId: Awaited>; + try { + templateId = await getPendingInquiryTemplate(credentialId, "cardLimit"); + } catch (error: unknown) { + if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) { + return c.json({ code: "not supported" }, 400); + } + throw error; + } + const template = getCardLimitTemplate(credentialId, templateId); + if (!template) return c.json({ code: "not started" }, 200); + const cardLimitInquiry = await getInquiry(credentialId, template); + if (!cardLimitInquiry) return c.json({ code: "not started" }, 200); + switch (cardLimitInquiry.attributes.status) { + case "approved": + return c.json({ code: "ok" }, 200); + case "completed": + case "needs_review": + return c.json({ code: "processing" }, 400); + case "created": + case "pending": + case "expired": + return c.json({ code: "not started" }, 200); + 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) => { @@ -113,7 +147,7 @@ export default new Hono() 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 +158,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 +175,56 @@ export default new Hono() setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); + if (scope === "cardLimit") { + if (await getPendingInquiryTemplate(credentialId, "basic")) return c.json({ code: "not started" }, 400); + let templateId: Awaited>; + try { + templateId = await getPendingInquiryTemplate(credentialId, "cardLimit"); + } catch (error: unknown) { + if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) { + return c.json({ code: "not supported" }, 400); + } + throw error; + } + const template = getCardLimitTemplate(credentialId, templateId); + if (!template) return c.json({ code: "not started" }, 400); + const cardLimitInquiry = await getInquiry(credentialId, template); + if (!cardLimitInquiry) { + const account = await getAccount(credentialId, "basic").catch((error: unknown) => { + captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } }); + }); + const { data } = await createInquiry( + credentialId, + template, + redirectURI, + account + ? { "name-first": account.attributes["name-first"], "name-last": account.attributes["name-last"] } + : undefined, + ); + return c.json(await generateInquiryTokens(data.id), 200); + } + switch (cardLimitInquiry.attributes.status) { + case "approved": + captureException(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: template, referenceId: credentialId } }, + }); + return c.json({ code: "already approved" }, 400); + case "completed": + case "needs_review": + return c.json({ code: "processing" }, 400); + case "pending": + case "created": + case "expired": + return c.json(await generateInquiryTokens(cardLimitInquiry.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 +241,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 +256,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"); } }, ); @@ -212,6 +293,21 @@ async function isLegacy( }); } +function getCardLimitTemplate( + credentialId: string, + inquiryTemplateId: Awaited>, +) { + if (inquiryTemplateId === PANDA_TEMPLATE) { + captureException(new Error("cardLimit: basic not found in Persona despite pandaId set"), { + level: "error", + contexts: { credential: { credentialId } }, + }); + return null; + } + if (!inquiryTemplateId) throw new Error("unexpected: no template for cardLimit"); + return inquiryTemplateId; +} + async function generateInquiryTokens(inquiryId: string): Promise<{ inquiryId: string; sessionToken: string }> { const { meta: sessionTokenMeta } = await resumeInquiry(inquiryId); return { inquiryId, sessionToken: sessionTokenMeta["session-token"] }; diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 1252d71fa..ecefa8d50 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -10,7 +10,9 @@ import { literal, looseObject, minLength, + minValue, nullable, + number, object, optional, picklist, @@ -23,13 +25,16 @@ 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, @@ -184,6 +189,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(), 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 +220,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 +243,38 @@ 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 cardLimitUsd = payload.data.attributes.fields.cardLimitUsd?.value; + if (cardLimitUsd == null) return c.json({ code: "no limit" }, 200); + const inquiry = payload.data.relationships.inquiries.data[0]; + if (!inquiry?.id) return c.json({ code: "no inquiry" }, 200); + const { + data: { + attributes: { "reference-id": referenceId }, + }, + } = await getInquiryById(inquiry.id); + const credential = await database.query.credentials.findFirst({ + columns: { pandaId: true }, + where: eq(credentials.id, referenceId), + with: { cards: { columns: { id: true }, where: eq(cards.status, "ACTIVE"), limit: 1 } }, + }); + if (!credential) { + captureException(new Error("no credential"), { level: "error", contexts: { credential: { referenceId } } }); + return c.json({ code: "no credential" }, 200); + } + if (!credential.pandaId) return c.json({ code: "no panda" }, 200); + if (!credential.cards[0]) { + captureException(new Error("no card"), { level: "error", contexts: { card: { referenceId } } }); + return c.json({ code: "no card" }, 200); + } + await updateCard({ + id: credential.cards[0].id, + limit: { amount: cardLimitUsd * 100, frequency: "per7DayPeriod" }, + }); + 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/kyc.test.ts b/server/test/api/kyc.test.ts index 0855ea299..70de9f3ba 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -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,409 @@ describe("authenticated", () => { }); }); }); + + describe("cardLimit scope", () => { + beforeEach(async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, "bob")); + }); + + describe("getting kyc", () => { + it("returns bad kyc when pandaId is missing", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); + + 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); + }); + + it("returns not started when basic kyc is not approved", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate").mockResolvedValueOnce(persona.PANDA_TEMPLATE); + const getInquiry = vi.spyOn(persona, "getInquiry"); + + 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(200); + expect(getInquiry).not.toHaveBeenCalled(); + }); + + it("returns not started when basic not in Persona despite pandaId set", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.PANDA_TEMPLATE); + const getInquiry = vi.spyOn(persona, "getInquiry"); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + expect(captureException).toHaveBeenCalledWith( + new Error("cardLimit: basic not found in Persona despite pandaId set"), + { level: "error", contexts: { credential: { credentialId: "bob" } } }, + ); + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + expect(response.status).toBe(200); + expect(getInquiry).not.toHaveBeenCalled(); + }); + + it("returns not started when no inquiry exists", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + expect(persona.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + expect(response.status).toBe(200); + }); + + it("returns ok when inquiry is approved", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(personaTemplate); + vi.mocked(captureException).mockClear(); + + const response = await appClient.index.$get( + { query: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession" } }, + ); + + expect(persona.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(response.status).toBe(200); + expect(captureException).not.toHaveBeenCalled(); + }); + + it.each(["created", "expired", "pending"] as const)("returns not started when inquiry is %s", async (status) => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status }, + }); + 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(200); + }); + + it.each(["completed", "needs_review"] as const)("returns processing when inquiry is %s", async (status) => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status }, + }); + + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status }, + }); + + 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 not started when basic kyc is not approved", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate").mockResolvedValueOnce(persona.PANDA_TEMPLATE); + + 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("returns not started when basic not in Persona despite pandaId set", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.PANDA_TEMPLATE); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(captureException).toHaveBeenCalledWith( + new Error("cardLimit: basic not found in Persona despite pandaId set"), + { level: "error", contexts: { credential: { credentialId: "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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + 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.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status }, + }); + 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.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + await expect(response.json()).resolves.toStrictEqual({ code: "failed" }); + expect(response.status).toBe(400); + }); + + it("returns already approved for approved inquiry", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "approved" }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "completed" }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + await expect(response.json()).resolves.toStrictEqual({ code: "processing" }); + expect(response.status).toBe(400); + }); + + it("returns processing for needs_review inquiry", async () => { + vi.spyOn(persona, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce({ + ...personaTemplate, + attributes: { ...personaTemplate.attributes, status: "needs_review" }, + }); + + const response = await appClient.index.$post( + { json: { scope: "cardLimit" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(persona.getInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE); + 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, "getPendingInquiryTemplate") + .mockResolvedValueOnce(undefined) // eslint-disable-line unicorn/no-useless-undefined + .mockResolvedValueOnce(persona.CARD_LIMIT_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + 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/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 87599f912..27fe2c3a3 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -7,15 +7,16 @@ 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 { 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"; import * as persona from "../../utils/persona"; +import { CARD_LIMIT_CASE_TEMPLATE, CARD_LIMIT_TEMPLATE } from "../../utils/persona"; import * as sardine from "../../utils/sardine"; const appClient = testClient(app); @@ -35,6 +36,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 +266,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 +319,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 +372,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 +404,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 +484,7 @@ describe("manteca template", () => { }); describe("ignored template", () => { - beforeEach(() => { + beforeAll(() => { vi.resetAllMocks(); }); @@ -510,6 +525,240 @@ describe("ignored template", () => { }); }); +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)); + }); + + 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(panda, "updateCard").mockResolvedValueOnce({} as Awaited>); + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: casePayload({ status: "Approved", cardLimitUsd: 20_000 }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(panda.updateCard).toHaveBeenCalledExactlyOnceWith({ + id: "case-card", + limit: { amount: 2_000_000, frequency: "per7DayPeriod" }, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 500 when updateCard fails", 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(panda, "updateCard").mockRejectedValueOnce(new Error("panda api error")); + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: casePayload({ status: "Approved", cardLimitUsd: 20_000 }), + }); + + expect(response.status).toBe(500); + expect(panda.updateCard).toHaveBeenCalledExactlyOnceWith({ + id: "case-card", + limit: { amount: 2_000_000, frequency: "per7DayPeriod" }, + }); + }); + + it("returns ok without updating card when declined", async () => { + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: 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 appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: 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 appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: 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 appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: casePayload({ status: "Approved", cardLimitUsd: 20_000 }), + }); + + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "no credential" }), + expect.objectContaining({ level: "error", contexts: { credential: { referenceId: "nonexistent" } } }), + ); + expect(panda.updateCard).not.toHaveBeenCalled(); + }); + + it("returns no panda when credential has no pandaId", async () => { + vi.spyOn(persona, "getInquiryById").mockResolvedValueOnce({ + data: { attributes: { "reference-id": referenceId } }, + }); + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: casePayload({ status: "Approved", cardLimitUsd: 20_000 }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("captures exception 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 } }, + }); + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: casePayload({ status: "Approved", cardLimitUsd: 20_000 }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(panda.updateCard).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "no card" }), + expect.objectContaining({ level: "error", contexts: { card: { referenceId } } }), + ); + }); + + it("falls through to bad persona for unknown case template", async () => { + const payload = casePayload({ status: "Approved", cardLimitUsd: 20_000 }); + // @ts-expect-error override template id for test + payload.data.attributes.payload.data.relationships.caseTemplate.data.id = "ctmpl_unknown"; // cspell:ignore ctmpl + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: 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 appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: 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("returns 500 when getInquiryById fails", async () => { + vi.spyOn(persona, "getInquiryById").mockRejectedValueOnce(new Error("persona api error")); + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: casePayload({ status: "Approved", cardLimitUsd: 20_000 }), + }); + + expect(response.status).toBe(500); + 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(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: CARD_LIMIT_CASE_TEMPLATE } }, + inquiries: { data: [{ type: "inquiry" as const, id: "inq_case_inquiry_123" }] }, + }, + }, + }, + }, + }, + }; +} function ignoredPayload(templateId: T) { return { data: { diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index 2df084035..9cec30461 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -754,6 +754,20 @@ describe("evaluateAccount", () => { ).rejects.toThrow(persona.scopeValidationErrors.INVALID_SCOPE_VALIDATION); }); }); + + describe("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", () => { diff --git a/server/utils/persona.ts b/server/utils/persona.ts index 3e00721f9..f28820298 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -30,11 +30,13 @@ 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 CRYPTOMATE_TEMPLATE = "itmpl_8uim4FvD5P3kFpKHX37CW817"; -export const PANDA_TEMPLATE = "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"; -export const MANTECA_TEMPLATE_EXTRA_FIELDS = "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo"; -export const MANTECA_TEMPLATE_WITH_ID_CLASS = "itmpl_TjaqJdQYkht17v645zNFUfkaWNan"; -export const ADDRESS_TEMPLATE = "itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx"; +export const CARD_LIMIT_CASE_TEMPLATE = "ctmpl_5cCoj56PD6NpsX3H3ZoMynZVfXbF" as const; // cspell:ignore ctmpl_5cCoj56PD6NpsX3H3ZoMynZVfXbF +export const CARD_LIMIT_TEMPLATE = "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" as const; // cspell:ignore itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2 +export const CRYPTOMATE_TEMPLATE = "itmpl_8uim4FvD5P3kFpKHX37CW817" as const; +export const PANDA_TEMPLATE = "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2" as const; +export const MANTECA_TEMPLATE_EXTRA_FIELDS = "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo" as const; +export const MANTECA_TEMPLATE_WITH_ID_CLASS = "itmpl_TjaqJdQYkht17v645zNFUfkaWNan" as const; +export const ADDRESS_TEMPLATE = "itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" as const; const PERSONA_API_VERSION = "2023-01-05"; @@ -55,13 +57,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?: Record, +) { 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 }, }); } @@ -269,6 +289,7 @@ const accountScopeSchemas = { } as const; export type AccountScope = keyof typeof accountScopeSchemas; +export type TemplateScope = "cardLimit" | AccountScope; type AccountResponse = InferOutput<(typeof accountScopeSchemas)[T]>; export type AccountOutput = AccountResponse["data"][number]; @@ -300,7 +321,7 @@ function getUnknownAccount(referenceId: string) { export async function getPendingInquiryTemplate( referenceId: string, - scope: AccountScope, + scope: TemplateScope, ): Promise>> { const unknownAccount = await getUnknownAccount(referenceId); return evaluateAccount(unknownAccount, scope); @@ -308,13 +329,22 @@ export async function getPendingInquiryTemplate( export async function evaluateAccount( unknownAccount: InferOutput, - scope: AccountScope, + scope: TemplateScope, ): 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": { + const basicTemplate = await evaluateAccount(unknownAccount, "basic"); + if (basicTemplate) return basicTemplate; + return CARD_LIMIT_TEMPLATE; + } case "basic": { const result = safeParse(accountScopeSchemas[scope], unknownAccount); if (!result.success) {