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
---

✨ process business onboarding approvals
12 changes: 10 additions & 2 deletions infra/utils/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@ export default define({
shared: ["manteca-api-url"],
},
panda: {
secrets: ["onesignal-api-key", "panda-api-key", "postgres-url", "sardine-api-key", "segment-write-key"],
shared: ["panda-api-url", "sardine-api-url"],
env: { PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "personaBusinessAccountTypeId" },
secrets: [
"onesignal-api-key",
"panda-api-key",
"persona-api-key",
"postgres-url",
"sardine-api-key",
"segment-write-key",
],
shared: ["panda-api-url", "persona-api-url", "sardine-api-url"],
signers: ["settler", "issuer"],
},
persona: {
Expand Down
105 changes: 58 additions & 47 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exa
import { Address, Base64URL, Hex } from "@exactly/common/validation";

import { cards, credentials } from "../database/schema";
import { isBusinessSalt } from "../utils/createCredential";
import {
cardLimit,
createMutex as createAccountMutex,
finalizeBusinessApproval,
getMutex,
notifyCardIssued,
} from "../utils/panda";
import publicClient from "../utils/publicClient";
import ServiceError from "../utils/ServiceError";
import validatorHook from "../utils/validatorHook";
Expand Down Expand Up @@ -555,12 +563,17 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \`
}),
async (c) => {
const { credentialId } = c.req.valid("cookie");
const mutex = mutexes.get(credentialId) ?? createMutex(credentialId);
const mutexAccount = await database.query.credentials
.findFirst({ columns: { account: true, salt: true }, where: eq(credentials.id, credentialId) })
.then((row) => (row && isBusinessSalt(parse(Address, row.salt)) ? parse(Address, row.account) : undefined));
const mutex = mutexAccount
? (getMutex(mutexAccount) ?? createAccountMutex(mutexAccount))
: (mutexes.get(credentialId) ?? createMutex(credentialId));
return mutex
.runExclusive(async () => {
const credential = await database.query.credentials.findFirst({
where: eq(credentials.id, credentialId),
columns: { account: true, pandaId: true, source: true },
columns: { account: true, pandaCompanyId: true, pandaId: true, salt: true, source: true },
with: {
cards: {
columns: { id: true, status: true, productId: true },
Expand Down Expand Up @@ -599,12 +612,30 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \`
}
}
}
if (cardCount > 0) return c.json({ code: "already created" }, 400);
if (cardCount > 0) {
if (mutexAccount && credential.pandaCompanyId)
await finalizeBusinessApproval(credentialId, credential.pandaCompanyId, account, database, panda, {
credit,
persona,
sardine,
segment,
});
return c.json({ code: "already created" }, 400);
}
try {
const kyc = await panda.getApplicationStatus(pandaId);
const kyc = mutexAccount
? credential.pandaCompanyId
? await panda.getCompanyStatus(credential.pandaCompanyId)
: undefined
: await panda.getApplicationStatus(pandaId);
if (!kyc) return c.json({ code: "no panda" }, 403);
if (kyc.applicationStatus !== "approved") {
return c.json({ code: "kyc not approved" }, 403);
}
if (mutexAccount && credential.pandaCompanyId) {
const users = await panda.getCompanyUsers(credential.pandaCompanyId);
if (!users.some(({ id }) => id === pandaId)) return c.json({ code: "no panda" }, 403);
}
const productId =
chain.id === base.id
? credential.source === "5lu2sNu0v0ZElC2m77QR3rAZBHLr8PoG" // cspell:ignore azbh
Expand All @@ -626,35 +657,22 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \`
},
});
return orphan;
} else {
return panda.createCard(
pandaId,
productId,
await persona
.getAccount(credentialId, "cardLimit")
.then((profile) =>
profile?.attributes.fields.card_limit_usd?.value == null
? undefined
: profile.attributes.fields.card_limit_usd.value * 100,
)
.catch((error: unknown): undefined => {
captureException(error, {
level: "error",
contexts: { details: { credentialId, scope: "cardLimit" } },
});
}),
);
}
return panda.createCard(pandaId, productId, {
amount: await cardLimit(credentialId, persona).catch((error: unknown): undefined => {
if (mutexAccount) throw error;
captureException(error, {
level: "error",
contexts: { details: { credentialId, scope: "cardLimit" } },
});
}),
...(mutexAccount && {
idempotencyKey: `business-approval:${credentialId}:${credential.cards.filter(({ status }) => status === "DELETED").length + activeCards.length - cardCount}`,
}),
});
});

await database.insert(cards).values([{ id: card.id, credentialId, lastFour: card.last4, productId }]);
await credit.enqueue(account).catch((error: unknown) =>
captureException(error, {
level: "error",
tags: { queue: creditName, job: creditName },
extra: { account },
}),
);
segment.track({
event: "CardIssued",
userId: account,
Expand All @@ -663,24 +681,17 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \`

if (isUpgradeFromPlatinum) handlePlatinumUpgrade(credentialId, account, pax, persona);

sardine
.customer({
flow: { name: "card.issued", type: "payment_method_link" },
customer: { id: credentialId, type: "customer" },
transaction: {
id: card.id,
paymentMethod: {
type: "card",
card: {
hash: card.id,
last4: card.last4,
expiryMonth: card.expirationMonth,
expiryYear: card.expirationYear,
},
},
},
})
.catch((error: unknown) => captureException(error, { level: "error" }));
notifyCardIssued(sardine, { credentialId, card });

await (mutexAccount
? credit.enqueue(account, `business-approval:${credentialId}:${card.id}`)
: credit.enqueue(account).catch((error: unknown) =>
captureException(error, {
level: "error",
tags: { queue: creditName, job: creditName },
extra: { account },
}),
));

return c.json(
{
Expand Down
2 changes: 1 addition & 1 deletion server/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default function api({
)
.route("/activity", activity({ auth, database }))
.route("/card", card({ auth, credit, database, panda, pax, persona, sardine, segment, walletExtension }))
.route("/kyc", kyc({ auth, database, panda, persona }))
.route("/kyc", kyc({ auth, credit, database, panda, persona, sardine, segment }))
.route("/passkey", passkey({ auth, database })) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove
.route("/pax", paxRoute({ auth, database, pax }))
.route("/ramp", ramp({ auth, bridge, database, manteca, persona }))
Expand Down
53 changes: 47 additions & 6 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 @@ -30,16 +30,18 @@ import chain, {
} from "@exactly/common/generated/chain";
import { Address, Hex } from "@exactly/common/validation";

import { credentials, walletAddresses } from "../database/schema";
import { cards, credentials, walletAddresses } from "../database/schema";
import { isBusinessSalt } from "../utils/createCredential";
import decodePublicKey from "../utils/decodePublicKey";
import {
activeCardStatuses,
Application,
UpdateApplicationRequest as ApplicationUpdate,
BusinessApplicationError,
CompanyApplicationResponse,
CompanyApplicationStatusResponse,
createMutex,
finalizeBusinessApproval,
getMutex,
} from "../utils/panda";
import {
Expand All @@ -59,6 +61,9 @@ import type * as schema from "../database/schema";
import type { Auth } from "../middleware/auth";
import type createPanda from "../utils/panda";
import type createPersona from "../utils/persona";
import type createSardine from "../utils/sardine";
import type createSegment from "../utils/segment";
import type createCredit from "../workers/credit/queue";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";

const debug = createDebug("exa:kyc");
Expand Down Expand Up @@ -88,14 +93,20 @@ function buildBaseResponse(example = "string") {

export default function route({
auth,
credit,
database,
panda,
persona,
sardine,
segment,
}: {
auth: Auth;
credit: ReturnType<typeof createCredit>;
database: NodePgDatabase<typeof schema>;
panda: ReturnType<typeof createPanda>;
persona: ReturnType<typeof createPersona>;
sardine: ReturnType<typeof createSardine>;
segment: ReturnType<typeof createSegment>;
}) {
return new Hono()
.get(
Expand Down Expand Up @@ -546,9 +557,8 @@ The admin should add a member using [addMember method](https://www.better-auth.c
),
async (c) => {
const payload = c.req.valid("json");
const isBusiness = c.req.valid("query")?.accountType === "business";
const credentialId = c.req.valid("cookie").credentialId;
if (isBusiness) {
if (c.req.valid("query")?.accountType === "business") {
const credential = await database.query.credentials.findFirst({
columns: { account: true, salt: true },
where: eq(credentials.id, credentialId),
Expand All @@ -565,12 +575,35 @@ The admin should add a member using [addMember method](https://www.better-auth.c
});
if (!current) return c.json({ code: "no credential" }, 500);
try {
if (current.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409);
if (current.pandaId) {
const existing = await database.query.cards.findFirst({
columns: { id: true },
where: and(eq(cards.credentialId, credentialId), inArray(cards.status, activeCardStatuses)),
});
if (existing) {
if (current.pandaCompanyId) {
const application = await panda.getCompanyStatus(current.pandaCompanyId);
if (application.applicationStatus === "approved")
await finalizeBusinessApproval(credentialId, current.pandaCompanyId, account, database, panda, {
credit,
persona,
sardine,
segment,
});
}
return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409);
}
}
const application = current.pandaCompanyId
? await panda.getCompanyStatus(current.pandaCompanyId)
: await panda
.createCompanyApplication(
await panda.businessApplication(credentialId, account, c.req.header("do-connecting-ip"), persona),
await panda.businessApplication(
credentialId,
account,
c.req.header("do-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
Comment thread
aguxez marked this conversation as resolved.
persona,
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{ idempotencyKey: `business-application:${credentialId}` },
)
.then(async (result) => {
Expand All @@ -585,6 +618,14 @@ The admin should add a member using [addMember method](https://www.better-auth.c
["denied", "locked", "canceled"].includes(application.applicationStatus)
)
return c.json({ code: "bad kyb", legacy: "kyb not approved" }, 400);
if (application.applicationStatus === "approved") {
await finalizeBusinessApproval(credentialId, application.id, account, database, panda, {
credit,
persona,
sardine,
segment,
});
}
Comment thread
aguxez marked this conversation as resolved.
setUser({ id: account });
return c.json(application, 200);
} catch (error) {
Expand Down
38 changes: 28 additions & 10 deletions server/hooks/bin/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import * as schema from "../../database/schema";
import supervise, { own } from "../../supervise";
import createOnesignal from "../../utils/onesignal";
import createPanda from "../../utils/panda";
import createPersona from "../../utils/persona";
import createSardine from "../../utils/sardine";
import secret from "../../utils/secret";
import createSegment from "../../utils/segment";
import { signer } from "../../utils/wallet";
import createCredit from "../../workers/credit/queue";
import createHook from "../../workers/hook/queue";
import createRefund from "../../workers/refund/queue";
import { connect } from "../../workers/worker";
Expand All @@ -27,22 +29,38 @@ supervise(
Promise.all([secret("panda-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) =>
createPanda({ key, url }),
),
Promise.all([secret("panda-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) =>
createPersona(key, url),
),
secret("redis-url", secrets)
.then((url) => connect(url))
.then((bullmq) => [bullmq, createRefund(bullmq), createHook(bullmq)] as const),
.then((bullmq) => [bullmq, createCredit(bullmq), createRefund(bullmq), createHook(bullmq)] as const),
Promise.all([secret("panda-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) =>
createSardine(key, url),
),
secret("panda-segment-write-key", secrets).then((key) => createSegment(key)),
signer("settler", kms),
]).then(([database, issuer, onesignal, provider, [bullmq, refund, webhook], sardine, segment, settler]) =>
own(
panda({ database, issuer, onesignal, panda: provider, refund, sardine, segment, settler, webhook }),
() => database.$client.end(),
() => kms.close(),
() => secrets.close(),
() => segment.close(),
() => Promise.all([refund.close(), webhook.close()]).finally(() => bullmq.quit()),
),
]).then(
([database, issuer, onesignal, provider, persona, [bullmq, credit, refund, webhook], sardine, segment, settler]) =>
own(
panda({
credit,
database,
issuer,
onesignal,
panda: provider,
persona,
refund,
sardine,
segment,
settler,
webhook,
}),
() => database.$client.end(),
() => kms.close(),
() => secrets.close(),
() => segment.close(),
() => Promise.all([credit.close(), refund.close(), webhook.close()]).finally(() => bullmq.quit()),
),
),
);
Loading
Loading