Skip to content
Merged
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/happy-insects-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🥅 improve activity api error context
5 changes: 5 additions & 0 deletions .changeset/yummy-lands-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add authorization declined handler
148 changes: 102 additions & 46 deletions server/api/activity.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
bigint,
boolean,
digits,
flatten,
intersect,
isoTimestamp,
length,
Expand All @@ -34,6 +35,7 @@ import {
type InferOutput,
} from "valibot";
import { decodeFunctionData, zeroHash, type Log } from "viem";
import { anvil } from "viem/chains";

import fixedRate from "@exactly/common/fixedRate";
import chain, {
Expand Down Expand Up @@ -407,7 +409,13 @@ export default new Hono().get(
usdAmount,
};
}
captureException(new Error("bad transaction"), { level: "error", contexts: { cryptomate, panda } });
captureException(new Error("bad transaction"), {
level: "error",
contexts: {
cryptomate: { success: cryptomate.success, ...flatten(cryptomate.issues) },
panda: { success: panda.success, ...flatten(panda.issues) },
},
});
}),
),
...[...deposits, ...repays, ...withdraws].map(({ blockNumber, ...event }) => {
Expand All @@ -420,6 +428,7 @@ export default new Hono().get(
}),
]
.filter(<T>(value: T | undefined): value is T => value !== undefined)
.filter((item) => chain.id === anvil.id || !("status" in item && item.status === "declined"))
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
.toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id));

