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/clear-cobras-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add card limit case update
5 changes: 5 additions & 0 deletions .changeset/full-meteors-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ return processing for card-limit review states in kyc-api
5 changes: 5 additions & 0 deletions .changeset/huge-maps-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add card limit support to persona hook
5 changes: 5 additions & 0 deletions .changeset/thick-facts-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add card limit inquiry flow to kyc api
13 changes: 12 additions & 1 deletion server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,18 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
}
if (cardCount > 0) return c.json({ code: "already created" }, 400);
try {
const card = await createCard(credential.pandaId, SIGNATURE_PRODUCT_ID);
const limit = await getAccount(credentialId, "cardLimit")
.then((personaAccount) => {
const value = personaAccount?.attributes.fields.card_limit_usd?.value;
return value == null ? undefined : value * 100;
})
.catch((error: unknown): undefined => {
captureException(error, {
level: "error",
contexts: { details: { credentialId, scope: "cardLimit" } },
});
});
const card = await createCard(credential.pandaId, SIGNATURE_PRODUCT_ID, limit);
let mode = 0;
try {
if (await autoCredit(account)) mode = 1;
Expand Down
112 changes: 100 additions & 12 deletions server/api/kyc.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ import database, { credentials } from "../database/index";
import auth from "../middleware/auth";
import decodePublicKey from "../utils/decodePublicKey";
import {
CARD_LIMIT_TEMPLATE,
createInquiry,
CRYPTOMATE_TEMPLATE,
getAccount,
getCardLimitStatus,
getInquiry,
getPendingInquiryTemplate,
getUnknownAccount,
PANDA_TEMPLATE,
parseAccount,
resumeInquiry,
scopeValidationErrors,
} from "../utils/persona";
Expand All @@ -41,7 +45,7 @@ export default new Hono()
"query",
object({
countryCode: optional(literal("true")),
scope: optional(picklist(["basic", "bridge", "manteca"])),
scope: optional(picklist(["basic", "bridge", "cardLimit", "manteca"])),
}),
validatorHook(),
),
Expand All @@ -59,6 +63,48 @@ export default new Hono()
setUser({ id: account });
setContext("exa", { credential });

if (scope === "cardLimit") {
let unknownAccount: Awaited<ReturnType<typeof getUnknownAccount>> | undefined;
if (c.req.valid("query").countryCode) {
try {
unknownAccount = await getUnknownAccount(credentialId);
} catch (error: unknown) {
captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } });
}
}
if (unknownAccount) {
const countryCode = parseAccount(unknownAccount, "basic")?.attributes["country-code"];
countryCode && c.header("User-Country", countryCode);
}
const cardLimit = await getCardLimitStatus(credentialId, unknownAccount);
Comment thread
aguxez marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The call to getCardLimitStatus lacks a try/catch block, causing unhandled exceptions to be caught by the global error handler, which is inconsistent with existing patterns.
Severity: MEDIUM

Suggested Fix

Wrap the call to getCardLimitStatus at line 79 in a try/catch block to handle potential exceptions gracefully, consistent with the error handling pattern established elsewhere in the file (e.g., lines 69-73).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: server/api/kyc.ts#L79

Potential issue: When the `GET /api/kyc` endpoint is called with `scope=cardLimit` but
without a `countryCode`, the call to `getCardLimitStatus` is not wrapped in a
`try/catch` block. If the underlying call to `getUnknownAccount` fails (for example, if
a downstream service is unavailable), the error is not handled locally. Instead, it
propagates to the global error handler, which returns a generic 555 error response. This
is inconsistent with the error handling pattern used elsewhere in the same file, where
similar potential errors are caught and handled gracefully.


switch (cardLimit.status) {
case "resolved":
return c.json({ code: "ok" }, 200);
case "approved":
captureException(new Error("inquiry approved but account not updated"), {
level: "error",
contexts: { inquiry: { templateId: CARD_LIMIT_TEMPLATE, referenceId: credentialId } },
});
return c.json({ code: "ok" }, 200);
case "noTemplate":
return c.json({ code: "no kyc" }, 400);
case "noInquiry":
case "created":
case "pending":
case "expired":
return c.json({ code: "not started" }, 400);
case "completed":
case "needs_review":
return c.json({ code: "processing" }, 400);
case "failed":
case "declined":
return c.json({ code: "bad kyc" }, 400);
default:
throw new Error("unknown inquiry status");
}
}
Comment thread
aguxez marked this conversation as resolved.

