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

✨ add activity and push notification on card decline
56 changes: 56 additions & 0 deletions server/hooks/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,9 @@ export default new Hono().post(
const mutex = getMutex(account);
mutex?.release();
setContext("mutex", { locked: mutex?.isLocked() });

void handleDeclinedTransaction(account, payload as v.InferOutput<typeof Transaction>, jsonBody);

trackTransactionRejected(account, payload, card.mode);
feedback({
kind: "issuing",
Expand Down Expand Up @@ -950,3 +953,56 @@ const TransactionPayload = v.object(
{ bodies: v.array(v.looseObject({ action: v.string() }), "invalid transaction payload") },
"invalid transaction payload",
);

function handleDeclinedTransaction(
account: Address,
payload: v.InferOutput<typeof Transaction>,
_jsonBody: unknown, // reserved for future use
) {
try {
if (payload.action === "requested" || payload.action === "completed") return;
const { id: transactionId, spend } = payload.body;
if (!transactionId) return;

// const tx = await database.query.transactions.findFirst({
// where: and(eq(transactions.id, transactionId), eq(transactions.cardId, spend.cardId)),
// });
// const createdAt = getCreatedAt(payload) ?? new Date().toISOString();
// const body = { ...(jsonBody as object), createdAt };
// TODO: Enable once UI has proper designs to handle declined transactions in activity
// await (tx
// ? database
// .update(transactions)
// .set({
// payload: {
// ...(tx.payload as object),
// bodies: [...v.parse(TransactionPayload, tx.payload).bodies, body],
// },
// })
// .where(and(eq(transactions.id, transactionId), eq(transactions.cardId, spend.cardId)))
// : database.insert(transactions).values([
// {
// id: transactionId,
// cardId: spend.cardId,
// hashes: [zeroHash],
// payload: {
// bodies: [body],
// type: "panda",
// },
// },
// ]));

Comment on lines +957 to +994
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Declined activity isn’t persisted yet.

The activity upsert is commented out, so no decline activity is stored despite the PR objective. This leaves the feature incomplete. Consider enabling the persistence now (and keeping the push notification), or adjust scope accordingly.

✅ Suggested implementation to persist decline activity
-function handleDeclinedTransaction(
-  account: Address,
-  payload: v.InferOutput<typeof Transaction>,
-  _jsonBody: unknown, // reserved for future use
-) {
+async function handleDeclinedTransaction(
+  account: Address,
+  payload: v.InferOutput<typeof Transaction>,
+  jsonBody: unknown,
+) {
   try {
     if (payload.action === "requested" || payload.action === "completed") return;
     const { id: transactionId, spend } = payload.body;
     if (!transactionId) return;
 
-    // const tx = await database.query.transactions.findFirst({
-    //   where: and(eq(transactions.id, transactionId), eq(transactions.cardId, spend.cardId)),
-    // });
-    // const createdAt = getCreatedAt(payload) ?? new Date().toISOString();
-    // const body = { ...(jsonBody as object), createdAt };
-    // TODO: Enable once UI has proper designs to handle declined transactions in activity
-    // await (tx
-    //   ? database
-    //       .update(transactions)
-    //       .set({
-    //         payload: {
-    //           ...(tx.payload as object),
-    //           bodies: [...v.parse(TransactionPayload, tx.payload).bodies, body],
-    //         },
-    //       })
-    //       .where(and(eq(transactions.id, transactionId), eq(transactions.cardId, spend.cardId)))
-    //   : database.insert(transactions).values([
-    //       {
-    //         id: transactionId,
-    //         cardId: spend.cardId,
-    //         hashes: [zeroHash],
-    //         payload: {
-    //           bodies: [body],
-    //           type: "panda",
-    //         },
-    //       },
-    //     ]));
+    const transactionRecord = await database.query.transactions.findFirst({
+      where: and(eq(transactions.id, transactionId), eq(transactions.cardId, spend.cardId)),
+    });
+    const createdAt = getCreatedAt(payload) ?? new Date().toISOString();
+    const body = { ...(jsonBody as object), createdAt };
+    await (transactionRecord
+      ? database
+          .update(transactions)
+          .set({
+            payload: {
+              ...(transactionRecord.payload as object),
+              bodies: [...v.parse(TransactionPayload, transactionRecord.payload).bodies, body],
+            },
+          })
+          .where(and(eq(transactions.id, transactionId), eq(transactions.cardId, spend.cardId)))
+      : database.insert(transactions).values([
+          {
+            id: transactionId,
+            cardId: spend.cardId,
+            hashes: [zeroHash],
+            payload: {
+              bodies: [body],
+              type: "panda",
+            },
+          },
+        ]));
🤖 Prompt for AI Agents
In `@server/hooks/panda.ts` around lines 957 - 994, The decline activity
persistence was commented out in handleDeclinedTransaction; restore and enable
it by making handleDeclinedTransaction async, reintroducing the transactions
lookup (const tx = await database.query.transactions.findFirst(...) matching
transactionId and spend.cardId), compute createdAt with getCreatedAt(payload) ??
new Date().toISOString(), build body from _jsonBody (cast to object) plus
createdAt, then re-enable the upsert logic that uses database.update(...) when
tx exists (merging bodies via v.parse(TransactionPayload, tx.payload).bodies) or
database.insert(transactions).values([...]) when not, and keep the existing push
notification; ensure any referenced symbols (handleDeclinedTransaction,
transactions, database.update, database.insert, TransactionPayload,
getCreatedAt, zeroHash) are imported/available and adjust variable names (use
_jsonBody) and error handling as needed.

sendPushNotification({
userId: account,
headings: { en: "Exa Card purchase rejected" },
contents: {
en: `Transaction declined: ${(spend.localAmount / 100).toLocaleString(undefined, {
style: "currency",
currency: spend.localCurrency,
})} at ${spend.merchantName.trim()}`,
},
}).catch((error: unknown) => captureException(error, { level: "error" }));
} catch (error: unknown) {
captureException(error, { level: "error" });
}
}
89 changes: 89 additions & 0 deletions server/test/hooks/panda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,95 @@ describe("concurrency", () => {
expect(collectSpendAuthorization.status).toBe(200);
});

// TODO: Enable once UI has proper designs to handle declined transactions in activity
it.todo("inserts declined transaction with empty hashes", async () => {
const cardId = `${account2}-card`;
const txId = "declined-tx-insert";

Comment on lines +1354 to +1355
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Rename txIdtransactionId for naming compliance.

Avoid abbreviations in new identifiers. As per coding guidelines, ...

♻️ Proposed rename
-    const txId = "declined-tx-insert";
+    const transactionId = "declined-tx-insert";
...
-          id: txId,
+          id: transactionId,
...
-    const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, txId) });
+    const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, transactionId) });
...
-      id: txId,
+      id: transactionId,
-    const txId = "declined-tx-update";
+    const transactionId = "declined-tx-update";
...
-          id: txId,
+          id: transactionId,
...
-    const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, txId) });
+    const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, transactionId) });