if (maturity !== undefined && pdf) {
Expand Down Expand Up @@ -535,38 +544,64 @@ const Borrow = object({ maturity: bigint(), assets: bigint(), fee: bigint() });

export const PandaActivity = pipe(
object({
bodies: array(looseObject({ action: picklist(["created", "completed", "updated"]) })),
bodies: array(looseObject({ action: picklist(["completed", "created", "requested", "updated"]) })),
borrows: array(nullable(object({ timestamp: optional(bigint()), events: array(Borrow) }))),
hashes: array(Hash),
type: literal("panda"),
}),
transform(({ bodies, borrows, hashes, type }) => {
const operations = hashes.map((hash, index) => {
const borrow = borrows[index];
const validation = safeParse(
{ 0: DebitActivity, 1: CreditActivity }[borrow?.events.length ?? 0] ?? InstallmentsActivity,
{
...bodies[index],
forceCapture: bodies[index]?.action === "completed" && !bodies.some((b) => b.action === "created"),
type,
hash,
events: borrow?.events,
blockTimestamp: borrow?.timestamp,
},
);
if (validation.success) return validation.output;
throw new Error("bad panda activity");
});
const operations = hashes
.map((hash, index) => {
const borrow = borrows[index];
const validation = safeParse(
{ 0: DebitActivity, 1: CreditActivity }[borrow?.events.length ?? 0] ?? InstallmentsActivity,
{
...bodies[index],
forceCapture: bodies[index]?.action === "completed" && !bodies.some((b) => b.action === "created"),
type,
hash,
events: borrow?.events,
blockTimestamp: borrow?.timestamp,
},
);
if (validation.success) return validation.output;
throw new Error("bad panda activity");
Comment thread
sentry[bot] marked this conversation as resolved.
Comment on lines +558 to +568
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: Declined 'requested' transactions without a body.id are silently filtered out of the activity feed due to a schema validation mismatch, making them invisible to the user.
Severity: HIGH

Suggested Fix

In the reject() function, when creating declinedBody, ensure body.id is always present. Use the existing fallback logic (payload.body.id ?? payload.id) to get a transaction ID and explicitly set it on declinedBody.body.id if it was originally missing. This will ensure the data conforms to the CardActivity schema upon read.

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/activity.ts#L558-L568

Potential issue: The schema for a 'requested' transaction allows `payload.body.id` to be
optional. When such a transaction is declined (e.g., on a frozen card), the system
stores the transaction body without an `id`. However, the activity feed parser
(`CardActivity`) requires `body.id`. This causes a validation failure in
`PandaActivity.transform()`, which is silently caught and results in the declined
transaction being filtered out of the user's activity feed. This leads to a silent data
loss for the user, as the declined transaction record is never displayed.

})
Comment thread
aguxez marked this conversation as resolved.
.filter((p) => p.provider === "panda");

const declined = (function () {
const operation = operations.findLast((b) => b.action === "created" && b.status === "declined");
Comment thread
aguxez marked this conversation as resolved.
if (operation) {
if (operation.reason === "webhook declined") {
const requested = operations.findLast((b) => b.action === "requested");
return requested ? { ...operation, reason: requested.reason } : operation;
Comment thread
aguxez marked this conversation as resolved.
}
return operation;
}
return operations.findLast((b) => b.action === "requested");
Comment thread
aguxez marked this conversation as resolved.
})();
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.

const flow = operations.reduce<{
completed: (typeof operations)[number] | undefined;
created: (typeof operations)[number] | undefined;
updates: (typeof operations)[number][];
}>(
(f, operation) => {
if (operation.action === "updated") f.updates.push(operation);
else if (operation.action === "created" || operation.action === "completed") f[operation.action] = operation;
else throw new Error("bad action");
switch (operation.action) {
case "updated":
f.updates.push(operation);
break;

case "created":
case "completed":
Comment thread
sentry[bot] marked this conversation as resolved.
f[operation.action] = operation;
break;

case "requested":
return f;
Comment thread
aguxez marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
default:
throw new Error("bad action");
}
return f;
},
{ created: undefined, updates: [], completed: undefined },
Expand All @@ -581,7 +616,9 @@ export const PandaActivity = pipe(
timestamp,
merchant: { city, country, name, state },
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
} = details;
const usdAmount = operations.reduce((sum, { usdAmount: amount }) => sum + amount, 0);
const usdAmount = operations
.filter((op) => op.action !== "requested")
.reduce((sum, { usdAmount: amount }) => sum + amount, 0);
const exchangeRate = flow.completed?.exchangeRate ?? [flow.created, ...flow.updates].at(-1)?.exchangeRate;
if (exchangeRate === undefined) throw new Error("no exchange rate");
return {
Comment thread
sentry[bot] marked this conversation as resolved.
Expand All @@ -592,42 +629,55 @@ export const PandaActivity = pipe(
name: name.trim(),
city: city?.trim(),
country: country?.trim(),
state: state?.trim(),
state: state.trim(),
icon: flow.completed?.merchant.icon ?? flow.updates.at(-1)?.merchant.icon,
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
},
operations: operations.filter(({ transactionHash }) => transactionHash !== zeroHash),
timestamp,
type,
settled: !!flow.completed,
usdAmount,
status: declined ? ("declined" as const) : flow.completed ? ("settled" as const) : ("pending" as const),
Comment thread
aguxez marked this conversation as resolved.
Comment thread
aguxez marked this conversation as resolved.
...(declined && { reason: declined.reason ?? "transaction declined" }),
Comment thread
aguxez marked this conversation as resolved.
};
}),
);

const PandaBase = {
type: literal("panda"),
createdAt: pipe(string(), isoTimestamp()),
body: object({
id: string(),
spend: object({
amount: number(),
authorizedAmount: nullish(number()),
currency: literal("usd"),
localAmount: number(),
localCurrency: string(),
merchantCity: nullish(string()),
merchantCountry: nullish(string()),
merchantName: string(),
authorizationUpdateAmount: optional(number()),
enrichedMerchantIcon: optional(string()),
}),
}),
forceCapture: boolean(),
hash: Hash,
};

const CardActivity = pipe(
variant("type", [
object({
type: literal("panda"),
action: picklist(["created", "completed", "updated"]),
createdAt: pipe(string(), isoTimestamp()),
body: object({
Comment thread
aguxez marked this conversation as resolved.
id: string(),
spend: object({
amount: number(),
authorizedAmount: nullish(number()),
currency: literal("usd"),
localAmount: number(),
localCurrency: string(),
merchantCity: nullish(string()),
merchantCountry: nullish(string()),
merchantName: string(),
authorizationUpdateAmount: optional(number()),
enrichedMerchantIcon: optional(string()),
pipe(
variant("action", [
object({ ...PandaBase, action: picklist(["completed", "updated"]) }),
Comment thread
aguxez marked this conversation as resolved.
object({
...PandaBase,
action: literal("created"),
status: optional(literal("declined")),
reason: optional(string()),
}),
}),
forceCapture: boolean(),
hash: Hash,
}),
object({ ...PandaBase, action: literal("requested"), status: literal("declined"), reason: string() }),
]),
),
object({
type: literal("cryptomate"),
operation_id: string(),
Expand Down Expand Up @@ -673,6 +723,7 @@ function transformCard(activity: InferOutput<typeof CardActivity>) {
activity.body.spend.amount === 0 ? 1 : activity.body.spend.localAmount / activity.body.spend.amount;
return {
type: "card" as const,
provider: "panda" as const,
action: activity.action,
id: activity.body.id,
transactionHash: activity.hash,
Expand All @@ -685,13 +736,18 @@ function transformCard(activity: InferOutput<typeof CardActivity>) {
name: activity.body.spend.merchantName,
city: activity.body.spend.merchantCity,
country: activity.body.spend.merchantCountry,
icon: activity.body.spend.enrichedMerchantIcon,
state: "",
icon: activity.body.spend.enrichedMerchantIcon,
},
...((activity.action === "requested" || activity.action === "created") && {
status: activity.status,
reason: activity.reason,
}),
};
}
return {
type: "card" as const,
provider: "cryptomate" as const,
id: activity.operation_id,
transactionHash: activity.hash,
timestamp: activity.data.created_at,
Expand Down
Loading