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

✅ add tests for declined transactions
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

Choose a reason for hiding this comment

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

🔴 PandaActivity throws "invalid flow" for records containing only "requested" bodies

When reject() is called during the "requested" webhook handler (e.g., frozen card at server/hooks/panda.ts:266, InsufficientAccountLiquidity at server/hooks/panda.ts:457), it stores a transaction record with a single body having action: "requested". When the activity API later processes this record through PandaActivity, the flow reducer at server/api/activity.ts:600-601 skips "requested" operations (return f), leaving both flow.created and flow.completed as undefined. This causes details at line 610 to be undefined, and line 611 throws "invalid flow". This throw inside the valibot transform can crash the entire activity API request for the user.

Race condition timeline
  1. "requested" webhook arrives → reject() stores a record with one body (action: "requested", status: "declined")
  2. User queries the activity API → PandaActivity transform throws "invalid flow" because no "created" or "completed" operation exists in the flow
  3. Panda sends "created" webhook with status: "declined"reject() appends a second body → record is now valid

The window between steps 1 and 3 is the bug trigger. If the "created" follow-up webhook never arrives (or arrives to a different server instance), the record is permanently broken.

(Refers to lines 610-611)

Prompt for agents
In server/api/activity.ts, at lines 610-611, the PandaActivity transform throws "invalid flow" when `flow.created` and `flow.completed` are both undefined. This happens when the only operations have action "requested" (which are skipped by the flow reducer at line 600-601). Fix this by handling the declined-only case. When `details` is undefined but `declined` is defined, use the `declined` operation as the details source instead of throwing. For example:

const details = flow.created ?? flow.completed ?? declined;
if (!details) throw new Error("invalid flow");

This ensures records that contain only "requested" declined bodies can be processed correctly without throwing.
Open in Devin Review

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

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

Choose a reason for hiding this comment

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

🚩 Declined filter hides items in production but not test — intentional but fragile

The new filter at server/api/activity.ts:431 uses chain.id === anvil.id to show declined transactions only in the anvil (test) environment. In production, all declined items are silently removed from the response. This is presumably a temporary measure while client-side support for declined transactions is being built. However, this couples the API behavior to the chain ID, meaning staging environments on non-anvil chains would also hide declined items. Consider using a feature flag or explicit query parameter instead of chain ID for more predictable behavior.

Open in Devin Review

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

.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");
})
.filter((p) => p.provider === "panda");

const declined = (function () {
const operation = operations.findLast((b) => b.action === "created" && b.status === "declined");
if (operation) {
if (operation.reason === "webhook declined") {
const requested = operations.findLast((b) => b.action === "requested");
return requested ? { ...operation, reason: requested.reason } : operation;
}
return operation;
}
return operations.findLast((b) => b.action === "requested");
})();

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":
f[operation.action] = operation;
break;

case "requested":
return f;
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 },
} = 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 on lines 616 to 624
Copy link

Choose a reason for hiding this comment

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

Bug: The PandaActivity transformer incorrectly handles declined transactions from frozen cards, causing them to be silently dropped from the activity feed due to an "invalid flow" error.
Severity: HIGH

Suggested Fix

Modify the PandaActivity transformer to correctly handle single-event, declined transactions. The flow reducer should be updated to process "requested" actions with a "declined" status, treating them as a complete flow. This will prevent the "invalid flow" error from being thrown and ensure these transactions are displayed in the activity feed.

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#L616-L624

Potential issue: When a transaction is declined on a frozen card, a record is created
with `action: "requested"` and `status: "declined"`. The `PandaActivity` transformer
processes this record, but its internal flow reducer ignores `"requested"` actions. This
results in both `flow.created` and `flow.completed` being `undefined`. Consequently, a
check for transaction details throws an `"invalid flow"` error. This error is caught by
`safeParse`, which logs a "bad transaction" error to Sentry and silently filters the
transaction out, preventing it from appearing in the user's activity feed.

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,
},
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),

Choose a reason for hiding this comment

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

🚩 Breaking change: settled boolean replaced with status string enum

The PandaActivity output at server/api/activity.ts:639 replaces settled: boolean with status: 'declined' | 'settled' | 'pending'. This is a breaking change for any API consumers that rely on the settled field. Since this is a patch changeset (.changeset/yummy-lands-end.md), existing clients consuming this API may break if they check for settled instead of status. If there are mobile app versions in the wild that read settled, they would stop receiving that field.

Open in Devin Review

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

...(declined && { reason: declined.reason ?? "transaction declined" }),
};
}),
);

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({
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"]) }),
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
Loading