diff --git a/.changeset/brown-heads-vanish.md b/.changeset/brown-heads-vanish.md new file mode 100644 index 000000000..348d4e68a --- /dev/null +++ b/.changeset/brown-heads-vanish.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ process business onboarding approvals diff --git a/infra/utils/modules.ts b/infra/utils/modules.ts index 8863f3583..aaa7f2afa 100644 --- a/infra/utils/modules.ts +++ b/infra/utils/modules.ts @@ -44,8 +44,16 @@ export default define({ shared: ["manteca-api-url"], }, panda: { - secrets: ["onesignal-api-key", "panda-api-key", "postgres-url", "sardine-api-key", "segment-write-key"], - shared: ["panda-api-url", "sardine-api-url"], + env: { PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "personaBusinessAccountTypeId" }, + secrets: [ + "onesignal-api-key", + "panda-api-key", + "persona-api-key", + "postgres-url", + "sardine-api-key", + "segment-write-key", + ], + shared: ["panda-api-url", "persona-api-url", "sardine-api-url"], signers: ["settler", "issuer"], }, persona: { diff --git a/server/api/card.ts b/server/api/card.ts index a21b2c9f7..675bb7ec2 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -40,6 +40,14 @@ import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exa import { Address, Base64URL, Hex } from "@exactly/common/validation"; import { cards, credentials } from "../database/schema"; +import { isBusinessSalt } from "../utils/createCredential"; +import { + cardLimit, + createMutex as createAccountMutex, + finalizeBusinessApproval, + getMutex, + notifyCardIssued, +} from "../utils/panda"; import publicClient from "../utils/publicClient"; import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; @@ -555,12 +563,17 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` }), async (c) => { const { credentialId } = c.req.valid("cookie"); - const mutex = mutexes.get(credentialId) ?? createMutex(credentialId); + const mutexAccount = await database.query.credentials + .findFirst({ columns: { account: true, salt: true }, where: eq(credentials.id, credentialId) }) + .then((row) => (row && isBusinessSalt(parse(Address, row.salt)) ? parse(Address, row.account) : undefined)); + const mutex = mutexAccount + ? (getMutex(mutexAccount) ?? createAccountMutex(mutexAccount)) + : (mutexes.get(credentialId) ?? createMutex(credentialId)); return mutex .runExclusive(async () => { const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, credentialId), - columns: { account: true, pandaId: true, source: true }, + columns: { account: true, pandaCompanyId: true, pandaId: true, salt: true, source: true }, with: { cards: { columns: { id: true, status: true, productId: true }, @@ -599,12 +612,30 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` } } } - if (cardCount > 0) return c.json({ code: "already created" }, 400); + if (cardCount > 0) { + if (mutexAccount && credential.pandaCompanyId) + await finalizeBusinessApproval(credentialId, credential.pandaCompanyId, account, database, panda, { + credit, + persona, + sardine, + segment, + }); + return c.json({ code: "already created" }, 400); + } try { - const kyc = await panda.getApplicationStatus(pandaId); + const kyc = mutexAccount + ? credential.pandaCompanyId + ? await panda.getCompanyStatus(credential.pandaCompanyId) + : undefined + : await panda.getApplicationStatus(pandaId); + if (!kyc) return c.json({ code: "no panda" }, 403); if (kyc.applicationStatus !== "approved") { return c.json({ code: "kyc not approved" }, 403); } + if (mutexAccount && credential.pandaCompanyId) { + const users = await panda.getCompanyUsers(credential.pandaCompanyId); + if (!users.some(({ id }) => id === pandaId)) return c.json({ code: "no panda" }, 403); + } const productId = chain.id === base.id ? credential.source === "5lu2sNu0v0ZElC2m77QR3rAZBHLr8PoG" // cspell:ignore azbh @@ -626,35 +657,22 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` }, }); return orphan; - } else { - return panda.createCard( - pandaId, - productId, - await persona - .getAccount(credentialId, "cardLimit") - .then((profile) => - profile?.attributes.fields.card_limit_usd?.value == null - ? undefined - : profile.attributes.fields.card_limit_usd.value * 100, - ) - .catch((error: unknown): undefined => { - captureException(error, { - level: "error", - contexts: { details: { credentialId, scope: "cardLimit" } }, - }); - }), - ); } + return panda.createCard(pandaId, productId, { + amount: await cardLimit(credentialId, persona).catch((error: unknown): undefined => { + if (mutexAccount) throw error; + captureException(error, { + level: "error", + contexts: { details: { credentialId, scope: "cardLimit" } }, + }); + }), + ...(mutexAccount && { + idempotencyKey: `business-approval:${credentialId}:${credential.cards.filter(({ status }) => status === "DELETED").length + activeCards.length - cardCount}`, + }), + }); }); await database.insert(cards).values([{ id: card.id, credentialId, lastFour: card.last4, productId }]); - await credit.enqueue(account).catch((error: unknown) => - captureException(error, { - level: "error", - tags: { queue: creditName, job: creditName }, - extra: { account }, - }), - ); segment.track({ event: "CardIssued", userId: account, @@ -663,24 +681,17 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` if (isUpgradeFromPlatinum) handlePlatinumUpgrade(credentialId, account, pax, persona); - sardine - .customer({ - flow: { name: "card.issued", type: "payment_method_link" }, - customer: { id: credentialId, type: "customer" }, - transaction: { - id: card.id, - paymentMethod: { - type: "card", - card: { - hash: card.id, - last4: card.last4, - expiryMonth: card.expirationMonth, - expiryYear: card.expirationYear, - }, - }, - }, - }) - .catch((error: unknown) => captureException(error, { level: "error" })); + notifyCardIssued(sardine, { credentialId, card }); + + await (mutexAccount + ? credit.enqueue(account, `business-approval:${credentialId}:${card.id}`) + : credit.enqueue(account).catch((error: unknown) => + captureException(error, { + level: "error", + tags: { queue: creditName, job: creditName }, + extra: { account }, + }), + )); return c.json( { diff --git a/server/api/index.ts b/server/api/index.ts index fb9e28ef8..0a55ea151 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -81,7 +81,7 @@ export default function api({ ) .route("/activity", activity({ auth, database })) .route("/card", card({ auth, credit, database, panda, pax, persona, sardine, segment, walletExtension })) - .route("/kyc", kyc({ auth, database, panda, persona })) + .route("/kyc", kyc({ auth, credit, database, panda, persona, sardine, segment })) .route("/passkey", passkey({ auth, database })) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove .route("/pax", paxRoute({ auth, database, pax })) .route("/ramp", ramp({ auth, bridge, database, manteca, persona })) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index eeaf400d7..339019671 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -1,6 +1,6 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; import createDebug from "debug"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import * as honoOpenapi from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; @@ -30,16 +30,18 @@ import chain, { } from "@exactly/common/generated/chain"; import { Address, Hex } from "@exactly/common/validation"; -import { credentials, walletAddresses } from "../database/schema"; +import { cards, credentials, walletAddresses } from "../database/schema"; import { isBusinessSalt } from "../utils/createCredential"; import decodePublicKey from "../utils/decodePublicKey"; import { + activeCardStatuses, Application, UpdateApplicationRequest as ApplicationUpdate, BusinessApplicationError, CompanyApplicationResponse, CompanyApplicationStatusResponse, createMutex, + finalizeBusinessApproval, getMutex, } from "../utils/panda"; import { @@ -59,6 +61,9 @@ import type * as schema from "../database/schema"; import type { Auth } from "../middleware/auth"; import type createPanda from "../utils/panda"; import type createPersona from "../utils/persona"; +import type createSardine from "../utils/sardine"; +import type createSegment from "../utils/segment"; +import type createCredit from "../workers/credit/queue"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; const debug = createDebug("exa:kyc"); @@ -88,14 +93,20 @@ function buildBaseResponse(example = "string") { export default function route({ auth, + credit, database, panda, persona, + sardine, + segment, }: { auth: Auth; + credit: ReturnType; database: NodePgDatabase; panda: ReturnType; persona: ReturnType; + sardine: ReturnType; + segment: ReturnType; }) { return new Hono() .get( @@ -546,9 +557,8 @@ The admin should add a member using [addMember method](https://www.better-auth.c ), async (c) => { const payload = c.req.valid("json"); - const isBusiness = c.req.valid("query")?.accountType === "business"; const credentialId = c.req.valid("cookie").credentialId; - if (isBusiness) { + if (c.req.valid("query")?.accountType === "business") { const credential = await database.query.credentials.findFirst({ columns: { account: true, salt: true }, where: eq(credentials.id, credentialId), @@ -565,12 +575,35 @@ The admin should add a member using [addMember method](https://www.better-auth.c }); if (!current) return c.json({ code: "no credential" }, 500); try { - if (current.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + if (current.pandaId) { + const existing = await database.query.cards.findFirst({ + columns: { id: true }, + where: and(eq(cards.credentialId, credentialId), inArray(cards.status, activeCardStatuses)), + }); + if (existing) { + if (current.pandaCompanyId) { + const application = await panda.getCompanyStatus(current.pandaCompanyId); + if (application.applicationStatus === "approved") + await finalizeBusinessApproval(credentialId, current.pandaCompanyId, account, database, panda, { + credit, + persona, + sardine, + segment, + }); + } + return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + } + } const application = current.pandaCompanyId ? await panda.getCompanyStatus(current.pandaCompanyId) : await panda .createCompanyApplication( - await panda.businessApplication(credentialId, account, c.req.header("do-connecting-ip"), persona), + await panda.businessApplication( + credentialId, + account, + c.req.header("do-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim(), + persona, + ), { idempotencyKey: `business-application:${credentialId}` }, ) .then(async (result) => { @@ -585,6 +618,14 @@ The admin should add a member using [addMember method](https://www.better-auth.c ["denied", "locked", "canceled"].includes(application.applicationStatus) ) return c.json({ code: "bad kyb", legacy: "kyb not approved" }, 400); + if (application.applicationStatus === "approved") { + await finalizeBusinessApproval(credentialId, application.id, account, database, panda, { + credit, + persona, + sardine, + segment, + }); + } setUser({ id: account }); return c.json(application, 200); } catch (error) { diff --git a/server/hooks/bin/panda.ts b/server/hooks/bin/panda.ts index 639cd0c75..a7190a7e6 100644 --- a/server/hooks/bin/panda.ts +++ b/server/hooks/bin/panda.ts @@ -6,10 +6,12 @@ import * as schema from "../../database/schema"; import supervise, { own } from "../../supervise"; import createOnesignal from "../../utils/onesignal"; import createPanda from "../../utils/panda"; +import createPersona from "../../utils/persona"; import createSardine from "../../utils/sardine"; import secret from "../../utils/secret"; import createSegment from "../../utils/segment"; import { signer } from "../../utils/wallet"; +import createCredit from "../../workers/credit/queue"; import createHook from "../../workers/hook/queue"; import createRefund from "../../workers/refund/queue"; import { connect } from "../../workers/worker"; @@ -27,22 +29,38 @@ supervise( Promise.all([secret("panda-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) => createPanda({ key, url }), ), + Promise.all([secret("panda-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) => + createPersona(key, url), + ), secret("redis-url", secrets) .then((url) => connect(url)) - .then((bullmq) => [bullmq, createRefund(bullmq), createHook(bullmq)] as const), + .then((bullmq) => [bullmq, createCredit(bullmq), createRefund(bullmq), createHook(bullmq)] as const), Promise.all([secret("panda-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) => createSardine(key, url), ), secret("panda-segment-write-key", secrets).then((key) => createSegment(key)), signer("settler", kms), - ]).then(([database, issuer, onesignal, provider, [bullmq, refund, webhook], sardine, segment, settler]) => - own( - panda({ database, issuer, onesignal, panda: provider, refund, sardine, segment, settler, webhook }), - () => database.$client.end(), - () => kms.close(), - () => secrets.close(), - () => segment.close(), - () => Promise.all([refund.close(), webhook.close()]).finally(() => bullmq.quit()), - ), + ]).then( + ([database, issuer, onesignal, provider, persona, [bullmq, credit, refund, webhook], sardine, segment, settler]) => + own( + panda({ + credit, + database, + issuer, + onesignal, + panda: provider, + persona, + refund, + sardine, + segment, + settler, + webhook, + }), + () => database.$client.end(), + () => kms.close(), + () => secrets.close(), + () => segment.close(), + () => Promise.all([credit.close(), refund.close(), webhook.close()]).finally(() => bullmq.quit()), + ), ), ); diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 6a24f1cae..83b9e8898 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -51,10 +51,12 @@ import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib"; import { cards, credentials, transactions } from "../database/schema"; import t, { f } from "../i18n"; +import { isBusinessSalt } from "../utils/createCredential"; import { collectors, createMutex, declineMessage, + finalizeBusinessApproval, getMutex, Payload, signIssuerOp, @@ -72,8 +74,10 @@ import { name as refundName } from "../workers/refund/job"; import type * as schema from "../database/schema"; import type createOnesignal from "../utils/onesignal"; import type createPanda from "../utils/panda"; +import type createPersona from "../utils/persona"; import type createSardine from "../utils/sardine"; import type createSegment from "../utils/segment"; +import type createCredit from "../workers/credit/queue"; import type createHook from "../workers/hook/queue"; import type createRefund from "../workers/refund/queue"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; @@ -83,20 +87,24 @@ const debug = createDebug("exa:panda"); Object.assign(debug, { inspectOpts: { depth: undefined } }); export default function hook({ + credit, database, issuer, onesignal, panda, + persona, refund, sardine, segment, settler, webhook, }: { + credit: ReturnType; database: Database; issuer: LocalAccount; onesignal: ReturnType; panda: ReturnType; + persona: ReturnType; refund: ReturnType; sardine: ReturnType; segment: ReturnType; @@ -118,6 +126,28 @@ export default function hook({ getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `panda.${payload.resource}.${payload.action}`); if (payload.resource !== "transaction") { + if (payload.resource === "application") return c.json({ code: "ok" }); + if (payload.resource === "company") { + if (payload.body.applicationStatus !== "approved") return c.json({ code: "ok" }); + const credential = await database.query.credentials.findFirst({ + columns: { account: true, id: true, salt: true }, + where: eq(credentials.pandaCompanyId, payload.body.id), + }); + if (!credential) return c.json({ code: "retry" }, 500); + if (isBusinessSalt(v.parse(Address, credential.salt))) { + const account = v.parse(Address, credential.account); + setUser({ id: account }); + await (getMutex(account) ?? createMutex(account)).runExclusive(() => + finalizeBusinessApproval(credential.id, payload.body.id, account, database, panda, { + credit, + persona, + sardine, + segment, + }), + ); + } + return c.json({ code: "ok" }); + } if (payload.resource === "dispute") return c.json({ code: "ok" }); const pandaId = payload.resource === "card" diff --git a/server/index.ts b/server/index.ts index 92f86b4af..838fbae0b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -126,10 +126,12 @@ const mantecaHook = createMantecaHook({ segment, }); const pandaHook = createPandaHook({ + credit, database, issuer, onesignal, panda, + persona, refund, sardine, segment, diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 3438749b5..f99f3fada 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -4,8 +4,8 @@ import "../mocks/onesignal"; import "../mocks/panda"; import * as pax from "../mocks/pax"; import "../mocks/persona"; -import "../mocks/sardine"; -import "../mocks/segment"; +import { customer as sardineCustomer } from "../mocks/sardine"; +import { track } from "../mocks/segment"; import "../mocks/wallet"; import { KeyManagementServiceClient } from "@google-cloud/kms"; @@ -88,6 +88,33 @@ const app = route({ }); const appClient = testClient(app); +async function insertBusinessCredential({ + id, + account, + companyId, + pandaId, +}: { + account: `0x${string}`; + companyId: string; + id: string; + pandaId: string; +}) { + await database.insert(credentials).values({ + id, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + pandaId, + salt: account, + }); +} + +async function removeBusinessCredential(id: string) { + await database.delete(cards).where(eq(cards.credentialId, id)); + await database.delete(credentials).where(eq(credentials.id, id)); +} + beforeAll(async () => { keeper = wallet(await signer("keeper", kms)); }); @@ -546,6 +573,213 @@ describe("authenticated", () => { expect(captureException).not.toHaveBeenCalled(); }); + it("uses the company application for a business card", async () => { + const credentialId = "card-business"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x99", { size: 20 }), + companyId: "card-business-company", + pandaId: "card-business-user", + }); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus"); + const getCompanyStatus = vi + .spyOn(panda, "getCompanyStatus") + .mockResolvedValueOnce({ id: "card-business-company", applicationStatus: "approved" }); + const getCompanyUsers = vi + .spyOn(panda, "getCompanyUsers") + .mockResolvedValueOnce([{ id: "card-business-user", walletAddress: padHex("0x99", { size: 20 }) }]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "00000000-0000-4000-8000-0000000000ab", + userId: "card-business-user", + }); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(getCompanyStatus).toHaveBeenCalledExactlyOnceWith("card-business-company"); + expect(getCompanyUsers).toHaveBeenCalledExactlyOnceWith("card-business-company"); + expect(getApplicationStatus).not.toHaveBeenCalled(); + expect(createCard).toHaveBeenCalledExactlyOnceWith( + "card-business-user", + SIGNATURE_PRODUCT_ID, + expect.objectContaining({ idempotencyKey: "business-approval:card-business:0" }), + ); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + padHex("0x99", { size: 20 }), + "business-approval:card-business:00000000-0000-4000-8000-0000000000ab", + ); + } finally { + await removeBusinessCredential(credentialId); + } + }); + + it("runs card integrations before the business credit enqueue", async () => { + const credentialId = "card-business-enqueue-order"; + const cardId = "00000000-0000-4000-8000-0000000000ad"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x992", { size: 20 }), + companyId: "card-business-order-company", + pandaId: "card-business-order-user", + }); + vi.spyOn(panda, "getCompanyStatus").mockResolvedValueOnce({ + id: "card-business-order-company", + applicationStatus: "approved", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValueOnce([ + { + id: "card-business-order-user", + walletAddress: padHex("0x992", { size: 20 }), + }, + ]); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: cardId, + userId: "card-business-order-user", + }); + credit.enqueue.mockImplementationOnce(() => { + expect(track).toHaveBeenCalledTimes(1); + expect(sardineCustomer).toHaveBeenCalledTimes(1); + return Promise.reject(new Error("redis unavailable")); + }); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + expect(response.status).toBe(500); + } finally { + await removeBusinessCredential(credentialId); + } + }); + + it("retries business credit for an existing active card", async () => { + const credentialId = "card-business-existing"; + const cardId = "00000000-0000-4000-8000-000000000002"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x991", { size: 20 }), + companyId: "card-business-existing-company", + pandaId: "card-business-existing-user", + }); + await database + .insert(cards) + .values({ id: cardId, credentialId, lastFour: "9999", productId: SIGNATURE_PRODUCT_ID }); + vi.spyOn(panda, "getCard").mockResolvedValue(cardTemplate); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([ + { + id: "card-business-existing-user", + walletAddress: padHex("0x991", { size: 20 }), + }, + ]); + vi.mocked(credit.enqueue).mockRejectedValueOnce(new Error("redis unavailable")); + + try { + const failed = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + expect(failed.status).toBe(500); + + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "already created" }); + expect(credit.enqueue).toHaveBeenCalledTimes(2); + expect(credit.enqueue).toHaveBeenNthCalledWith( + 1, + padHex("0x991", { size: 20 }), + `business-approval:${credentialId}:${cardId}`, + ); + expect(credit.enqueue).toHaveBeenNthCalledWith( + 2, + padHex("0x991", { size: 20 }), + `business-approval:${credentialId}:${cardId}`, + ); + } finally { + await removeBusinessCredential(credentialId); + } + }); + + it("rotates the idempotency key when the provider deleted an active card", async () => { + const credentialId = "card-business-provider-deleted"; + const staleCardId = "00000000-0000-4000-8000-000000000003"; + const newCardId = "00000000-0000-4000-8000-0000000000ac"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x993", { size: 20 }), + companyId: "card-business-provider-deleted-company", + pandaId: "card-business-provider-deleted-user", + }); + await database + .insert(cards) + .values({ id: staleCardId, credentialId, lastFour: "8888", productId: SIGNATURE_PRODUCT_ID }); + vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "", "NotFoundError")); + vi.spyOn(panda, "getCompanyStatus").mockResolvedValueOnce({ + id: "card-business-provider-deleted-company", + applicationStatus: "approved", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValueOnce([ + { + id: "card-business-provider-deleted-user", + walletAddress: padHex("0x993", { size: 20 }), + }, + ]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: newCardId, + userId: "card-business-provider-deleted-user", + }); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(createCard).toHaveBeenCalledExactlyOnceWith( + "card-business-provider-deleted-user", + SIGNATURE_PRODUCT_ID, + expect.objectContaining({ idempotencyKey: `business-approval:${credentialId}:1` }), + ); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + padHex("0x993", { size: 20 }), + `business-approval:${credentialId}:${newCardId}`, + ); + const stale = await database.query.cards.findFirst({ + columns: { id: true, status: true }, + where: eq(cards.id, staleCardId), + }); + expect(stale).toStrictEqual({ id: staleCardId, status: "DELETED" }); + } finally { + await removeBusinessCredential(credentialId); + } + }); + + it("propagates business card limit lookup failures", async () => { + const credentialId = "card-business-limit-error"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x992", { size: 20 }), + companyId: "card-business-limit-company", + pandaId: "card-business-limit-user", + }); + vi.spyOn(panda, "getCompanyStatus").mockResolvedValueOnce({ + id: "card-business-limit-company", + applicationStatus: "approved", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValueOnce([ + { + id: "card-business-limit-user", + walletAddress: padHex("0x992", { size: 20 }), + }, + ]); + vi.spyOn(persona, "getAccount").mockRejectedValueOnce(new Error("persona unavailable")); + const createCard = vi.spyOn(panda, "createCard"); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + expect(response.status).toBe(500); + expect(createCard).not.toHaveBeenCalled(); + } finally { + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + it("throws when createCard fails with empty-body 403", async () => { const credentialId = "not-approved-empty"; await database.insert(credentials).values({ @@ -1098,7 +1332,7 @@ describe("authenticated", () => { const response = await appClient.index.$post({ header: { "test-credential-id": "base-default" } }); expect(response.status).toBe(200); - expect(createCard).toHaveBeenCalledWith("base-default-panda", BASE_PRODUCT_ID, undefined); + expect(createCard).toHaveBeenCalledWith("base-default-panda", BASE_PRODUCT_ID, { amount: undefined }); await expect(response.json()).resolves.toStrictEqual({ status: "ACTIVE", lastFour: "4081", @@ -1125,7 +1359,7 @@ describe("authenticated", () => { const response = await appClient.index.$post({ header: { "test-credential-id": "base-signature" } }); expect(response.status).toBe(200); - expect(createCard).toHaveBeenCalledWith("base-signature-panda", SIGNATURE_PRODUCT_ID, undefined); + expect(createCard).toHaveBeenCalledWith("base-signature-panda", SIGNATURE_PRODUCT_ID, { amount: undefined }); await expect(response.json()).resolves.toStrictEqual({ status: "ACTIVE", lastFour: "4242", @@ -1152,7 +1386,7 @@ describe("authenticated", () => { const response = await appClient.index.$post({ header: { "test-credential-id": "optimism-credential" } }); expect(response.status).toBe(200); - expect(createCard).toHaveBeenCalledWith("optimism-panda", SIGNATURE_PRODUCT_ID, undefined); + expect(createCard).toHaveBeenCalledWith("optimism-panda", SIGNATURE_PRODUCT_ID, { amount: undefined }); await expect(response.json()).resolves.toStrictEqual({ status: "ACTIVE", lastFour: "1010", @@ -2201,7 +2435,7 @@ describe("authenticated", () => { 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); + expect(createCardSpy).toHaveBeenCalledWith("limit-sync-panda", SIGNATURE_PRODUCT_ID, { amount: 2_000_000 }); }); it("uses default limit when persona account has no card limit", async () => { @@ -2230,7 +2464,7 @@ describe("authenticated", () => { 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); + expect(createCardSpy).toHaveBeenCalledWith("limit-null-panda", SIGNATURE_PRODUCT_ID, { amount: undefined }); }); it("falls back to default limit and captures when getAccount fails", async () => { @@ -2256,7 +2490,7 @@ describe("authenticated", () => { 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(createCardSpy).toHaveBeenCalledWith("limit-fail-panda", SIGNATURE_PRODUCT_ID, { amount: undefined }); expect(captureException).toHaveBeenCalledWith( error, expect.objectContaining({ diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index f051d898e..b4a407229 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -2,6 +2,8 @@ import "../mocks/auth"; import "../mocks/deployments"; import "../mocks/panda"; import "../mocks/persona"; +import "../mocks/sardine"; +import "../mocks/segment"; import "../mocks/sentry"; import { captureException } from "@sentry/node"; @@ -18,19 +20,23 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, inject, i import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; +import { SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address } from "@exactly/common/validation"; import route from "../../api/kyc"; -import database, { credentials, organizations, sources } from "../../database"; +import database, { cards, credentials, organizations, sources } from "../../database"; import authenticate from "../../middleware/auth"; import createAuth from "../../utils/auth"; import authSecret from "../../utils/authSecret"; import createPanda, * as Panda from "../../utils/panda"; -import createPersona, * as Persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; +import createPersona, * as Persona from "../../utils/persona"; import publicClient from "../../utils/publicClient"; +import createSardine from "../../utils/sardine"; +import createSegment from "../../utils/segment"; import ServiceError from "../../utils/ServiceError"; +import type createCredit from "../../workers/credit/queue"; import type * as v from "valibot"; const auth = createAuth(database, authSecret); @@ -48,11 +54,18 @@ const persona = Object.assign( ), Persona, ); +const credit = { + close: vi.fn(() => Promise.resolve()), + enqueue: vi.fn(() => Promise.resolve()), +} satisfies ReturnType; const app = route({ auth: authenticate(""), + credit, database, panda, persona, + sardine: createSardine("sardine", "https://sardine.test"), + segment: createSegment("segment"), }); const appClient = testClient(app); @@ -2545,9 +2558,9 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); describe("business application", () => { - const businessId = "bob-business"; - const businessAccount = parse(Address, padHex("0xb0b", { size: 20 })); - const businessSalt = parse(Address, padHex("0xb1b", { size: 20 })); + const businessId = "business-kyc"; + const businessAccount = parse(Address, padHex("0xb055", { size: 20 })); + const businessSalt = parse(Address, padHex("0xb056", { size: 20 })); const businessFields = { i_company_name: { value: "Account Acme" }, company_description: { value: "Account software" }, @@ -2578,6 +2591,20 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE country_code_1: { value: "US" }, }; + function mockBusiness() { + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { status: "approved", "reference-id": businessId }, + }); + vi.spyOn(persona, "getAccount").mockResolvedValue({ + id: "account-id", + type: "account", + attributes: { "reference-id": businessId, fields: businessFields }, + relationships: { "account-type": { data: { id: "acttp_company" } } }, + }); + } + beforeAll(async () => { await database.insert(credentials).values([ { @@ -2591,7 +2618,11 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); afterEach(async () => { - await database.update(credentials).set({ pandaCompanyId: null }).where(eq(credentials.id, businessId)); + await database.delete(cards).where(eq(cards.credentialId, businessId)); + await database + .update(credentials) + .set({ pandaCompanyId: null, pandaId: null }) + .where(eq(credentials.id, businessId)); }); afterAll(async () => { @@ -2624,21 +2655,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); it("submits a company application for a business credential", async () => { - vi.spyOn(persona, "getInquiry").mockResolvedValue({ - id: "inquiry-id", - type: "inquiry", - attributes: { - status: "approved", - "reference-id": businessId, - fields: { "company-description": { value: "Inquiry software" } }, - }, - }); - vi.spyOn(persona, "getAccount").mockResolvedValue({ - id: "account-id", - type: "account", - attributes: { "reference-id": businessId, fields: businessFields }, - relationships: { "account-type": { data: { id: "acttp_company" } } }, - }); + mockBusiness(); const companyApplication = { id: "company-1", name: "Account Acme", @@ -2674,21 +2691,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); it("returns bad request for a Panda validation error", async () => { - vi.spyOn(persona, "getInquiry").mockResolvedValue({ - id: "inquiry-id", - type: "inquiry", - attributes: { - status: "approved", - "reference-id": businessId, - fields: { "company-description": { value: "Inquiry software" } }, - }, - }); - vi.spyOn(persona, "getAccount").mockResolvedValue({ - id: "account-id", - type: "account", - attributes: { "reference-id": businessId, fields: businessFields }, - relationships: { "account-type": { data: { id: "acttp_company" } } }, - }); + mockBusiness(); vi.spyOn(panda, "createCompanyApplication").mockRejectedValueOnce( new ServiceError("Panda", 400, '{"message":"invalid company"}', undefined, "invalid company"), ); @@ -2708,6 +2711,115 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); }); + it("finalizes an approved company application", async () => { + mockBusiness(); + vi.spyOn(panda, "createCompanyApplication").mockResolvedValue({ + id: "company-approved", + name: "Account Acme", + address: { + line1: "1 Main St", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + applicationStatus: "approved", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user", walletAddress: businessAccount }]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-card", + userId: "business-user", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + + const response = await appClient.application.$post( + { json: {}, query: { accountType: "business" } }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession", "do-connecting-ip": "127.0.0.1" }, + }, + ); + + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, businessId) }); + const card = await database.query.cards.findFirst({ where: eq(cards.id, "business-card") }); + expect(response.status).toBe(200); + expect(card).toMatchObject({ + credentialId: businessId, + lastFour: "1234", + productId: SIGNATURE_PRODUCT_ID, + }); + expect(credential).toMatchObject({ pandaId: "business-user" }); + expect(createCard.mock.calls[0]?.slice(0, 2)).toStrictEqual(["business-user", SIGNATURE_PRODUCT_ID]); + }); + + it("resumes finalization when the panda user exists without a card", async () => { + await database + .update(credentials) + .set({ pandaId: "business-user", pandaCompanyId: "company-approved" }) + .where(eq(credentials.id, businessId)); + vi.spyOn(panda, "getCompanyStatus").mockResolvedValue({ + id: "company-approved", + applicationStatus: "approved", + applicationReason: "", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user" }]); + vi.spyOn(persona, "getAccount").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + const createCard = vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-card", + userId: "business-user", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + + const response = await appClient.application.$post( + { json: {}, query: { accountType: "business" } }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession", "do-connecting-ip": "127.0.0.1" }, + }, + ); + + const card = await database.query.cards.findFirst({ where: eq(cards.id, "business-card") }); + expect(response.status).toBe(200); + expect(createCard).toHaveBeenCalled(); + expect(card).toMatchObject({ credentialId: businessId }); + }); + + it("retries credit when an approved business card already exists", async () => { + await database + .update(credentials) + .set({ pandaId: "business-user", pandaCompanyId: "company-approved" }) + .where(eq(credentials.id, businessId)); + await database.insert(cards).values({ id: "business-retry-card", credentialId: businessId, lastFour: "1234" }); + vi.spyOn(panda, "getCompanyStatus").mockResolvedValue({ + id: "company-approved", + applicationStatus: "approved", + applicationReason: "", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user" }]); + credit.enqueue.mockClear(); + + const response = await appClient.application.$post( + { json: {}, query: { accountType: "business" } }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession" }, + }, + ); + + expect(response.status).toBe(409); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + businessAccount, + "business-approval:business-kyc:business-retry-card", + ); + }); + it("returns bad request when a business application includes a verify payload", async () => { const response = await appClient.application.$post( { diff --git a/server/test/hooks/bin.test.ts b/server/test/hooks/bin.test.ts index 8c25c0ad9..b1e2e556e 100644 --- a/server/test/hooks/bin.test.ts +++ b/server/test/hooks/bin.test.ts @@ -17,6 +17,7 @@ const pax = {}; const persona = {}; const sardine = {}; const segment = { close: vi.fn<() => Promise>() }; +const credit = { close: vi.fn<() => Promise>() }; const allow = { close: vi.fn<() => Promise>() }; const poke = { close: vi.fn<() => Promise>() }; const refund = { close: vi.fn<() => Promise>() }; @@ -25,6 +26,7 @@ const mocks = { alchemy: vi.fn<(key: string) => object>(), allow: vi.fn<(bullmq: object) => typeof allow>(), bridge: vi.fn<(key: string, url: string) => object>(), + credit: vi.fn<(bullmq: object) => typeof credit>(), drizzle: vi.fn<() => typeof database>(), hook: vi.fn<(config: Record) => Handle>(), manteca: vi.fn<(key: string, url: string) => object>(), @@ -51,6 +53,7 @@ beforeEach(() => { mocks.alchemy.mockReset().mockReturnValue(alchemy); mocks.allow.mockReset().mockReturnValue(allow); mocks.bridge.mockReset().mockReturnValue(bridge); + mocks.credit.mockReset().mockReturnValue(credit); mocks.drizzle.mockReset().mockReturnValue(database); mocks.hook.mockReset().mockReturnValue({ app: new Hono().get("/", (c) => c.json({ status: "ok" })), @@ -101,6 +104,7 @@ beforeEach(() => { vi.doMock("../../utils/secret", () => ({ default: mocks.secret })); vi.doMock("../../utils/segment", () => ({ default: mocks.segment })); vi.doMock("../../utils/wallet", () => ({ signer: mocks.signer })); + vi.doMock("../../workers/credit/queue", () => ({ default: mocks.credit })); vi.doMock("../../workers/allow/queue", () => ({ default: mocks.allow })); vi.doMock("../../workers/hook/queue", () => ({ default: mocks.webhook })); vi.doMock("../../workers/poke/queue", () => ({ default: mocks.poke })); @@ -165,7 +169,19 @@ describe("hook bin", () => { }, { accounts: ["issuer", "settler"], - config: { database, issuer, onesignal, panda, refund, sardine, segment, settler: account, webhook }, + config: { + credit, + database, + issuer, + onesignal, + panda, + persona, + refund, + sardine, + segment, + settler: account, + webhook, + }, load: () => import("../../hooks/bin/panda"), name: "panda", secrets: [ @@ -173,6 +189,8 @@ describe("hook bin", () => { "panda-onesignal-api-key", "panda-panda-api-key", "panda-api-url", + "panda-persona-api-key", + "persona-api-url", "redis-url", "panda-sardine-api-key", "sardine-api-url", diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 337ec0ef9..aba3e5aeb 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -42,6 +42,7 @@ import chain, { marketAbi, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; +import { SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import ProposalType from "@exactly/common/ProposalType"; import { Address, type Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; @@ -51,6 +52,7 @@ import createPandaHook from "../../hooks/panda"; import t, { f } from "../../i18n"; import createOnesignal from "../../utils/onesignal"; import createPanda, * as Panda from "../../utils/panda"; +import createPersona from "../../utils/persona"; import publicClient from "../../utils/publicClient"; import createSardine from "../../utils/sardine"; import createSegment from "../../utils/segment"; @@ -58,10 +60,18 @@ import traceClient from "../../utils/traceClient"; import wallet from "../../utils/wallet"; import anvilClient from "../anvilClient"; +import type createCredit from "../../workers/credit/queue"; import type createHookQueue from "../../workers/hook/queue"; import type createRefund from "../../workers/refund/queue"; import type { drizzle as Drizzle } from "drizzle-orm/node-postgres"; +const credit = vi.hoisted(() => ({ + close: vi.fn["close"]>().mockResolvedValue(), + enqueue: vi.fn["enqueue"]>().mockResolvedValue(), +})); +const persona = Object.assign(createPersona("persona", "https://persona.test"), { + getAccount: vi.fn().mockResolvedValue(null), +}); const refund = vi.hoisted(() => ({ close: vi.fn["close"]>().mockResolvedValue(), enqueue: vi.fn["enqueue"]>(), @@ -76,10 +86,12 @@ const sardineConfig = { key: "sardine", url: "https://api.sardine.ai" }; const issuer = privateKeyToAccount(padHex("0x420")); const owner = createWalletClient({ chain, transport: http(), account: privateKeyToAccount(generatePrivateKey()) }); const pandaHook = createPandaHook({ + credit, database, issuer, onesignal: createOnesignal("onesignal"), panda, + persona, refund, sardine: createSardine(sardineConfig.key, sardineConfig.url), segment: createSegment("segment"), @@ -3343,6 +3355,357 @@ describe("concurrency", () => { }); describe("webhooks", () => { + it.each([ + { name: "missing status", body: { id: "company-missing-status" } }, + { name: "pending status", body: { id: "company-pending", applicationStatus: "pending" } }, + { name: "not started status", body: { id: "company-not-started", applicationStatus: "notStarted" } }, + ] satisfies { body: { applicationStatus?: "notStarted" | "pending"; id: string }; name: string }[])( + "ignores company.updated with $name", + async ({ body }) => { + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { id: `ignored-${body.id}`, resource: "company", action: "updated", body }, + }); + + expect(response.status).toBe(200); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + }, + ); + + it("rejects company.updated with an invalid application status", async () => { + const response = await app.request("/", { + body: JSON.stringify({ + id: "company-invalid-status", + resource: "company", + action: "updated", + body: { id: "company-invalid-status", applicationStatus: "approvedLater" }, + }), + headers: { "content-type": "application/json", signature: "panda-signature" }, + method: "POST", + }); + + expect(response.status).toBe(400); + }); + + it("adopts the company user and issues the card for an approved company", async () => { + const credentialId = "business-hook"; + const companyId = "business-company"; + const businessAccount = parse(Address, padHex("0xb051", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + salt: padHex("0x42", { size: 20 }), + }); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([ + { id: "other-business-user", walletAddress: zeroAddress }, + { id: "business-user", walletAddress: businessAccount }, + ]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-card", + userId: "business-user", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, credentialId) }); + const card = await database.query.cards.findFirst({ where: eq(cards.id, "business-card") }); + expect(response.status).toBe(200); + expect(getCompanyUsers).toHaveBeenCalledExactlyOnceWith(companyId); + expect(createCard).toHaveBeenCalledExactlyOnceWith("business-user", SIGNATURE_PRODUCT_ID, { + amount: undefined, + idempotencyKey: `business-approval:${credentialId}:0`, + }); + expect(card).toMatchObject({ + id: "business-card", + credentialId, + lastFour: "1234", + productId: SIGNATURE_PRODUCT_ID, + }); + expect(credential).toMatchObject({ pandaId: "business-user" }); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + businessAccount, + `business-approval:${credentialId}:business-card`, + ); + expect(hookQueue.enqueue).not.toHaveBeenCalled(); + } finally { + await database.delete(cards).where(eq(cards.id, "business-card")); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("returns a retryable error when the company user is unavailable", async () => { + const credentialId = "business-unavailable"; + const companyId = "business-company-unavailable"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xb052", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + salt: padHex("0x45", { size: 20 }), + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([]); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-user-unavailable", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, credentialId) }); + expect(response.status).toBe(500); + expect(credential?.pandaId).toBeNull(); + } finally { + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("retries when the stored company user is missing from the company users", async () => { + const credentialId = "business-stale-user"; + const companyId = "business-company-stale-user"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xb055", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + pandaId: "stale-business-user", + salt: padHex("0x56", { size: 20 }), + }); + + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([]); + const createCard = vi.spyOn(panda, "createCard"); + const enqueue = vi.spyOn(credit, "enqueue"); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved-stale-user", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(500); + expect(createCard).not.toHaveBeenCalled(); + expect(enqueue).not.toHaveBeenCalled(); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("reuses the company user and local card for repeated approvals", async () => { + const credentialId = "business-repeated"; + const companyId = "business-company-repeated"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xb053", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + pandaId: "business-user", + salt: padHex("0x43", { size: 20 }), + }); + await database.insert(cards).values({ id: "business-repeated-card", credentialId, lastFour: "1234" }); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user" }]); + const createCard = vi.spyOn(panda, "createCard"); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved-repeated", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + expect(getCompanyUsers).toHaveBeenCalledExactlyOnceWith(companyId); + expect(createCard).not.toHaveBeenCalled(); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("rotates the idempotency key when reissuing after a deleted card", async () => { + const credentialId = "business-reissue"; + const companyId = "business-company-reissue"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xb054", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + pandaId: "business-user", + salt: padHex("0x44", { size: 20 }), + }); + await database + .insert(cards) + .values({ id: "business-reissue-card", credentialId, lastFour: "1234", status: "DELETED" }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user" }]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-reissue-card-2", + userId: "business-user", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "5678", + expirationMonth: "12", + expirationYear: "2030", + }); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved-reissue", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + expect(createCard).toHaveBeenCalledExactlyOnceWith("business-user", SIGNATURE_PRODUCT_ID, { + amount: undefined, + idempotencyKey: `business-approval:${credentialId}:1`, + }); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("runs integrations before awaiting credit for an approved company", async () => { + const credentialId = "business-hook-reconcile"; + const companyId = "business-company-reconcile"; + const businessAccount = parse(Address, padHex("0xb055", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + pandaCompanyId: companyId, + salt: padHex("0x46", { size: 20 }), + }); + const getCompanyUsers = vi + .spyOn(panda, "getCompanyUsers") + .mockResolvedValue([{ id: "business-user-reconcile", walletAddress: businessAccount }]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-reconcile-card", + userId: "business-user-reconcile", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + const customer = vi.spyOn(sardine, "customer").mockResolvedValue({ + status: "Success", + level: "low", + sessionKey: "mock-session-key", + }); + const track = vi.spyOn(segment, "track").mockReturnValue(); + const payload = { + header: { signature: "panda-signature" }, + json: { + id: "company-approved-reconcile", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + } as const; + + try { + credit.enqueue.mockRejectedValueOnce(new Error("redis unavailable")); + const failed = await appClient.index.$post(payload); + expect(failed.status).toBe(500); + expect(track).toHaveBeenCalledTimes(1); + expect(customer).toHaveBeenCalledTimes(1); + expect(credit.enqueue).toHaveBeenCalledTimes(1); + + const response = await appClient.index.$post(payload); + expect(response.status).toBe(200); + expect(getCompanyUsers).toHaveBeenCalledTimes(2); + expect(createCard).toHaveBeenCalledTimes(1); + expect(customer).toHaveBeenCalledExactlyOnceWith({ + flow: { name: "card.issued", type: "payment_method_link" }, + customer: { id: credentialId, type: "customer" }, + transaction: { + id: "business-reconcile-card", + paymentMethod: { + type: "card", + card: { hash: "business-reconcile-card", last4: "1234", expiryMonth: "12", expiryYear: "2030" }, + }, + }, + }); + expect(credit.enqueue).toHaveBeenCalledTimes(2); + expect(credit.enqueue).toHaveBeenNthCalledWith( + 1, + businessAccount, + `business-approval:${credentialId}:business-reconcile-card`, + ); + expect(credit.enqueue).toHaveBeenNthCalledWith( + 2, + businessAccount, + `business-approval:${credentialId}:business-reconcile-card`, + ); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("acknowledges individual application webhooks without company provisioning", async () => { + const createCard = vi.spyOn(panda, "createCard"); + + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "application-pending", + resource: "application", + action: "updated", + body: { id: "business-company-application" }, + }, + }); + + expect(response.status).toBe(200); + expect(createCard).not.toHaveBeenCalled(); + }); + it("enqueues declined transaction webhooks", async () => { const response = await appClient.index.$post({ ...authorization, diff --git a/server/test/mocks/panda.ts b/server/test/mocks/panda.ts index 9f60e9484..a7323d0e2 100644 --- a/server/test/mocks/panda.ts +++ b/server/test/mocks/panda.ts @@ -27,6 +27,8 @@ const mock = vi.hoisted(() => { current().getApplicationStatus(...parameters), getCard: (...parameters: Parameters) => current().getCard(...parameters), getCards: (...parameters: Parameters) => current().getCards(...parameters), + getCompanyUsers: (...parameters: Parameters) => + current().getCompanyUsers(...parameters), getCompanyStatus: (...parameters: Parameters) => current().getCompanyStatus(...parameters), getNonce: (...parameters: Parameters) => current().getNonce(...parameters), diff --git a/server/test/utils/panda.test.ts b/server/test/utils/panda.test.ts index 0dae3eaa1..da00bbd30 100644 --- a/server/test/utils/panda.test.ts +++ b/server/test/utils/panda.test.ts @@ -1,5 +1,7 @@ import "../mocks/sentry"; +import { Hono } from "hono"; +import { createHmac } from "node:crypto"; import { parse } from "valibot"; import { padHex } from "viem"; import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; @@ -72,6 +74,49 @@ describe("panda request", () => { expect.objectContaining({ method: "GET" }), ); }); + + it("lists company users through the parent tenant", async () => { + const users = [{ id: "user-id", walletAddress: "0x1234" }]; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(Response.json(users)); + + await expect(panda.getCompanyUsers("company-id")).resolves.toStrictEqual(users); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/issuing/users?companyId=company-id"), + expect.objectContaining({ method: "GET" }), + ); + }); +}); + +describe("panda webhook signature", () => { + const payload = "payload"; + const primary = createPanda({ key: "primary", url: "https://panda.test" }); + const primaryApp = new Hono().post("/", primary.headerValidator, (c) => c.text("ok")); + + it("accepts the primary signature", async () => { + const response = await primaryApp.request("/", { + method: "POST", + headers: { signature: createHmac("sha256", "primary").update(payload).digest("hex") }, + body: payload, + }); + + expect(response.status).toBe(200); + }); + + it("rejects a missing signature", async () => { + const response = await primaryApp.request("/", { method: "POST" }); + + expect(response.status).toBe(400); + }); + + it("rejects an invalid signature", async () => { + const response = await primaryApp.request("/", { + method: "POST", + headers: { signature: createHmac("sha256", "invalid").update(payload).digest("hex") }, + body: payload, + }); + + expect(response.status).toBe(401); + }); }); describe("business application", () => { @@ -321,6 +366,26 @@ describe("create card", () => { ); }); + it("sends an idempotency key and custom limit", async () => { + chainMock.id = baseSepolia.id; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(Response.json(card)); + + await panda.createCard("user-id", SIGNATURE_PRODUCT_ID, { amount: 123, idempotencyKey: "approval-key" }); + expect(fetchSpy).toHaveBeenLastCalledWith( + expect.stringContaining("/issuing/users/user-id/cards"), + expect.objectContaining({ + body: JSON.stringify({ + type: "virtual", + status: "active", + limit: { amount: 123, frequency: "per7DayPeriod" }, + configuration: { productId: SIGNATURE_PRODUCT_ID, virtualCardArt: "0c515d7eb0a140fa8f938f8242b0780a" }, + }), + }), + ); + const [, init] = fetchSpy.mock.lastCall ?? []; + expect(init?.headers).toMatchObject({ "Idempotency-Key": "approval-key" }); + }); + it("sends sandbox card art on optimism sepolia", async () => { chainMock.id = optimismSepolia.id; const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 2681bddea..aa95a0393 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -1,6 +1,7 @@ import { vValidator } from "@hono/valibot-validator"; -import { setContext } from "@sentry/node"; +import { captureException, setContext } from "@sentry/node"; import { Mutex, withTimeout, type MutexInterface } from "async-mutex"; +import { and, eq, isNull } from "drizzle-orm"; import { array, boolean, @@ -42,6 +43,7 @@ import { type BaseIssue, type BaseSchema, type InferInput, + type InferOutput, } from "valibot"; import { recoverTypedDataAddress, type LocalAccount } from "viem"; import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; @@ -51,17 +53,25 @@ import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exa import { Address, Hex } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; +import { isBusinessSalt } from "./createCredential"; import { PANDA_BUSINESS_TEMPLATE } from "./persona"; import ServiceError from "./ServiceError"; import verifySignature from "./verifySignature"; +import { cards, credentials } from "../database/schema"; import type createPersona from "./persona"; +import type createSardine from "./sardine"; +import type createSegment from "./segment"; +import type * as schema from "../database/schema"; +import type createCredit from "../workers/credit/queue"; +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; export default function panda({ key, url }: { key: string; url: string }) { return { createCard, createCompanyApplication, createUser, getApplicationStatus, + getCompanyUsers, getCompanyStatus, getCard, getCards, @@ -86,12 +96,12 @@ export default function panda({ key, url }: { key: string; url: string }) { async function createCard( userId: string, productId: typeof BASE_PRODUCT_ID | typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID, - amount = 1_000_000, + { amount = 1_000_000, idempotencyKey }: { amount?: number; idempotencyKey?: string } = {}, ) { return await request( CardResponse, `/issuing/users/${userId}/cards`, - {}, + idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}, parse(CreateCardRequest, { type: "virtual", status: "active", @@ -136,6 +146,16 @@ export default function panda({ key, url }: { key: string; url: string }) { 10_000, ); } + function getCompanyUsers(companyId: string) { + return request( + array(object({ id: string(), companyId: optional(string()), walletAddress: optional(string()) })), + `/issuing/users?companyId=${companyId}`, + {}, + undefined, + "GET", + 10_000, + ); + } async function getCompanyStatus(companyId: string) { const application = await request( CompanyApplicationStatusResponse, @@ -553,9 +573,33 @@ const Card = variant("action", [ }), ]); +export const kycStatus = [ + "needsVerification", + "needsInformation", + "manualReview", + "notStarted", + "approved", + "canceled", + "pending", + "denied", + "locked", +] as const; + export const Payload = variant("resource", [ Transaction, Card, + object({ + resource: literal("company"), + action: literal("updated"), + body: looseObject({ id: string(), applicationStatus: optional(nullable(picklist(kycStatus))) }), + id: string(), + }), + object({ + resource: literal("application"), + action: string(), + body: looseObject({ id: string() }), + id: string(), + }), object({ resource: literal("dispute"), action: string(), @@ -644,6 +688,8 @@ const CardResponse = object({ expirationYear: pipe(string(), length(4)), }); +type PandaCard = InferOutput; + const CardsResponse = array( object({ id: string(), @@ -819,6 +865,127 @@ async function businessApplication( } const mutexes = new Map(); +export const activeCardStatuses: (typeof cards.$inferSelect.status)[] = ["ACTIVE", "FROZEN"]; + +export async function finalizeBusinessApproval( + credentialId: string, + companyId: string, + account: Address, + database: NodePgDatabase, + client: ReturnType, + { + credit, + persona, + sardine, + segment, + }: { + credit: ReturnType; + persona: ReturnType; + sardine: ReturnType; + segment: ReturnType; + }, +) { + const row = await database.query.credentials.findFirst({ + columns: { pandaCompanyId: true, pandaId: true, salt: true, source: true }, + where: eq(credentials.id, credentialId), + }); + if (row?.pandaCompanyId !== companyId || !isBusinessSalt(parse(Address, row.salt))) return; + const existingCards = await database.query.cards.findMany({ + columns: { id: true, status: true }, + where: eq(cards.credentialId, credentialId), + }); + const localCard = existingCards.find(({ status }) => activeCardStatuses.includes(status)); + const users = await client.getCompanyUsers(companyId); + if (row.pandaId && !users.some(({ id }) => id === row.pandaId)) throw new Error("company user not found"); + let userId: null | string | undefined = row.pandaId; + if (!userId) { + const user = users.find(({ walletAddress }) => walletAddress?.toLowerCase() === account.toLowerCase()); + if (!user) throw new Error("company user not found"); + const [updated] = await database + .update(credentials) + .set({ pandaId: user.id }) + .where( + and(eq(credentials.id, credentialId), eq(credentials.pandaCompanyId, companyId), isNull(credentials.pandaId)), + ) + .returning({ pandaId: credentials.pandaId }); + userId = + updated?.pandaId ?? + (await database.query.credentials + .findFirst({ columns: { pandaId: true }, where: eq(credentials.id, credentialId) }) + .then((current) => current?.pandaId)); + } + if (!userId) return; + if (localCard) { + await credit.enqueue(account, `business-approval:${credentialId}:${localCard.id}`); + return; + } + const card = await client.createCard(userId, SIGNATURE_PRODUCT_ID, { + amount: await cardLimit(credentialId, persona).catch((error: unknown) => { + captureException(error, { + level: "error", + contexts: { details: { credentialId, scope: "cardLimit" } }, + }); + throw error; + }), + idempotencyKey: `business-approval:${credentialId}:${existingCards.filter(({ status }) => status === "DELETED").length}`, + }); + const [inserted] = await database + .insert(cards) + .values({ id: card.id, lastFour: card.last4, credentialId, productId: SIGNATURE_PRODUCT_ID }) + .onConflictDoNothing() + .returning({ id: cards.id }); + if (!inserted) { + const existing = await database.query.cards.findFirst({ columns: { id: true }, where: eq(cards.id, card.id) }); + if (!existing) return; + await credit.enqueue(account, `business-approval:${credentialId}:${existing.id}`); + return; + } + segment.track({ + event: "CardIssued", + userId: account, + properties: { productId: SIGNATURE_PRODUCT_ID, source: row.source }, + }); + notifyCardIssued(sardine, { credentialId, card }); + await credit.enqueue(account, `business-approval:${credentialId}:${card.id}`); +} + +export function cardLimit(credentialId: string, persona: ReturnType) { + return persona + .getAccount(credentialId, "cardLimit") + .then((profile) => + profile?.attributes.fields.card_limit_usd?.value == null + ? undefined + : profile.attributes.fields.card_limit_usd.value * 100, + ); +} + +export function notifyCardIssued( + sardine: ReturnType, + { + card, + credentialId, + }: { card: Pick; credentialId: string }, +) { + sardine + .customer({ + flow: { name: "card.issued", type: "payment_method_link" }, + customer: { id: credentialId, type: "customer" }, + transaction: { + id: card.id, + paymentMethod: { + type: "card", + card: { + hash: card.id, + last4: card.last4, + expiryMonth: card.expirationMonth, + expiryYear: card.expirationYear, + }, + }, + }, + }) + .catch((error: unknown) => captureException(error, { level: "error" })); +} + export function createMutex(address: Address) { const mutex = withTimeout( new Mutex(), @@ -1063,18 +1230,6 @@ const ApplicationResponse = object({ applicationStatus: pipe(string(), maxLength(50)), }); -export const kycStatus = [ - "needsVerification", - "needsInformation", - "manualReview", - "notStarted", - "approved", - "canceled", - "pending", - "denied", - "locked", -] as const; - const ApplicationStatusResponse = object({ id: string(), applicationStatus: picklist(kycStatus), diff --git a/server/workers/hook/worker.ts b/server/workers/hook/worker.ts index 8358b5211..b676ff77f 100644 --- a/server/workers/hook/worker.ts +++ b/server/workers/hook/worker.ts @@ -50,7 +50,8 @@ export default function worker({ } const { requestBody: payload } = await panda.getWebhook(id); if (payload.resource === "transaction" && payload.action === "requested") return; - if (payload.resource === "dispute") return; + if (payload.resource === "application" || payload.resource === "company" || payload.resource === "dispute") + return; if (payload.resource === "card" && payload.action === "notification") return; const user = await database.query.credentials.findFirst({ columns: { account: true, id: true, source: true },