From 8b03ec66629f9fc16f1eee688f5ca141e0c4f37d Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Thu, 13 Aug 2026 10:44:11 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20server:=20process=20business=20onbo?= =?UTF-8?q?arding=20approvals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/brown-heads-vanish.md | 5 + infra/utils/modules.ts | 11 +- server/api/card.ts | 143 ++++-- server/api/kyc.ts | 22 +- server/hooks/bin/panda.ts | 54 ++- server/hooks/panda.ts | 134 +++++- server/index.ts | 3 + server/test/api/card.test.ts | 462 ++++++++++++++++++- server/test/api/kyc.test.ts | 137 +++++- server/test/hooks/bin.test.ts | 21 +- server/test/hooks/panda.test.ts | 747 ++++++++++++++++++++++++++++++- server/test/mocks/panda.ts | 3 + server/test/utils/panda.test.ts | 119 ++++- server/utils/panda.ts | 121 ++++- server/workers/hook/worker.ts | 3 +- 15 files changed, 1882 insertions(+), 103 deletions(-) create mode 100644 .changeset/brown-heads-vanish.md diff --git a/.changeset/brown-heads-vanish.md b/.changeset/brown-heads-vanish.md new file mode 100644 index 0000000000..348d4e68a8 --- /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 4142ba6d7d..935ce4fb34 100644 --- a/infra/utils/modules.ts +++ b/infra/utils/modules.ts @@ -41,8 +41,15 @@ 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"], + 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 a21b2c9f7f..d5a9e09599 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -30,6 +30,7 @@ import { type InferInput, type InferOutput, } from "valibot"; +import { getAddress, zeroAddress } from "viem"; import { base } from "viem/chains"; import { createSiweMessage, parseSiweMessage, verifySiweMessage } from "viem/siwe"; @@ -40,6 +41,15 @@ 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 { + activeStatuses, + cardLimit, + createMutex as createAccountMutex, + deleteMutex as deleteAccountMutex, + getMutex, + issuanceKey, + markCardLock, +} from "../utils/panda"; import publicClient from "../utils/publicClient"; import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; @@ -176,6 +186,32 @@ export default function route({ mutexes.set(credentialId, mutex); return mutex; } + async function cardMutex(credentialId: string) { + const account = await database.query.credentials + .findFirst({ columns: { account: true, salt: true }, where: eq(credentials.id, credentialId) }) + .then((row) => { + if (!row) return; + return getAddress(row.salt) === zeroAddress ? undefined : parse(Address, row.account); + }); + const mutex = account + ? (getMutex(account) ?? createAccountMutex(account)) + : (mutexes.get(credentialId) ?? createMutex(credentialId)); + const unlock = await mutex.acquire(); + if (account) markCardLock(account, true); + return { + release: () => { + unlock(); + if (account) markCardLock(account, false); + const clear = () => { + if (mutex.isLocked()) return; + if (account) deleteAccountMutex(account); + else mutexes.delete(credentialId); + }; + if (mutex.isLocked()) mutex.waitForUnlock().then(clear, clear); + else clear(); + }, + }; + } return new Hono() .get( "/", @@ -555,12 +591,12 @@ 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); - return mutex - .runExclusive(async () => { + const { release } = await cardMutex(credentialId); + try { + return await (async () => { const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, credentialId), - columns: { account: true, pandaId: true, source: true }, + columns: { account: true, pandaId: true, salt: true, source: true }, with: { cards: { columns: { id: true, status: true, productId: true }, @@ -570,6 +606,7 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` }); if (!credential) return c.json({ code: "no credential" }, 500); const account = parse(Address, credential.account); + const isBusiness = getAddress(credential.salt) !== zeroAddress; setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); @@ -579,7 +616,7 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` ({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID, ); - const activeCards = credential.cards.filter(({ status }) => status === "ACTIVE" || status === "FROZEN"); + const activeCards = credential.cards.filter(({ status }) => activeStatuses.includes(status)); let cardCount = activeCards.length; for (const card of activeCards) { @@ -601,15 +638,20 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` } if (cardCount > 0) return c.json({ code: "already created" }, 400); try { - const kyc = await panda.getApplicationStatus(pandaId); + const kyc = isBusiness + ? await panda.getCompanyApplication(credentialId) + : 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 (isBusiness) { + const users = await panda.getCompanyUsers(kyc.id); + 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 - ? SIGNATURE_PRODUCT_ID - : BASE_PRODUCT_ID + chain.id === base.id && !isBusiness && credential.source !== "5lu2sNu0v0ZElC2m77QR3rAZBHLr8PoG" // cspell:ignore azbh + ? BASE_PRODUCT_ID : SIGNATURE_PRODUCT_ID; const card = await panda .getCards(pandaId) @@ -627,34 +669,33 @@ 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 (isBusiness) throw error; + captureException(error, { + level: "error", + contexts: { details: { credentialId, scope: "cardLimit" } }, + }); + }), + ...(isBusiness && { + idempotencyKey: issuanceKey(credentialId, credential.cards, 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 }, - }), - ); + const [inserted] = await database + .insert(cards) + .values([{ id: card.id, credentialId, lastFour: card.last4, productId }]) + .onConflictDoNothing() + .returning({ id: cards.id }); + if (!inserted) + return c.json( + { lastFour: card.last4, status: "ACTIVE", cardId: card.id, productId } satisfies InferOutput< + typeof CreatedCardResponse + >, + 200, + ); segment.track({ event: "CardIssued", userId: account, @@ -682,6 +723,16 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` }) .catch((error: unknown) => captureException(error, { level: "error" })); + await (isBusiness + ? 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( { lastFour: card.last4, @@ -729,10 +780,10 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` } return c.json({ code: "no panda" }, 403); } - }) - .finally(() => { - if (!mutex.isLocked()) mutexes.delete(credentialId); - }); + })(); + } finally { + release(); + } }, ) .patch( @@ -839,9 +890,9 @@ async function encryptPIN(pin: string) { async (c) => { const patch = c.req.valid("json"); const { credentialId } = c.req.valid("cookie"); - const mutex = mutexes.get(credentialId) ?? createMutex(credentialId); - return mutex - .runExclusive(async () => { + const { release } = await cardMutex(credentialId); + try { + return await (async () => { const credential = await database.query.credentials.findFirst({ columns: { account: true, @@ -969,10 +1020,10 @@ async function encryptPIN(pin: string) { } } } - }) - .finally(() => { - if (!mutex.isLocked()) mutexes.delete(credentialId); - }); + })(); + } finally { + release(); + } }, ); } diff --git a/server/api/kyc.ts b/server/api/kyc.ts index ff1396b0f3..c85195d456 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"; @@ -33,9 +33,10 @@ 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 decodePublicKey from "../utils/decodePublicKey"; import { + activeStatuses, Application, ApplicationLink, UpdateApplicationRequest as ApplicationUpdate, @@ -590,7 +591,13 @@ 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, activeStatuses)), + }); + if (existing) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + } const application = (await panda.getCompanyApplication(credentialId)) ?? (await panda.createCompanyApplication( @@ -713,6 +720,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c schema: resolver( union([ buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ code: literal("not supported") }), object({ ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, legacy: optional(pipe(string(), metadata({ examples: [BadRequestCodes.BAD_REQUEST] }))), @@ -732,12 +740,15 @@ The admin should add a member using [addMember method](https://www.better-auth.c const { credentialId } = c.req.valid("cookie"); const payload = c.req.valid("json"); const credential = await database.query.credentials.findFirst({ - columns: { id: true, account: true, pandaId: true }, + columns: { id: true, account: true, pandaId: true, salt: true }, where: eq(credentials.id, credentialId), }); if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); + if (getAddress(credential.salt) !== zeroAddress) { + return c.json({ code: "not supported" }, 400); + } if (!credential.pandaId) { return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); } @@ -794,7 +805,8 @@ The admin should add a member using [addMember method](https://www.better-auth.c where: eq(credentials.id, credentialId), }); if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); - setUser({ id: parse(Address, credential.account) }); + const account = parse(Address, credential.account); + setUser({ id: account }); setContext("exa", { credential }); if (getAddress(credential.salt) !== zeroAddress) { const application = await panda.getCompanyApplication(credentialId); diff --git a/server/hooks/bin/panda.ts b/server/hooks/bin/panda.ts index 639cd0c75d..486b4caaae 100644 --- a/server/hooks/bin/panda.ts +++ b/server/hooks/bin/panda.ts @@ -6,10 +6,13 @@ 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 createAllow from "../../workers/allow/queue"; +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 +30,53 @@ 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, createAllow(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, allow, credit, refund, webhook], + sardine, + segment, + settler, + ]) => + own( + panda({ + allow, + credit, + database, + issuer, + onesignal, + panda: provider, + persona, + refund, + sardine, + segment, + settler, + webhook, + }), + () => database.$client.end(), + () => kms.close(), + () => secrets.close(), + () => segment.close(), + () => + Promise.all([allow.close(), credit.close(), refund.close(), webhook.close()]).finally(() => bullmq.quit()), + ), ), ); diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 1ff091c218..36d83791f4 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -12,33 +12,37 @@ import { } from "@sentry/node"; import { E_TIMEOUT } from "async-mutex"; import createDebug from "debug"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { Hono } from "hono"; import * as v from "valibot"; import { BaseError, + bytesToHex, ContractFunctionRevertedError, decodeEventLog, encodeAbiParameters, encodeEventTopics, encodeFunctionData, erc20Abi, + getAddress, getContractError, keccak256, maxUint256, padHex, RawContractError, toBytes, + zeroAddress, zeroHash, type LocalAccount, } from "viem"; -import { +import chain, { auditorAbi, exaPluginAbi, exaPluginAddress, exaPreviewerAbi, exaPreviewerAddress, + firewallAddress, issuerCheckerAbi, marketAbi, proposalManagerAbi, @@ -46,6 +50,7 @@ import { usdcAddress, } from "@exactly/common/generated/chain"; import MIN_BORROW_INTERVAL from "@exactly/common/MIN_BORROW_INTERVAL"; +import { SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import revertReason from "@exactly/common/revertReason"; import { Address, type Hash, type Hex } from "@exactly/common/validation"; import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib"; @@ -53,13 +58,18 @@ import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib"; import { cards, credentials, transactions } from "../database/schema"; import t, { f } from "../i18n"; import { + activeStatuses, + cardLimit, collectors, createMutex, declineMessage, getMutex, + isCardLocked, + issuanceKey, Payload, signIssuerOp, TransactionPayload, + withMutex, type Transaction, } from "../utils/panda"; import publicClient from "../utils/publicClient"; @@ -73,8 +83,11 @@ 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 createAllow from "../workers/allow/queue"; +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"; @@ -84,20 +97,26 @@ const debug = createDebug("exa:panda"); Object.assign(debug, { inspectOpts: { depth: undefined } }); export default function hook({ + allow, + credit, database, issuer, onesignal, panda, + persona, refund, sardine, segment, settler, webhook, }: { + allow: ReturnType; + credit: ReturnType; database: Database; issuer: LocalAccount; onesignal: ReturnType; panda: ReturnType; + persona: ReturnType; refund: ReturnType; sardine: ReturnType; segment: ReturnType; @@ -119,6 +138,112 @@ 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 company = await panda.getCompany(payload.body.id); + if (!company?.externalId) return c.json({ code: "ok" }); + const credential = await database.query.credentials.findFirst({ + columns: { account: true, factory: true, id: true, publicKey: true, salt: true }, + where: eq(credentials.id, company.externalId), + }); + if (!credential) return c.json({ code: "retry" }, 500); + const salt = v.parse(Address, credential.salt); + if (getAddress(salt) !== zeroAddress) { + const account = v.parse(Address, credential.account); + setUser({ id: account }); + await withMutex(account, async () => { + const row = await database.query.credentials.findFirst({ + columns: { pandaId: true, source: true }, + where: eq(credentials.id, credential.id), + }); + if (!row) return; + const existingCards = await database.query.cards.findMany({ + columns: { id: true, status: true }, + where: eq(cards.credentialId, credential.id), + }); + const localCard = existingCards.find(({ status }) => activeStatuses.includes(status)); + const users = await panda.getCompanyUsers(payload.body.id); + if (row.pandaId && !users.some(({ id }) => id === row.pandaId)) throw new Error("company user not found"); + if (!row.pandaId) { + const user = users.find(({ walletAddress }) => walletAddress?.toLowerCase() === account.toLowerCase()); + if (!user) throw new Error("company user not found"); + await database + .update(credentials) + .set({ pandaId: user.id }) + .where(and(eq(credentials.id, credential.id), isNull(credentials.pandaId))); + } + const userId = + row.pandaId ?? + (await database.query.credentials + .findFirst({ columns: { pandaId: true }, where: eq(credentials.id, credential.id) }) + .then((current) => current?.pandaId)); + if (!userId) throw new Error("company user not found"); + if (firewallAddress) + await allow.enqueue({ + account, + chainId: chain.id, + factory: v.parse(Address, credential.factory), + publicKey: bytesToHex(credential.publicKey), + salt, + source: row.source, + }); + if (localCard) { + await credit.enqueue(account, `business-approval:${credential.id}:${localCard.id}`); + return; + } + const card = await panda.createCard(userId, SIGNATURE_PRODUCT_ID, { + amount: await cardLimit(credential.id, persona).catch((error: unknown) => { + captureException(error, { + level: "error", + contexts: { details: { credentialId: credential.id, scope: "cardLimit" } }, + }); + throw error; + }), + idempotencyKey: issuanceKey(credential.id, existingCards), + }); + const [inserted] = await database + .insert(cards) + .values({ + id: card.id, + lastFour: card.last4, + credentialId: credential.id, + productId: SIGNATURE_PRODUCT_ID, + }) + .onConflictDoNothing() + .returning({ id: cards.id }); + if (!inserted) { + await credit.enqueue(account, `business-approval:${credential.id}:${card.id}`); + return; + } + segment.track({ + event: "CardIssued", + userId: account, + properties: { productId: SIGNATURE_PRODUCT_ID, source: row.source }, + }); + sardine + .customer({ + flow: { name: "card.issued", type: "payment_method_link" }, + customer: { id: credential.id, 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" })); + await credit.enqueue(account, `business-approval:${credential.id}:${card.id}`); + }); + } + return c.json({ code: "ok" }); + } if (payload.resource === "dispute") return c.json({ code: "ok" }); const pandaId = payload.resource === "card" @@ -513,7 +638,7 @@ export default function hook({ ...(payload.body.spend.declinedReason && { "span.description": payload.body.spend.declinedReason }), }); const mutex = getMutex(account); - mutex?.release(); + if (!isCardLocked(account)) mutex?.release(); setContext("mutex", { locked: mutex?.isLocked() }); const provider = payload.body.spend.declinedReason === "" ? undefined : payload.body.spend.declinedReason; @@ -893,7 +1018,8 @@ export default function hook({ } } finally { const mutex = getMutex(account); - if (payload.action === "created" || payload.action === "updated") mutex?.release(); + if ((payload.action === "created" || payload.action === "updated") && !isCardLocked(account)) + mutex?.release(); setContext("mutex", { locked: mutex?.isLocked() }); } } diff --git a/server/index.ts b/server/index.ts index 92f86b4af1..6b8b6e98a0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -126,10 +126,13 @@ const mantecaHook = createMantecaHook({ segment, }); const pandaHook = createPandaHook({ + allow, + 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 3438749b56..e7937404e4 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"; @@ -35,7 +35,7 @@ import database, { cards, credentials } from "../../database"; import authenticate from "../../middleware/auth"; import createAuth from "../../utils/auth"; import authSecret from "../../utils/authSecret"; -import createPanda from "../../utils/panda"; +import createPanda, * as Panda from "../../utils/panda"; import createPax from "../../utils/pax"; import createPersona from "../../utils/persona"; import createSardine from "../../utils/sardine"; @@ -68,6 +68,7 @@ const persona = createPersona( parse(pipe(string(), nonEmpty()), env.PERSONA_URL), ); const walletExtension = createWalletExtension(WALLET_EXTENSION_SECRET); +const businessSalt = parse(Address, padHex("0x7e", { size: 20 })); const app = route({ auth: authenticate(""), credit, @@ -88,6 +89,30 @@ const app = route({ }); const appClient = testClient(app); +async function insertBusinessCredential({ + id, + account, + pandaId, +}: { + account: `0x${string}`; + id: string; + pandaId: string; +}) { + await database.insert(credentials).values({ + id, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId, + salt: businessSalt, + }); +} + +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 +571,385 @@ 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 }), + pandaId: "card-business-user", + }); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus"); + const getCompanyApplication = vi + .spyOn(panda, "getCompanyApplication") + .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(getCompanyApplication).toHaveBeenCalledExactlyOnceWith(credentialId); + 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"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x992", { size: 20 }), + pandaId: "card-business-order-user", + }); + vi.spyOn(panda, "getCompanyApplication").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: "00000000-0000-4000-8000-0000000000ad", + 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("cleans up the business mutex after card creation", async () => { + const credentialId = "card-business-mutex-cleanup"; + const account = padHex("0x990", { size: 20 }); + await insertBusinessCredential({ + id: credentialId, + account, + pandaId: "card-business-mutex-user", + }); + vi.spyOn(panda, "getCompanyApplication").mockResolvedValueOnce({ + id: "card-business-mutex-company", + applicationStatus: "approved", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValueOnce([ + { id: "card-business-mutex-user", walletAddress: account }, + ]); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "00000000-0000-4000-8000-000000000001", + userId: "card-business-mutex-user", + }); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(Panda.getMutex(parse(Address, account))).toBeUndefined(); + expect(Panda.isCardLocked(parse(Address, account))).toBe(false); + } finally { + Panda.getMutex(parse(Address, account))?.release(); + await removeBusinessCredential(credentialId); + } + }); + + it("queues a second business card request behind the account mutex", async () => { + const credentialId = "card-business-mutex-queued"; + const account = padHex("0x991", { size: 20 }); + await insertBusinessCredential({ + id: credentialId, + account, + pandaId: "card-business-mutex-queued-user", + }); + vi.spyOn(panda, "getCompanyApplication").mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { id: "card-business-mutex-queued-company", applicationStatus: "approved" }; + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([ + { id: "card-business-mutex-queued-user", walletAddress: account }, + ]); + vi.spyOn(panda, "createCard").mockResolvedValue({ + ...cardTemplate, + id: "00000000-0000-4000-8000-000000000002", + userId: "card-business-mutex-queued-user", + }); + + try { + const first = appClient.index.$post({ header: { "test-credential-id": credentialId } }); + await vi.waitUntil(() => vi.mocked(panda.getCompanyApplication).mock.calls.length === 1, 26_666); + expect(Panda.isCardLocked(parse(Address, account))).toBe(true); + const second = appClient.index.$post({ header: { "test-credential-id": credentialId } }); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(500); + expect(panda.getCompanyApplication).toHaveBeenCalledTimes(1); + expect(panda.createCard).toHaveBeenCalledTimes(1); + await vi.waitUntil(() => Panda.getMutex(parse(Address, account)) === undefined, 26_666); + } finally { + Panda.getMutex(parse(Address, account))?.release(); + await removeBusinessCredential(credentialId); + } + }); + + it("queues a second card request behind the mutex", async () => { + const credentialId = "card-mutex-queued"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0x986", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: credentialId, + }); + vi.spyOn(panda, "getApplicationStatus").mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { id: credentialId, applicationStatus: "approved" }; + }); + vi.spyOn(panda, "getCard").mockResolvedValue(cardTemplate); + vi.spyOn(panda, "getCards").mockResolvedValue([]); + vi.spyOn(panda, "createCard").mockResolvedValue({ + ...cardTemplate, + id: "00000000-0000-4000-8000-0000000000b2", + userId: credentialId, + }); + + try { + const first = appClient.index.$post({ header: { "test-credential-id": credentialId } }); + await vi.waitUntil(() => vi.mocked(panda.getApplicationStatus).mock.calls.length === 1, 26_666); + let settled = false; + const second = appClient.index.$post({ header: { "test-credential-id": credentialId } }).then((response) => { + settled = true; + return response; + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(settled).toBe(false); + + await Promise.all([first, second]); + expect(settled).toBe(true); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("recovers the persisted card when the approval inserts it first", async () => { + const credentialId = "card-business-insert-race"; + const cardId = "00000000-0000-4000-8000-0000000000ae"; + const account = padHex("0x993", { size: 20 }); + await insertBusinessCredential({ id: credentialId, account, pandaId: "card-business-race-user" }); + vi.spyOn(panda, "getCompanyApplication").mockResolvedValueOnce({ + id: "card-business-race-company", + applicationStatus: "approved", + }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValueOnce([ + { id: "card-business-race-user", walletAddress: account }, + ]); + vi.spyOn(panda, "createCard").mockImplementationOnce(async (userId) => { + await database + .insert(cards) + .values({ id: cardId, credentialId, lastFour: "4242", productId: SIGNATURE_PRODUCT_ID }); + return { ...cardTemplate, id: cardId, last4: "4242", userId }; + }); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + status: "ACTIVE", + lastFour: "4242", + cardId, + productId: SIGNATURE_PRODUCT_ID, + }); + expect(credit.enqueue).not.toHaveBeenCalled(); + expect(track).not.toHaveBeenCalled(); + expect(sardineCustomer).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + await expect( + database.query.cards.findMany({ where: eq(cards.credentialId, credentialId) }), + ).resolves.toHaveLength(1); + } finally { + await removeBusinessCredential(credentialId); + } + }); + + it("does not finalize an approved business application when an active card exists", async () => { + const credentialId = "card-business-existing"; + const cardId = "00000000-0000-4000-8000-000000000002"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x991", { size: 20 }), + 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); + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue({ + id: "card-business-existing-company", + applicationStatus: "approved", + }); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + + try { + 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(getCompanyApplication).not.toHaveBeenCalled(); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + expect(credit.enqueue).not.toHaveBeenCalled(); + } 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 }), + 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, "getCompanyApplication").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 }), + pandaId: "card-business-limit-user", + }); + vi.spyOn(panda, "getCompanyApplication").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("returns 403 no panda when the business has no company application", async () => { + const credentialId = "card-business-no-application"; + await insertBusinessCredential({ + id: credentialId, + account: padHex("0x993", { size: 20 }), + pandaId: "card-business-no-application-user", + }); + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus"); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + + try { + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(getCompanyApplication).toHaveBeenCalledExactlyOnceWith(credentialId); + expect(getApplicationStatus).not.toHaveBeenCalled(); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + expect(credit.enqueue).not.toHaveBeenCalled(); + expect(track).not.toHaveBeenCalled(); + expect(sardineCustomer).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + await expect( + database.query.cards.findMany({ where: eq(cards.credentialId, credentialId) }), + ).resolves.toStrictEqual([]); + } finally { + await removeBusinessCredential(credentialId); + } + }); + it("throws when createCard fails with empty-body 403", async () => { const credentialId = "not-approved-empty"; await database.insert(credentials).values({ @@ -1098,7 +1502,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 +1529,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", @@ -1139,6 +1543,46 @@ describe("authenticated", () => { expect(created?.productId).toBe(SIGNATURE_PRODUCT_ID); }); + it("issues a signature product card on base for a business credential", async () => { + chain.id = base.id; + const credentialId = "base-business"; + const account = padHex("0xba53", { size: 20 }); + await insertBusinessCredential({ id: credentialId, account, pandaId: "base-business-user" }); + try { + const getCompanyApplication = vi + .spyOn(panda, "getCompanyApplication") + .mockResolvedValueOnce({ id: "base-business-company", applicationStatus: "approved" }); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValueOnce([ + { id: "base-business-user", walletAddress: account }, + ]); + const createCard = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "543c1771-beae-4f26-b662-44ea48b40ba3", last4: "5353" }); + + const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); + + expect(response.status).toBe(200); + expect(getCompanyApplication).toHaveBeenCalledExactlyOnceWith(credentialId); + expect(createCard).toHaveBeenCalledExactlyOnceWith("base-business-user", SIGNATURE_PRODUCT_ID, { + amount: undefined, + idempotencyKey: "business-approval:base-business:0", + }); + await expect(response.json()).resolves.toStrictEqual({ + status: "ACTIVE", + lastFour: "5353", + cardId: "543c1771-beae-4f26-b662-44ea48b40ba3", + productId: SIGNATURE_PRODUCT_ID, + }); + const created = await database.query.cards.findFirst({ + columns: { productId: true }, + where: eq(cards.credentialId, credentialId), + }); + expect(created?.productId).toBe(SIGNATURE_PRODUCT_ID); + } finally { + await removeBusinessCredential(credentialId); + } + }); + it("issues a signature product card on optimism", async () => { chain.id = optimism.id; vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ @@ -1152,7 +1596,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 +2645,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 +2674,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 +2700,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 e45cf61abe..8176daa721 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"; @@ -21,7 +23,7 @@ import chain from "@exactly/common/generated/chain"; 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"; @@ -2667,6 +2669,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); afterEach(async () => { + await database.delete(cards).where(eq(cards.credentialId, businessId)); await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, businessId)); }); @@ -2683,6 +2686,20 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE await expect(response.json()).resolves.toStrictEqual(pendingApplication); }); + it("rejects individual application updates", async () => { + const update = vi.spyOn(panda, "updateApplication"); + await database.update(credentials).set({ pandaId: "business-user" }).where(eq(credentials.id, businessId)); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); + expect(update).not.toHaveBeenCalled(); + }); + it("serializes business inquiry creation", async () => { let created = false; vi.spyOn(persona, "getPendingInquiryTemplate").mockResolvedValue(persona.BUSINESS_TEMPLATE); @@ -2772,9 +2789,15 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE it("returns conflict when a business application already started", async () => { await database.update(credentials).set({ pandaId: "panda-id" }).where(eq(credentials.id, businessId)); + await database.insert(cards).values({ + id: "business-conflict-card", + credentialId: businessId, + lastFour: "1234", + }); const businessApplication = vi.spyOn(panda, "businessApplication"); const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication"); const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication"); + const createCard = vi.spyOn(panda, "createCard"); const response = await postApplication(); @@ -2782,6 +2805,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE expect(businessApplication).not.toHaveBeenCalled(); expect(getCompanyApplication).not.toHaveBeenCalled(); expect(createCompanyApplication).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); await expect(response.json()).resolves.toStrictEqual({ code: "already started" }); }); @@ -2800,8 +2824,8 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); it("returns bad request for a Panda validation error", async () => { - vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined mockProfile(); + vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined vi.spyOn(panda, "createCompanyApplication").mockRejectedValueOnce( new ServiceError("Panda", 400, '{"message":"invalid company"}', undefined, "invalid company"), ); @@ -2815,6 +2839,57 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); }); + it("creates an approved company application without issuing a card", async () => { + mockProfile(); + const application = { + id: "company-approved", + name: "Account Acme", + address: { + line1: "1 Main St", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + applicationStatus: "approved" as const, + }; + vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + vi.spyOn(panda, "createCompanyApplication").mockResolvedValue(application); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + + const response = await postApplication({ "do-connecting-ip": "127.0.0.1", "account-type": "business" }); + + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, businessId) }); + const card = await database.query.cards.findFirst({ where: eq(cards.credentialId, businessId) }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual(application); + expect(card).toBeUndefined(); + expect(credential).toMatchObject({ pandaId: null }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + }); + + it("returns the company application when a panda user exists without a card", async () => { + await database.update(credentials).set({ pandaId: "business-user" }).where(eq(credentials.id, businessId)); + const application = { + id: "company-approved", + applicationStatus: "approved" as const, + applicationReason: "", + }; + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(application); + const createCard = vi.spyOn(panda, "createCard"); + + const response = await postApplication({ "do-connecting-ip": "127.0.0.1", "account-type": "business" }); + + const card = await database.query.cards.findFirst({ where: eq(cards.credentialId, businessId) }); + expect(response.status).toBe(200); + expect(getCompanyApplication).toHaveBeenCalledExactlyOnceWith(businessId); + await expect(response.json()).resolves.toStrictEqual(application); + expect(card).toBeUndefined(); + expect(createCard).not.toHaveBeenCalled(); + }); + it("returns bad request when a business application includes a verify payload", async () => { const response = await appClient.application.$post( { @@ -2924,7 +2999,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE it("returns company application status", async () => { const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue({ id: businessId, - applicationStatus: "approved", + applicationStatus: "pending", applicationReason: "", }); @@ -2935,12 +3010,68 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE expect(response.status).toBe(200); expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "pending", + reason: "", + }); + }); + + it("does not issue a card when polling an approved company", async () => { + const application = { + id: "company-approved", + applicationStatus: "approved" as const, + applicationReason: "", + }; + vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(application); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + await expect( + database.query.credentials.findFirst({ columns: { pandaId: true }, where: eq(credentials.id, businessId) }), + ).resolves.toMatchObject({ pandaId: null }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + }); + + it("does not issue a card when polling an approved company that already has an active card", async () => { + await database.update(credentials).set({ pandaId: "business-user" }).where(eq(credentials.id, businessId)); + await database.insert(cards).values({ id: "business-poll-card", credentialId: businessId, lastFour: "1234" }); + vi.spyOn(panda, "getCompanyApplication").mockResolvedValue({ + id: "company-approved", + applicationStatus: "approved", + applicationReason: "", + }); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok", status: "approved", reason: "", }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); }); it("returns company application verification links", async () => { diff --git a/server/test/hooks/bin.test.ts b/server/test/hooks/bin.test.ts index 8c25c0ad99..3cc51dd922 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,20 @@ describe("hook bin", () => { }, { accounts: ["issuer", "settler"], - config: { database, issuer, onesignal, panda, refund, sardine, segment, settler: account, webhook }, + config: { + allow, + credit, + database, + issuer, + onesignal, + panda, + persona, + refund, + sardine, + segment, + settler: account, + webhook, + }, load: () => import("../../hooks/bin/panda"), name: "panda", secrets: [ @@ -173,6 +190,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 dd1e1206f8..44a7cae1e5 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -1,4 +1,4 @@ -import "../mocks/deployments"; +import deployments from "../mocks/deployments"; import sendPushNotificationMock from "../mocks/onesignal"; import "../mocks/panda"; import * as sardine from "../mocks/sardine"; @@ -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,23 @@ import traceClient from "../../utils/traceClient"; import wallet from "../../utils/wallet"; import anvilClient from "../anvilClient"; +import type createAllow from "../../workers/allow/queue"; +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 allow = vi.hoisted(() => ({ + close: vi.fn["close"]>().mockResolvedValue(), + enqueue: vi.fn["enqueue"]>().mockResolvedValue(), +})); +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"]>(), @@ -75,11 +90,15 @@ const panda = createPanda(pandaConfig); const sardineConfig = { key: "sardine", url: "https://api.sardine.ai" }; const issuer = privateKeyToAccount(padHex("0x420")); const owner = createWalletClient({ chain, transport: http(), account: privateKeyToAccount(generatePrivateKey()) }); +const businessSalt = parse(Address, padHex("0x7e", { size: 20 })); const pandaHook = createPandaHook({ + allow, + credit, database, issuer, onesignal: createOnesignal("onesignal"), panda, + persona, refund, sardine: createSardine(sardineConfig.key, sardineConfig.url), segment: createSegment("segment"), @@ -2725,7 +2744,7 @@ describe("concurrency", () => { const [spend, spend2, collect] = await promises; const spendStatuses = [spend.status, spend2.status].toSorted(); - expect(spendStatuses).toStrictEqual([200, 554]); + expect(spendStatuses).toStrictEqual([200, 557]); expect(collect.status).toBe(200); }); @@ -2764,6 +2783,38 @@ describe("concurrency", () => { expect(spendAuthorization.status).toBe(200); expect(collectSpendAuthorization.status).toBe(200); }); + + it("does not release a mutex held by a card operation", async () => { + const mutex = Panda.createMutex(account2); + await mutex.acquire(); + Panda.markCardLock(account2, true); + + try { + const post = (status: "declined" | "pending") => + appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: "card-held-mutex-tx", + spend: { ...authorization.json.body.spend, amount: 700, cardId: `${account2}-card`, status }, + }, + } as unknown as typeof authorization.json, + }); + + const pending = await post("pending"); + const declined = await post("declined"); + + expect(pending.status).toBe(200); + expect(declined.status).toBe(200); + expect(mutex.isLocked()).toBe(true); + } finally { + Panda.markCardLock(account2, false); + mutex.release(); + } + }); }); it("inserts declined transaction with zero-hash placeholder", async () => { @@ -3658,6 +3709,698 @@ 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("acknowledges a company webhook for another action", async () => { + const getCompany = vi.spyOn(panda, "getCompany").mockResolvedValue({ id: "business-company-created" }); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-created", + resource: "company", + action: "created", + body: { id: "business-company-created", applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(getCompany).toHaveBeenCalledExactlyOnceWith("business-company-created"); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + }); + + 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"), + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + 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({ + credentialId, + lastFour: "1234", + productId: SIGNATURE_PRODUCT_ID, + }); + expect(credential).toMatchObject({ pandaId: "business-user" }); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + businessAccount, + `business-approval:${credentialId}:business-card`, + ); + expect(allow.enqueue).toHaveBeenCalledExactlyOnceWith({ + account: businessAccount, + chainId: chain.id, + factory: inject("ExaAccountFactory"), + publicKey: "0x", + salt: businessSalt, + source: null, + }); + expect(hookQueue.enqueue).not.toHaveBeenCalled(); + expect(Panda.getMutex(businessAccount)).toBeUndefined(); + } finally { + Panda.getMutex(businessAccount)?.release(); + await database.delete(cards).where(eq(cards.id, "business-card")); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("issues the card without allowing when the firewall is unavailable", async () => { + const credentialId = "business-no-firewall"; + const companyId = "business-company-no-firewall"; + const businessAccount = parse(Address, padHex("0xb059", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user", walletAddress: businessAccount }]); + vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-no-firewall-card", + userId: "business-user", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + deployments.setFirewall(undefined); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved-no-firewall", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + expect(allow.enqueue).not.toHaveBeenCalled(); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + businessAccount, + `business-approval:${credentialId}:business-no-firewall-card`, + ); + } finally { + deployments.setFirewall(inject("Firewall")); + await database.delete(cards).where(eq(cards.id, "business-no-firewall-card")); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("issues a single card for concurrent company approvals", async () => { + const credentialId = "business-concurrent"; + const companyId = "business-company-concurrent"; + const businessAccount = parse(Address, padHex("0xb057", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user", walletAddress: businessAccount }]); + const createCard = vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-concurrent-card", + userId: "business-user", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + const payload = { + header: { signature: "panda-signature" }, + json: { + id: "company-approved-concurrent", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + } as const; + + try { + const responses = await Promise.all([appClient.index.$post(payload), appClient.index.$post(payload)]); + + expect(responses.map(({ status }) => status)).toStrictEqual([200, 200]); + expect(createCard).toHaveBeenCalledExactlyOnceWith("business-user", SIGNATURE_PRODUCT_ID, { + amount: undefined, + idempotencyKey: `business-approval:${credentialId}:0`, + }); + expect(credit.enqueue).toHaveBeenCalledTimes(2); + expect(credit.enqueue).toHaveBeenNthCalledWith( + 1, + businessAccount, + `business-approval:${credentialId}:business-concurrent-card`, + ); + expect(credit.enqueue).toHaveBeenNthCalledWith( + 2, + businessAccount, + `business-approval:${credentialId}:business-concurrent-card`, + ); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + await database.delete(credentials).where(eq(credentials.id, credentialId)); + } + }); + + it("captures card limit failures after setting the sentry user for an approved company", async () => { + const credentialId = "business-card-limit"; + const companyId = "business-company-card-limit"; + const businessAccount = parse(Address, padHex("0xb058", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + pandaId: "business-user", + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([{ id: "business-user" }]); + const createCard = vi.spyOn(panda, "createCard"); + const error = new Error("persona unavailable"); + vi.mocked(persona.getAccount).mockRejectedValueOnce(error); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved-card-limit", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(500); + expect(createCard).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledWith(error, { + level: "error", + contexts: { details: { credentialId, scope: "cardLimit" } }, + }); + expect(setUser).toHaveBeenCalledWith({ id: businessAccount }); + expect(vi.mocked(setUser).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(captureException).mock.invocationCallOrder[0] ?? 0, + ); + } finally { + await database.delete(cards).where(eq(cards.credentialId, credentialId)); + 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"), + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + 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("acknowledges an approved company without a company lookup", async () => { + vi.spyOn(panda, "getCompany").mockImplementation(() => Promise.resolve()); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-mapping-missing-company", + resource: "company", + action: "updated", + body: { id: "business-company-missing", applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + expect(credit.enqueue).not.toHaveBeenCalled(); + expect(hookQueue.enqueue).not.toHaveBeenCalled(); + }); + + it("acknowledges an approved company without an external id", async () => { + vi.spyOn(panda, "getCompany").mockResolvedValue({ id: "business-company-unmapped" }); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-mapping-missing", + resource: "company", + action: "updated", + body: { id: "business-company-unmapped", applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + expect(credit.enqueue).not.toHaveBeenCalled(); + expect(hookQueue.enqueue).not.toHaveBeenCalled(); + }); + + it("returns a retryable error when the approved company maps to an unknown credential", async () => { + vi.spyOn(panda, "getCompany").mockImplementation((id) => + Promise.resolve({ externalId: "unknown-business-credential", id }), + ); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-mapping-unknown", + resource: "company", + action: "updated", + body: { id: "business-company-mapping-unknown", applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toStrictEqual({ code: "retry" }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + expect(credit.enqueue).not.toHaveBeenCalled(); + expect(hookQueue.enqueue).not.toHaveBeenCalled(); + }); + + it("acknowledges an approved company for an individual credential", async () => { + const credentialId = "individual-company-hook"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xb056", { size: 20 }), + factory: inject("ExaAccountFactory"), + salt: zeroAddress, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + const getCompanyUsers = vi.spyOn(panda, "getCompanyUsers"); + const createCard = vi.spyOn(panda, "createCard"); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-individual-approved", + resource: "company", + action: "updated", + body: { id: "business-company-individual", applicationStatus: "approved" }, + }, + }); + + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, credentialId) }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(getCompanyUsers).not.toHaveBeenCalled(); + expect(createCard).not.toHaveBeenCalled(); + expect(credit.enqueue).not.toHaveBeenCalled(); + expect(hookQueue.enqueue).not.toHaveBeenCalled(); + 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"), + pandaId: "stale-business-user", + salt: businessSalt, + }); + + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + 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"), + pandaId: "business-user", + salt: businessSalt, + }); + await database.insert(cards).values({ id: "business-repeated-card", credentialId, lastFour: "1234" }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + 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(); + expect(credit.enqueue).toHaveBeenCalledExactlyOnceWith( + parse(Address, padHex("0xb053", { size: 20 })), + `business-approval:${credentialId}:business-repeated-card`, + ); + } 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"), + pandaId: "business-user", + salt: businessSalt, + }); + await database + .insert(cards) + .values({ id: "business-reissue-card", credentialId, lastFour: "1234", status: "DELETED" }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + 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("captures Sardine failures after issuing a card", async () => { + const credentialId = "business-sardine-failure"; + const companyId = "business-company-sardine-failure"; + const businessAccount = parse(Address, padHex("0xb054", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + vi.spyOn(panda, "getCompanyUsers").mockResolvedValue([ + { id: "business-user-sardine-failure", walletAddress: businessAccount }, + ]); + vi.spyOn(panda, "createCard").mockResolvedValue({ + id: "business-sardine-card", + userId: "business-user-sardine-failure", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "1234", + expirationMonth: "12", + expirationYear: "2030", + }); + const error = new Error("sardine unavailable"); + vi.spyOn(sardine, "customer").mockRejectedValueOnce(error); + + try { + const response = await appClient.index.$post({ + header: { signature: "panda-signature" }, + json: { + id: "company-approved-sardine-failure", + resource: "company", + action: "updated", + body: { id: companyId, applicationStatus: "approved" }, + }, + }); + + expect(response.status).toBe(200); + expect(vi.mocked(captureException).mock.calls.filter(([captured]) => captured === error)).toStrictEqual([ + [error, { level: "error" }], + ]); + } 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"), + salt: businessSalt, + }); + vi.spyOn(panda, "getCompany").mockImplementation((id) => Promise.resolve({ externalId: credentialId, id })); + 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(); + expect(hookQueue.enqueue).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 8035df27d8..37bad37e46 100644 --- a/server/test/mocks/panda.ts +++ b/server/test/mocks/panda.ts @@ -27,8 +27,11 @@ const mock = vi.hoisted(() => { current().getApplicationStatus(...parameters), getCard: (...parameters: Parameters) => current().getCard(...parameters), getCards: (...parameters: Parameters) => current().getCards(...parameters), + getCompany: (...parameters: Parameters) => current().getCompany(...parameters), getCompanyApplication: (...parameters: Parameters) => current().getCompanyApplication(...parameters), + getCompanyUsers: (...parameters: Parameters) => + current().getCompanyUsers(...parameters), getNonce: (...parameters: Parameters) => current().getNonce(...parameters), getPIN: (...parameters: Parameters) => current().getPIN(...parameters), getProcessorDetails: (...parameters: Parameters) => diff --git a/server/test/utils/panda.test.ts b/server/test/utils/panda.test.ts index dd4abeee85..11e02b92e7 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"; @@ -82,6 +84,92 @@ 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" }), + ); + }); + + it("resolves a company external id by company id", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ externalId: "reference-id", id: "company-id" })); + + await expect(panda.getCompany("company-id")).resolves.toStrictEqual({ + externalId: "reference-id", + id: "company-id", + }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/issuing/companies/company-id"), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("rejects a company response for another company", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + Response.json({ externalId: "reference-id", id: "other-company-id" }), + ); + + await expect(panda.getCompany("company-id")).rejects.toThrow("panda company id mismatch"); + }); + + it("returns nothing when the company does not exist", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + await expect(panda.getCompany("company-id")).resolves.toBeUndefined(); + }); + + it("rethrows a company lookup failure that is not a not-found", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response('{"message":"Internal Server Error","error":"ServerError","statusCode":500}', { status: 500 }), + ); + + const rejection = panda.getCompany("company-id"); + await expect(rejection).rejects.toBeInstanceOf(ServiceError); + await expect(rejection).rejects.toMatchObject({ + name: "PandaServer", + status: 500, + message: "Internal Server Error", + }); + }); +}); + +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", () => { @@ -328,8 +416,9 @@ describe("business application", () => { }); describe("mutex", () => { + const account = parse(Address, "0x29684075a3C86ea11D9964BcAf0F956e801396bD"); + it("purges the mutex entry after the exclusive run", async () => { - const account = parse(Address, "0x29684075a3C86ea11D9964BcAf0F956e801396bD"); await Panda.withMutex(account, () => { expect(Panda.getMutex(account)).toBeDefined(); return Promise.resolve(); @@ -337,8 +426,15 @@ describe("mutex", () => { expect(Panda.getMutex(account)).toBeUndefined(); }); + it("marks the account busy while the exclusive run is in flight", async () => { + await Panda.withMutex(account, () => { + expect(Panda.isCardLocked(account)).toBe(true); + return Promise.resolve(); + }); + expect(Panda.isCardLocked(account)).toBe(false); + }); + it("serializes concurrent exclusive runs for the same account", async () => { - const account = parse(Address, "0x29684075a3C86ea11D9964BcAf0F956e801396bD"); const order: string[] = []; const run = (name: string) => Panda.withMutex(account, async () => { @@ -421,6 +517,25 @@ 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" }, + }), + headers: expect.objectContaining({ "Idempotency-Key": "approval-key" }) as object, + }), + ); + }); + 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 95a5887dd2..8e550517fc 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -54,13 +54,16 @@ import ServiceError from "./ServiceError"; import verifySignature from "./verifySignature"; import type createPersona from "./persona"; +import type { cards } from "../database/schema"; export default function panda({ key, url }: { key: string; url: string }) { return { createCard, createCompanyApplication, createUser, getApplicationStatus, + getCompany, getCompanyApplication, + getCompanyUsers, getCard, getCards, getNonce, @@ -84,12 +87,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", @@ -134,6 +137,24 @@ export default function panda({ key, url }: { key: string; url: string }) { 10_000, ); } + function getCompany(companyId: string) { + return request( + object({ externalId: optional(nullable(string())), id: string() }), + `/issuing/companies/${companyId}`, + {}, + undefined, + "GET", + 10_000, + ) + .catch((error: unknown) => { + if (error instanceof ServiceError && error.status === 404) return; + throw error; + }) + .then((company) => { + if (company && company.id !== companyId) throw new Error("panda company id mismatch"); + return company; + }); + } async function getCompanyApplication(externalId: string) { const application = await request( CompanyApplicationStatusResponse, @@ -151,6 +172,16 @@ export default function panda({ key, url }: { key: string; url: string }) { throw new Error("panda company external id mismatch"); return application; } + 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 getApplicationStatus(applicationId: string) { return request( ApplicationStatusResponse, @@ -556,9 +587,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: string(), + 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(), @@ -795,10 +850,40 @@ async function businessApplication( } const mutexes = new Map(); +export const activeStatuses: (typeof cards.$inferSelect.status)[] = ["ACTIVE", "FROZEN"]; + +export function issuanceKey( + credentialId: string, + previous: { status: typeof cards.$inferSelect.status }[], + cleaned = 0, +) { + return `business-approval:${credentialId}:${previous.filter(({ status }) => status === "DELETED").length + cleaned}`; +} + +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, + ); +} + +const cardLocks = new Set
(); + +export function markCardLock(address: Address, locked: boolean) { + if (locked) cardLocks.add(address); + else cardLocks.delete(address); +} +export function isCardLocked(address: Address) { + return cardLocks.has(address); +} + export function createMutex(address: Address) { const mutex = withTimeout( new Mutex(), - (proposalManager.delay as Record)[chain.id] ?? proposalManager.delay.default * 1000, + ((proposalManager.delay as Record)[chain.id] ?? proposalManager.delay.default) * 1000, ); mutexes.set(address, mutex); return mutex; @@ -806,11 +891,23 @@ export function createMutex(address: Address) { export function getMutex(address: Address) { return mutexes.get(address); } +export function deleteMutex(address: Address) { + mutexes.delete(address); +} export function withMutex(address: Address, task: () => Promise) { const mutex = getMutex(address) ?? createMutex(address); - return mutex.runExclusive(task).finally(() => { - if (!mutex.isLocked()) mutexes.delete(address); - }); + return mutex + .runExclusive(async () => { + markCardLock(address, true); + try { + return await task(); + } finally { + markCardLock(address, false); + } + }) + .finally(() => { + if (!mutex.isLocked()) mutexes.delete(address); + }); } const AddressSchema = object({ @@ -941,18 +1038,6 @@ const ApplicationReview = { applicationExternalVerificationLink: optional(nullable(ApplicationLink)), }; -export const kycStatus = [ - "needsVerification", - "needsInformation", - "manualReview", - "notStarted", - "approved", - "canceled", - "pending", - "denied", - "locked", -] as const; - export const CompanyApplicationStatusResponse = object({ id: string(), externalId: optional(nullable(string())), diff --git a/server/workers/hook/worker.ts b/server/workers/hook/worker.ts index 19e61d8af0..7ddc65f94a 100644 --- a/server/workers/hook/worker.ts +++ b/server/workers/hook/worker.ts @@ -51,7 +51,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 },