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
16 changes: 15 additions & 1 deletion server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,21 @@ 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 card = await createCard(
credential.pandaId,
SIGNATURE_PRODUCT_ID,
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" } },
});
}),
);
let mode = 0;
try {
if (await autoCredit(account)) mode = 1;
Expand Down
109 changes: 97 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,45 @@ export default new Hono()
setUser({ id: account });
setContext("exa", { credential });

if (scope === "cardLimit") {
const unknownAccount = c.req.valid("query").countryCode
? await getUnknownAccount(credentialId).catch((error: unknown): undefined => {
captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } });
})
: undefined;
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.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.

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 +151,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 thread
aguxez marked this conversation as resolved.
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 +167,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 +184,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 +245,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 +260,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.
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
Loading