if (scope === "basic" && credential.pandaId) {
if (c.req.valid("query").countryCode) {
const personaAccount = await getAccount(credentialId, scope).catch((error: unknown) => {
Expand Down Expand Up @@ -108,12 +154,12 @@ export default new Hono()
return c.json({ code: "not started", legacy: "kyc not started" }, 400);
case "completed":
case "needs_review":
return c.json({ code: "bad kyc", legacy: "kyc not approved" }, 400); // TODO send a different response for this transitory statuses
return c.json({ code: "processing", legacy: "kyc not approved" }, 400);
Comment thread
aguxez marked this conversation as resolved.
Comment on lines 155 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 Behavioral change: completed/needs_review status now returns 'processing' instead of error codes

The completed and needs_review inquiry statuses now return { code: "processing" } instead of { code: "bad kyc" } (GET) or { code: "failed" } (POST) at server/api/kyc.ts:155-157 and server/api/kyc.ts:264-266. This is an intentional semantic change (the old code had TODO comments about this), but it alters the API contract for existing clients. Any frontend code that checks for "bad kyc" or "failed" to determine if a review is in progress will need to be updated to also handle "processing". The test updates confirm this is deliberate.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

case "failed":
case "declined":
return c.json({ code: "bad kyc", legacy: "kyc not approved" }, 400);
default:
throw new Error("Unknown inquiry status");
throw new Error("unknown inquiry status");
}
},
)
Expand All @@ -124,7 +170,7 @@ export default new Hono()
"json",
object({
redirectURI: optional(string()),
scope: optional(picklist(["basic", "bridge", "manteca"])),
scope: optional(picklist(["basic", "bridge", "cardLimit", "manteca"])),
}),
validatorHook({ debug }),
),
Expand All @@ -141,6 +187,51 @@ export default new Hono()
setUser({ id: parse(Address, credential.account) });
setContext("exa", { credential });

if (scope === "cardLimit") {
const cardLimit = await getCardLimitStatus(credentialId);
switch (cardLimit.status) {
case "resolved":
return c.json({ code: "already approved" }, 400);
case "approved":
captureException(new Error("inquiry approved but account not updated"), {
level: "error",
contexts: { inquiry: { templateId: CARD_LIMIT_TEMPLATE, referenceId: credentialId } },
});
return c.json({ code: "already approved" }, 400);
case "noTemplate":
return c.json({ code: "not started" }, 400);
case "noInquiry": {
const basicAccount = await getAccount(credentialId, "basic").catch((error: unknown) => {
captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } });
});
const { data } = await createInquiry(
credentialId,
CARD_LIMIT_TEMPLATE,
redirectURI,
basicAccount
? {
"name-first": basicAccount.attributes["name-first"],
"name-last": basicAccount.attributes["name-last"],
}
: undefined,
);
return c.json(await generateInquiryTokens(data.id), 200);
}
case "completed":
case "needs_review":
return c.json({ code: "processing" }, 400);
case "pending":
case "created":
case "expired":
return c.json(await generateInquiryTokens(cardLimit.id), 200);
case "failed":
case "declined":
return c.json({ code: "failed" }, 400);
default:
throw new Error("unknown inquiry status");
}
}

let inquiryTemplateId: Awaited<ReturnType<typeof getPendingInquiryTemplate>>;
try {
inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope);
Expand All @@ -157,8 +248,7 @@ export default new Hono()
const inquiry = await getInquiry(credentialId, inquiryTemplateId);
if (!inquiry) {
const { data } = await createInquiry(credentialId, inquiryTemplateId, redirectURI);
const { inquiryId, sessionToken } = await generateInquiryTokens(data.id);
return c.json({ inquiryId, sessionToken }, 200);
return c.json(await generateInquiryTokens(data.id), 200);
}

switch (inquiry.attributes.status) {
Expand All @@ -173,15 +263,13 @@ export default new Hono()
return c.json({ code: "failed", legacy: "kyc failed" }, 400);
case "completed":
case "needs_review":
return c.json({ code: "failed", legacy: "kyc failed" }, 400); // TODO send a different response
return c.json({ code: "processing", legacy: "kyc failed" }, 400);
Comment thread
aguxez marked this conversation as resolved.
case "pending":
case "created":
case "expired": {
const { inquiryId, sessionToken } = await generateInquiryTokens(inquiry.id);
return c.json({ inquiryId, sessionToken }, 200);
}
case "expired":
return c.json(await generateInquiryTokens(inquiry.id), 200);
default:
throw new Error("Unknown inquiry status");
throw new Error("unknown inquiry status");
}
},
);
Expand Down
96 changes: 91 additions & 5 deletions server/hooks/persona.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { vValidator } from "@hono/valibot-validator";
import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, setContext, setUser } from "@sentry/node";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { Hono } from "hono";
import {
array,
check,
integer,
ip,
isoTimestamp,
literal,
looseObject,
minLength,
minValue,
nullable,
number,
object,
optional,
picklist,
Expand All @@ -23,17 +26,21 @@ import {

import { Address } from "@exactly/common/validation";

import database, { credentials } from "../database/index";
import { createUser } from "../utils/panda";
import database, { cards, credentials } from "../database/index";
import { createUser, updateCard } from "../utils/panda";
import { addCapita, deriveAssociateId } from "../utils/pax";
import {
addDocument,
ADDRESS_TEMPLATE,
CARD_LIMIT_CASE_TEMPLATE,
CARD_LIMIT_TEMPLATE,
CRYPTOMATE_TEMPLATE,
getInquiryById,
headerValidator,
MANTECA_TEMPLATE_EXTRA_FIELDS,
MANTECA_TEMPLATE_WITH_ID_CLASS,
PANDA_TEMPLATE,
updateCardLimit,
} from "../utils/persona";
import { customer } from "../utils/sardine";
import validatorHook from "../utils/validatorHook";
Expand Down Expand Up @@ -184,6 +191,29 @@ export default new Hono().post(
}),
transform((payload) => ({ template: "manteca" as const, ...payload })),
),
pipe(
object({
data: object({
type: literal("case"),
id: string(),
attributes: object({
status: picklist(["Approved", "Declined", "Open", "Pending"]),
fields: looseObject({
cardLimitUsd: optional(
object({ type: literal("integer"), value: nullable(pipe(number(), integer(), minValue(1))) }),
),
Comment thread
aguxez marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
}),
}),
relationships: object({
caseTemplate: object({ data: object({ id: literal(CARD_LIMIT_CASE_TEMPLATE) }) }),
inquiries: object({
data: array(object({ type: literal("inquiry"), id: string() })),
}),
}),
}),
}),
transform((payload) => ({ template: "cardLimit" as const, ...payload })),
),
pipe(
object({
data: object({
Expand All @@ -192,7 +222,12 @@ export default new Hono().post(
relationships: object({
inquiryTemplate: object({
data: object({
id: picklist([ADDRESS_TEMPLATE, CRYPTOMATE_TEMPLATE, MANTECA_TEMPLATE_EXTRA_FIELDS]),
id: picklist([
ADDRESS_TEMPLATE,
CARD_LIMIT_TEMPLATE,
CRYPTOMATE_TEMPLATE,
MANTECA_TEMPLATE_EXTRA_FIELDS,
]),
}),
}),
}),
Expand All @@ -210,7 +245,58 @@ export default new Hono().post(
const payload = c.req.valid("json").data.attributes.payload;

if (payload.template === "ignored") return c.json({ code: "ok" }, 200);

if (payload.template === "cardLimit") {
getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, "persona.case.card-limit");
if (payload.data.attributes.status !== "Approved") return c.json({ code: "ok" }, 200);
Comment thread
aguxez marked this conversation as resolved.
const limitUsd = payload.data.attributes.fields.cardLimitUsd?.value;
if (limitUsd == null) return c.json({ code: "no limit" }, 200);
const inquiryId = payload.data.relationships.inquiries.data[0]?.id;
if (!inquiryId) return c.json({ code: "no inquiry" }, 200);
const referenceId = await getInquiryById(inquiryId).then((r) => r.data.attributes["reference-id"]);
Comment thread
sentry[bot] marked this conversation as resolved.
const credential = await database.query.credentials.findFirst({
columns: { pandaId: true },
where: eq(credentials.id, referenceId),
with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]), limit: 1 } },
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
});
if (!credential) {
captureException(new Error("no credential"), { level: "error", contexts: { credential: { referenceId } } });
return c.json({ code: "ok" }, 200);
}
await updateCardLimit(referenceId, limitUsd).catch((error: unknown) => {
Comment thread
aguxez marked this conversation as resolved.
captureException(error, {
level: "error",
contexts: {
Comment thread
aguxez marked this conversation as resolved.
cardLimitDrift: {
referenceId,
limitUsd,
pandaId: credential.pandaId ?? null,
cardId: credential.cards[0]?.id ?? null,
},
},
});
throw error;
});
Comment thread
aguxez marked this conversation as resolved.
if (credential.pandaId && credential.cards[0]) {
await updateCard({
id: credential.cards[0].id,
limit: { amount: limitUsd * 100, frequency: "per7DayPeriod" },
}).catch((error: unknown) => {
captureException(error, {
level: "error",
contexts: {
cardLimitDrift: {
referenceId,
limitUsd,
pandaId: credential.pandaId,
cardId: credential.cards[0]?.id ?? null,
},
},
});
throw error;
});
}
return c.json({ code: "ok" }, 200);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
if (payload.template === "manteca") {
getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, "persona.inquiry.manteca");
await addDocument(payload.data.attributes.referenceId, {
Expand Down
Loading