Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-heads-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
Comment thread
aguxez marked this conversation as resolved.
---

✨ process business onboarding approvals
11 changes: 9 additions & 2 deletions infra/utils/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
143 changes: 97 additions & 46 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Expand Down Expand Up @@ -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));
Comment thread
aguxez marked this conversation as resolved.
const unlock = await mutex.acquire();
if (account) markCardLock(account, true);
return {
release: () => {
unlock();
if (account) markCardLock(account, false);
Comment thread
aguxez marked this conversation as resolved.
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(
"/",
Expand Down Expand Up @@ -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 },
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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);
Comment thread
aguxez marked this conversation as resolved.
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)
Expand All @@ -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,
Expand Down Expand Up @@ -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}`)
Comment on lines +726 to +727

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enqueue firewall access in the business card route

On a firewall-enabled chain, if POST /card becomes the recovery path after the company webhook stored pandaId but failed to enqueue the allow job, this business branch creates the card and enqueues only credit, then returns success while the account remains blocked by the firewall. Fresh evidence beyond the earlier webhook finding is that the webhook now calls allow.enqueue() in server/hooks/panda.ts:182-190, whereas the card route does not receive or invoke the allow queue. Enqueue the same allowance before completing business card issuance.

Useful? React with 👍 / 👎.

: credit.enqueue(account).catch((error: unknown) =>
captureException(error, {
level: "error",
tags: { queue: creditName, job: creditName },
extra: { account },
}),
));

return c.json(
{
lastFour: card.last4,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -969,10 +1020,10 @@ async function encryptPIN(pin: string) {
}
}
}
})
.finally(() => {
if (!mutex.isLocked()) mutexes.delete(credentialId);
});
})();
} finally {
release();
}
},
);
}
Expand Down
22 changes: 17 additions & 5 deletions server/api/kyc.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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] }))),
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading