From 4bcf82ff646351685907bc1c0a004f0e7446ecd5 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 19 Jan 2026 17:50:45 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=EF=B8=8F=20server:=20add=20activity?= =?UTF-8?q?=20and=20push=20notification=20on=20card=20decline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/honest-peas-stand.md | 5 ++ server/hooks/panda.ts | 56 +++++++++++++++++++++ server/test/hooks/panda.test.ts | 89 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 .changeset/honest-peas-stand.md diff --git a/.changeset/honest-peas-stand.md b/.changeset/honest-peas-stand.md new file mode 100644 index 000000000..e57ca5fbc --- /dev/null +++ b/.changeset/honest-peas-stand.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add activity and push notification on card decline diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 1b29a2cd2..da8454fbf 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -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, jsonBody); + trackTransactionRejected(account, payload, card.mode); feedback({ kind: "issuing", @@ -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, + _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", + // }, + // }, + // ])); + + 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" }); + } +} diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 05e0083c9..8a4bf729f 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -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"; + + 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());