Also applies to: 1392-1393

🤖 Prompt for AI Agents
In `@server/test/hooks/panda.test.ts` around lines 1354 - 1355, Rename the
abbreviated variable txId to the full transactionId across this test file:
replace the declaration const txId = "declined-tx-insert"; and all its usages
(including the other occurrences referenced around the later block) with const
transactionId = "declined-tx-insert"; and update any assertions, mocks, helper
calls, or test setup that reference txId so names remain consistent (e.g., in
functions/variables that accept transactionId).

const response = await appClient.index.$post({
...authorization,
json: {
...authorization.json,
action: "created",
body: {
...authorization.json.body,
id: txId,
spend: {
...authorization.json.body.spend,
amount: 500,
cardId,
status: "declined",
declinedReason: "insufficient_funds",
},
},
},
});

const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, txId) });

expect(response.status).toBe(200);
expect(transaction).toMatchObject({
id: txId,
cardId,
hashes: [],
payload: {
type: "panda",
bodies: [{ action: "created", body: { spend: { status: "declined" } } }],
},
});
});

// TODO: Enable once UI has proper designs to handle declined transactions in activity
it.todo("appends body to existing transaction when declined", async () => {
const cardId = `${account2}-card`;
const txId = "declined-tx-update";

// first create a pending transaction
await appClient.index.$post({
...authorization,
json: {
...authorization.json,
action: "created",
body: {
...authorization.json.body,
id: txId,
spend: { ...authorization.json.body.spend, amount: 600, cardId },
},
},
});

// then update it as declined
const response = await appClient.index.$post({
...authorization,
json: {
...authorization.json,
action: "updated",
body: {
...authorization.json.body,
id: txId,
spend: {
...authorization.json.body.spend,
amount: 600,
authorizationUpdateAmount: 0,
authorizedAt: new Date().toISOString(),
cardId,
status: "declined",
declinedReason: "merchant_blocked",
},
},
},
});

const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, txId) });

expect(response.status).toBe(200);
expect(transaction?.hashes).toHaveLength(1);
expect(transaction?.payload).toMatchObject({
type: "panda",
bodies: [{ action: "created" }, { action: "updated", body: { spend: { status: "declined" } } }],
});
});

describe("with fake timers", () => {
beforeEach(() => vi.useFakeTimers());

Expand Down