From 4687e868d0d2dd9cdaadd03dd5756e44c3aaa50b Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 16 Feb 2026 17:31:16 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=85=20server:=20update=20contract=20c?= =?UTF-8?q?all=20for=20user=20funds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/hooks/panda.test.ts | 42 ++++++++++----------------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 9419097c2..e236ee088 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -108,21 +108,12 @@ describe("card operations", () => { afterEach(() => panda.getMutex(account)?.release()); it("fails with InsufficientAccountLiquidity", async () => { - const currentFunds = await publicClient - .readContract({ - address: inject("MarketUSDC"), - abi: marketAbi, - functionName: "balanceOf", - args: [account], - }) - .then((shares) => { - return publicClient.readContract({ - address: inject("MarketUSDC"), - abi: marketAbi, - functionName: "convertToAssets", - args: [shares], - }); - }); + const currentFunds = await publicClient.readContract({ + address: inject("MarketUSDC"), + abi: marketAbi, + functionName: "maxWithdraw", + args: [account], + }); const response = await appClient.index.$post({ ...authorization, @@ -1785,21 +1776,12 @@ describe("card operations", () => { it("force capture fraud", async () => { const updateUser = vi.spyOn(panda, "updateUser").mockResolvedValue(userResponseTemplate); - const currentFunds = await publicClient - .readContract({ - address: inject("MarketUSDC"), - abi: marketAbi, - functionName: "balanceOf", - args: [account], - }) - .then((shares) => { - return publicClient.readContract({ - address: inject("MarketUSDC"), - abi: marketAbi, - functionName: "convertToAssets", - args: [shares], - }); - }); + const currentFunds = await publicClient.readContract({ + address: inject("MarketUSDC"), + abi: marketAbi, + functionName: "maxWithdraw", + args: [account], + }); const capture = Number(currentFunds) / 1e4 + 10_000; From 76113dde7a16260224fbfd458d55122b5325dea8 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 16 Feb 2026 17:33:00 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A5=85=20server:=20improve=20activity?= =?UTF-8?q?=20error=20handler=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/activity.ts | 9 ++++++++- server/test/api/activity.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/server/api/activity.ts b/server/api/activity.ts index 9728cbae7..a1bde6491 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -10,6 +10,7 @@ import { bigint, boolean, digits, + flatten, intersect, isoTimestamp, length, @@ -407,7 +408,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 }) => { diff --git a/server/test/api/activity.test.ts b/server/test/api/activity.test.ts index 6c46b4dc2..d6ebac517 100644 --- a/server/test/api/activity.test.ts +++ b/server/test/api/activity.test.ts @@ -229,8 +229,8 @@ describe.concurrent("authenticated", () => { expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment contexts: expect.objectContaining({ - cryptomate: expect.objectContaining({ issues: expect.anything() }), // eslint-disable-line @typescript-eslint/no-unsafe-assignment - panda: expect.objectContaining({ issues: expect.anything() }), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + cryptomate: expect.objectContaining({ success: false }), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + panda: expect.objectContaining({ success: false }), // eslint-disable-line @typescript-eslint/no-unsafe-assignment }), }), ); From 17a217653b9762bad85476a463341dc744aa6188 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 16 Feb 2026 17:35:38 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20server:=20add=20authorization?= =?UTF-8?q?=20declined=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/yummy-lands-end.md | 5 + server/api/activity.ts | 137 +++++++++----- server/hooks/panda.ts | 122 ++++++++++++- server/test/api/activity.test.ts | 304 ++++++++++++++++++++++++------- 4 files changed, 450 insertions(+), 118 deletions(-) create mode 100644 .changeset/yummy-lands-end.md diff --git a/.changeset/yummy-lands-end.md b/.changeset/yummy-lands-end.md new file mode 100644 index 000000000..c6d9a120d --- /dev/null +++ b/.changeset/yummy-lands-end.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add authorization declined handler diff --git a/server/api/activity.ts b/server/api/activity.ts index a1bde6491..40877a5db 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -542,28 +542,42 @@ 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; @@ -571,9 +585,21 @@ export const PandaActivity = pipe( 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 }, @@ -588,7 +614,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 { @@ -599,42 +627,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), + ...(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(), @@ -680,6 +721,7 @@ function transformCard(activity: InferOutput) { 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, @@ -692,13 +734,18 @@ function transformCard(activity: InferOutput) { 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, diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 348e55731..20b8887d5 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -11,7 +11,7 @@ import { } from "@sentry/node"; import { E_TIMEOUT } from "async-mutex"; import createDebug from "debug"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { Hono } from "hono"; import * as v from "valibot"; import { @@ -251,13 +251,27 @@ export default new Hono().post( switch (payload.action) { case "requested": { const card = await database.query.cards.findFirst({ - columns: { mode: true }, - where: and(eq(cards.id, payload.body.spend.cardId), eq(cards.status, "ACTIVE")), + columns: { mode: true, status: true }, + where: eq(cards.id, payload.body.spend.cardId), with: { credential: { columns: { account: true, id: true, source: true } } }, }); if (!card) return c.json({ code: "card not found" }, 404); + const account = v.parse(Address, card.credential.account); setUser({ id: account }); + + if (card.status === "FROZEN") { + trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "frozen-card"); + + await reject(account, payload, jsonBody, "frozen_card"); + + return c.json({ code: "frozen card" }, 403 as UnofficialStatusCode); + } + + if (card.status !== "ACTIVE") { + trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "card-not-active"); + return c.json({ code: "card not active" }, 403); + } const assess = () => { return risk({ sessionKey: payload.body.id ?? payload.id, @@ -314,7 +328,7 @@ export default new Hono().post( try { const { amount, call, transaction } = await prepareCollection(card, payload); const authorize = () => { - trackTransactionAuthorized(account, payload, card.mode, card.credential.source); + trackAuthorized(account, payload, card.mode, card.credential.source); return c.json({ code: "ok" }); }; if (!transaction) { @@ -438,10 +452,18 @@ export default new Hono().post( if (error.statusCode !== (557 as UnofficialStatusCode)) { captureException(error, { level: "error", tags: { unhandled: true } }); } + + if (error.message !== "Replay" && error.message !== "tx reverted") { + await reject(account, payload, jsonBody, error.message); + } + return c.json({ code: error.message }, error.statusCode as UnofficialStatusCode); } trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "unexpected-error"); captureException(error, { level: "error", tags: { unhandled: true } }); + + await reject(account, payload, jsonBody, error instanceof Error ? error.message : "unexpected error"); + return c.json({ code: "ouch" }, 569 as UnofficialStatusCode); } } @@ -540,7 +562,7 @@ export default new Hono().post( en: `${refundAmountUsd} USDC from ${payload.body.spend.merchantName.trim()} have been refunded to your account`, }, }).catch((error: unknown) => captureException(error)); - trackTransactionRefund(account, refundAmountUsd, payload, card.credential.source); + trackRefund(account, refundAmountUsd, payload, card.credential.source); if (payload.action === "completed") { if (payload.body.spend.amount < 0) { feedback({ @@ -637,7 +659,10 @@ export default new Hono().post( const mutex = getMutex(account); mutex?.release(); setContext("mutex", { locked: mutex?.isLocked() }); - trackTransactionRejected(account, payload, card.mode, card.credential.source); + + await reject(account, payload, jsonBody, payload.body.spend.declinedReason ?? "transaction declined"); + + trackRejected(account, payload, card.mode, card.credential.source); feedback({ kind: "issuing", customer: { id: card.credential.id }, @@ -912,7 +937,7 @@ export default new Hono().post( }, ); -function trackTransactionAuthorized( +function trackAuthorized( account: Address, payload: v.InferOutput, cardMode: number, @@ -961,7 +986,7 @@ function trackAuthorizationRejected( }); } -function trackTransactionRejected( +function trackRejected( account: Address, payload: v.InferOutput, cardMode: number, @@ -991,7 +1016,7 @@ function trackTransactionRejected( }); } -function trackTransactionRefund( +function trackRefund( account: Address, refundAmountUsd: number, payload: v.InferOutput, @@ -1168,3 +1193,82 @@ const TransactionPayload = v.object( { bodies: v.array(v.looseObject({ action: v.string() }), "invalid transaction payload") }, "invalid transaction payload", ); + +async function sendDeclinedNotification( + account: Address, + spend: v.InferOutput["body"]["spend"], + reason: string, +) { + let formattedAmount: string; + try { + formattedAmount = (spend.localAmount / 100).toLocaleString(undefined, { + style: "currency", + currency: spend.localCurrency, + }); + } catch { + formattedAmount = `${spend.localCurrency.toUpperCase()} ${(spend.localAmount / 100).toFixed(2)}`; + } + + await sendPushNotification({ + userId: account, + headings: { en: "Exa Card purchase rejected" }, + contents: { + en: `Transaction at ${spend.merchantName.trim()} for ${formattedAmount} rejected: ${reason}`, + }, + }); +} + +const declineReasons: Record = { + InsufficientAccountLiquidity: { reason: "insufficient funds" as const, notify: true }, + insufficient_funds: { reason: "insufficient funds" as const, notify: true }, + merchant_blocked: { reason: "merchant blocked" as const, notify: true }, + frozen_card: { reason: "frozen card" as const, notify: true }, + "webhook declined": { reason: "webhook declined" as const, notify: false }, +} as const; + +async function reject( + account: Address, + payload: v.InferOutput, + jsonBody: unknown, + declineReason: string, +) { + const { spend } = payload.body; + const transactionId = payload.body.id ?? payload.id; + + const { reason, notify } = + declineReasons[declineReason] ?? ({ reason: "transaction declined", notify: false } as const); + + const createdAt = getCreatedAt(payload) ?? new Date().toISOString(); + const declinedBody = { ...(jsonBody as object), createdAt, status: "declined" as const, reason }; + + return database + .insert(transactions) + .values({ + id: transactionId, + cardId: spend.cardId, + hashes: [zeroHash], + payload: { bodies: [declinedBody], type: "panda" }, + }) + .onConflictDoUpdate({ + target: transactions.id, + set: { + hashes: sql`${transactions.hashes} || ARRAY[${zeroHash}]::text[]`, + payload: sql`jsonb_set( + ${transactions.payload}, + '{bodies}', + COALESCE(${transactions.payload}::jsonb->'bodies', '[]'::jsonb) || ${JSON.stringify([declinedBody])}::jsonb + )`, + }, + }) + .returning({ isNew: sql`xmax = 0` }) + .then((result) => { + if (result[0]?.isNew && notify) { + sendDeclinedNotification(account, spend, reason).catch((error: unknown) => { + captureException(error, { level: "error" }); + }); + } + }) + .catch((error: unknown) => { + captureException(error, { level: "error" }); + }); +} diff --git a/server/test/api/activity.test.ts b/server/test/api/activity.test.ts index d6ebac517..07d418c5e 100644 --- a/server/test/api/activity.test.ts +++ b/server/test/api/activity.test.ts @@ -7,12 +7,13 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; +import assert from "node:assert"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { safeParse, type InferOutput } from "valibot"; import { padHex, zeroHash, type Hash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; -import { afterEach, assert, beforeAll, describe, expect, inject, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; import { marketAbi } from "@exactly/common/generated/chain"; @@ -21,6 +22,24 @@ import app, { CreditActivity, DebitActivity, InstallmentsActivity, PandaActivity import database, { cards, transactions } from "../../database"; import anvilClient from "../anvilClient"; +function httpSerialize(object: T): T { + const cloned = structuredClone(object); + return removeUndefined(cloned) as T; +} + +function removeUndefined(object: unknown): unknown { + if (object === null || typeof object !== "object") return object; + if (Array.isArray(object)) return object.map((value) => removeUndefined(value)); + + const result: Record = {}; + for (const [key, value] of Object.entries(object)) { + if (value !== undefined) { + result[key] = removeUndefined(value); + } + } + return result; +} + const appClient = testClient(app); const account = deriveAddress(inject("ExaAccountFactory"), { x: padHex(privateKeyToAddress(padHex("0xb0b"))), @@ -79,7 +98,7 @@ describe.concurrent("authenticated", () => { fromBlock: 0n, strict: true, }); - assert(borrows[0], "expected at least one BorrowAtMaturity event"); + assert.ok(borrows[0], "expected at least one BorrowAtMaturity event"); maturity = String(borrows[0].args.maturity); const logs = [ ...borrows, @@ -99,65 +118,107 @@ describe.concurrent("authenticated", () => { ), ).then((blocks) => new Map(blocks.map(({ number, timestamp }) => [number, timestamp]))); const txs = [ - ...logs.reduce((m, { args, transactionHash: h, ...v }) => { - const d = m.get(h) ?? { ...v, events: [] as (typeof logs)[number]["args"][] }; - return m.set(h, (d.events.push(args), d)); - }, new Map()), - ].map(([hash, { blockNumber, eventName, events }], index) => { - const blockTimestamp = timestamps.get(blockNumber)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion - const total = events.reduce((sum, { assets }) => sum + assets, 0n); - const createdAt = new Date(Number(blockTimestamp) * 1000).toISOString(); - const { payload, hashes } = - index === 0 - ? { - hashes: [hash] as [Hash], - payload: { - operation_id: String(index), - type: "cryptomate", - data: { - created_at: createdAt, - bill_amount: Number(total) / 1e6, - transaction_amount: (1200 * Number(total)) / 1e6, - transaction_currency_code: "ARS", - merchant_data: { name: "Merchant", country: "ARG", city: "Buenos Aires", state: "BA" }, + ...[ + ...logs.reduce((m, { args, transactionHash: h, ...v }) => { + const d = m.get(h) ?? { ...v, events: [] as (typeof logs)[number]["args"][] }; + return m.set(h, (d.events.push(args), d)); + }, new Map()), + ].map(([hash, { blockNumber, eventName, events }], index) => { + const blockTimestamp = timestamps.get(blockNumber)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + const total = events.reduce((sum, { assets }) => sum + assets, 0n); + const createdAt = new Date(Number(blockTimestamp) * 1000).toISOString(); + const { payload, hashes } = + index === 0 + ? { + hashes: [hash] as [Hash], + payload: { + operation_id: String(index), + type: "cryptomate", + data: { + created_at: createdAt, + bill_amount: Number(total) / 1e6, + transaction_amount: (1200 * Number(total)) / 1e6, + transaction_currency_code: "ARS", + merchant_data: { name: "Merchant", country: "ARG", city: "Buenos Aires", state: "BA" }, + }, }, - }, - } - : { - hashes: index === 1 ? ([hash] as [Hash]) : ([hash, zeroHash] as [Hash, Hash]), - payload: { - type: "panda", - bodies: (index === 1 ? ["completed"] : ["created", "completed"]).map((action) => ({ - action, - resource: "transaction", - createdAt, - body: { - id: String(index), - type: "spend", - spend: { - ...spendTemplate, - amount: Number(total) / 1e4, - localAmount: (1200 * Number(total)) / 1e4, - ...(action === "completed" && { - enrichedMerchantIcon: "https://storage.googleapis.com/icon/icon.png", - }), + } + : { + hashes: index === 1 ? ([hash] as [Hash]) : ([hash, zeroHash] as [Hash, Hash]), + payload: { + type: "panda", + bodies: (index === 1 ? ["completed"] : ["created", "completed"]).map((action) => ({ + action, + resource: "transaction", + createdAt, + body: { + id: String(index), + spend: { + ...spendTemplate, + amount: Number(total) / 1e4, + localAmount: (1200 * Number(total)) / 1e4, + ...(action === "completed" && { + enrichedMerchantIcon: "https://storage.googleapis.com/icon/icon.png", + }), + }, }, - }, - })), - }, - }; - return { - id: String(index), + })), + }, + }; + return { + id: String(index), + cardId: "activity", + hashes, + payload, + hash, + blockNumber, + eventName, + events, + blockTimestamp, + }; + }), + { + id: "transaction-declined", cardId: "activity", - hashes, - payload, - hash, - blockNumber, - eventName, - events, - blockTimestamp, - }; - }); + hashes: [zeroHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "request declined", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + id: "aa8b527b-c508-4550-954d-f85a25335113", + body: { + id: "ac89fe24-8c17-48d5-b0df-efdd80695ed4", + type: "spend", + spend: { ...spendTemplate, amount: 1500, localAmount: 1500, localCurrency: "usd" }, + }, + action: "created", + resource: "transaction", + createdAt: new Date(0).toISOString(), + status: "declined" as const, + reason: "insufficient funds" as const, + }, + { + id: "bb8b527b-c508-4550-954d-f85a25335114", + body: { + id: "ac89fe24-8c17-48d5-b0df-efdd80695ed4", + type: "spend", + spend: { ...spendTemplate, amount: 1500, localAmount: 1500, localCurrency: "usd" }, + }, + action: "requested", + resource: "transaction", + createdAt: new Date(0).toISOString(), + status: "declined" as const, + reason: "insufficient funds" as const, + }, + ], + }, + }, + ]; await database .insert(transactions) @@ -168,7 +229,12 @@ describe.concurrent("authenticated", () => { const panda = safeParse(PandaActivity, { ...(payload as object), hashes, - borrows: eventName === "Withdraw" ? [null] : [{ blockNumber, events }], + borrows: + eventName === "Withdraw" + ? hashes.map(() => null) + : hashes.map((currentHash) => + currentHash === hash && events.length > 0 ? { timestamp: blockTimestamp, events } : null, + ), }); if (panda.success) return panda.output; const eventCount = eventName === "Withdraw" ? 0 : events.length; @@ -191,7 +257,22 @@ describe.concurrent("authenticated", () => { ); expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual(activity); + await expect(response.json()).resolves.toMatchObject(httpSerialize(activity)); + }); + + it("returns declined transaction in http response", async () => { + const txId = "ac89fe24-8c17-48d5-b0df-efdd80695ed4"; + expect.hasAssertions(); + const response = await appClient.index.$get( + { query: { include: "card" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as { id: string }[]; + const declined = json.find(({ id }) => id === txId); + assert.ok(declined, "expected declined transaction in response"); + expect(declined).toStrictEqual(httpSerialize(activity.find(({ id }) => id === txId))); }); it("accepts panda activity with zero exchange rate", () => { @@ -213,6 +294,7 @@ describe.concurrent("authenticated", () => { if (!panda.success) return; expect(panda.output.amount).toBe(0); expect(panda.output.usdAmount).toBe(1); + expect(panda.output.status).toBe("pending"); }); it("reports bad transaction", async () => { @@ -235,7 +317,7 @@ describe.concurrent("authenticated", () => { }), ); expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual(activity); + await expect(response.json()).resolves.toMatchObject(httpSerialize(activity)); }); it("filters by maturity", async () => { @@ -299,7 +381,7 @@ describe.concurrent("authenticated", () => { database.query.credentials.findMany({ columns: { id: true } }), ]); const otherCredential = credentials.find(({ id }) => id !== "bob"); - assert(otherCredential, "expected another credential"); + assert.ok(otherCredential, "expected another credential"); const borrows = await anvilClient.getContractEvents({ abi: marketAbi, eventName: "BorrowAtMaturity", @@ -318,7 +400,7 @@ describe.concurrent("authenticated", () => { columns: { hashes: true, payload: true }, }); const source = transactionsByHash.find(({ hashes }) => hashes.some((hash) => borrowHashes.has(hash as Hash))); - assert(source, "expected source transaction"); + assert.ok(source, "expected source transaction"); const leak = { cardId: `leak-card-${Date.now()}`, @@ -368,7 +450,7 @@ describe.concurrent("authenticated", () => { fromBlock: 0n, strict: true, }); - assert(repays[0], "expected at least one RepayAtMaturity event"); + assert.ok(repays[0], "expected at least one RepayAtMaturity event"); const response = await appClient.index.$get( { query: { include: "received", maturity: String(repays[0].args.maturity) } }, { headers: { "test-credential-id": "bob" } }, @@ -443,6 +525,100 @@ describe.concurrent("authenticated", () => { ]), ); }); + + describe("declined transactions", () => { + it("parses declined transaction with created action", () => { + const result = safeParse(PandaActivity, { + type: "panda", + hashes: [zeroHash], + borrows: [null], + bodies: [ + { + action: "created", + createdAt: "2024-01-15T10:30:00.000Z", + status: "declined", + reason: "insufficient funds", + body: { + id: "declined-tx-1", + spend: { ...spendTemplate, amount: 1000, localAmount: 1000, localCurrency: "usd" }, + }, + }, + ], + }); + + expect(result.success).toBe(true); + assert.ok(result.success); + expect(result.output).toStrictEqual({ + id: "declined-tx-1", + type: "panda", + status: "declined", + reason: "insufficient funds", + currency: "USD", + amount: 10, + usdAmount: 10, + merchant: { + name: "once", + city: "Buenos Aires", + country: "ARG", + icon: undefined, + state: "", + }, + operations: [], + timestamp: "2024-01-15T10:30:00.000Z", + }); + }); + + it("parses declined transaction with requested action alongside created", () => { + const result = safeParse(PandaActivity, { + type: "panda", + hashes: [zeroHash, zeroHash], + borrows: [null, null], + bodies: [ + { + action: "created", + createdAt: "2024-01-15T11:00:00.000Z", + status: "declined", + reason: "merchant blocked", + body: { + id: "declined-tx-2", + spend: { ...spendTemplate, amount: 500, localAmount: 500, localCurrency: "usd" }, + }, + }, + { + action: "requested", + createdAt: "2024-01-15T10:59:00.000Z", + status: "declined", + reason: "merchant blocked", + body: { + id: "declined-tx-2", + spend: { ...spendTemplate, amount: 500, localAmount: 500, localCurrency: "usd" }, + }, + }, + ], + }); + + expect(result.success).toBe(true); + assert.ok(result.success); + expect(result.output).toStrictEqual({ + id: "declined-tx-2", + type: "panda", + status: "declined", + reason: "merchant blocked", + currency: "USD", + amount: 5, + usdAmount: 5, + merchant: { + name: "once", + city: "Buenos Aires", + country: "ARG", + icon: undefined, + state: "", + }, + operations: [], + timestamp: "2024-01-15T11:00:00.000Z", + }); + }); + }); }); vi.mock("@sentry/node", { spy: true }); From 95e2d11cc379ca5bc465148c5b9cba5662707fd9 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 16 Feb 2026 17:38:15 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=85=20server:=20add=20tests=20for=20d?= =?UTF-8?q?eclined=20transactions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/late-dingos-sit.md | 5 + server/test/hooks/panda.test.ts | 373 +++++++++++++++++++++++++++++++- 2 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 .changeset/late-dingos-sit.md diff --git a/.changeset/late-dingos-sit.md b/.changeset/late-dingos-sit.md new file mode 100644 index 000000000..0172316e9 --- /dev/null +++ b/.changeset/late-dingos-sit.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✅ add tests for declined transactions diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index e236ee088..a29e37a7f 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -48,6 +48,7 @@ import { proposalManager } from "@exactly/plugin/deploy.json"; import database, { cards, credentials, transactions } from "../../database"; import app from "../../hooks/panda"; import keeper from "../../utils/keeper"; +import * as onesignal from "../../utils/onesignal"; import * as panda from "../../utils/panda"; import publicClient from "../../utils/publicClient"; import * as sardine from "../../utils/sardine"; @@ -539,8 +540,9 @@ describe("card operations", () => { const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, cardId) }); await Promise.all( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - transaction!.hashes.map((h) => publicClient.waitForTransactionReceipt({ hash: h as Hex, confirmations: 0 })), + (transaction?.hashes ?? []).map((txHash) => + publicClient.waitForTransactionReceipt({ hash: txHash as Hex, confirmations: 0 }), + ), ); expect(createResponse.status).toBe(200); @@ -2046,6 +2048,224 @@ describe("concurrency", () => { expect(collectSpendAuthorization.status).toBe(200); }); + it("inserts declined transaction with zero-hash placeholder", 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: [zeroHash], + payload: { + type: "panda", + bodies: [ + { + action: "created", + status: "declined", + reason: "insufficient funds", + body: { spend: { status: "declined" } }, + }, + ], + }, + }); + }); + + it("appends body to existing transaction when declined", async () => { + const cardId = `${account2}-card`; + const txId = "declined-tx-update"; + + await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { ...authorization.json.body.spend, amount: 600, cardId }, + }, + }, + }); + + 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(2); + expect(transaction?.hashes[1]).toBe(zeroHash); + expect(transaction?.payload).toMatchObject({ + type: "panda", + bodies: [ + { action: "created" }, + { action: "updated", status: "declined", reason: "merchant blocked", body: { spend: { status: "declined" } } }, + ], + }); + }); + + it("preserves correct body structure with interleaved pending and declined events", async () => { + const txId = "interleaved-events-test"; + const cardId = `${account2}-card`; + + const post = (action: "created" | "updated", status: "declined" | "pending", declinedReason?: string) => + appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action, + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + amount: 1000, + cardId, + status, + ...(action === "updated" && { authorizationUpdateAmount: 0, authorizedAt: new Date().toISOString() }), + ...(declinedReason && { declinedReason }), + }, + }, + } as unknown as typeof authorization.json, + }); + + await post("created", "pending"); + await post("updated", "pending"); + await post("updated", "declined", "insufficient_funds"); + await post("updated", "declined", "merchant_blocked"); + + const transaction = await database.query.transactions.findFirst({ where: eq(transactions.id, txId) }); + const bodies = (transaction?.payload as { bodies: { action: string; reason?: string; status?: string }[] }).bodies; + + expect(transaction?.hashes).toHaveLength(4); + expect(bodies).toHaveLength(4); + expect(bodies[0]).toMatchObject({ action: "created" }); + expect(bodies[1]).toMatchObject({ action: "updated" }); + expect(bodies[2]).toMatchObject({ action: "updated", reason: "insufficient funds", status: "declined" }); + expect(bodies[3]).toMatchObject({ action: "updated", reason: "merchant blocked", status: "declined" }); + expect(bodies[0]).not.toHaveProperty("status"); + expect(bodies[0]).not.toHaveProperty("reason"); + expect(bodies[1]).not.toHaveProperty("status"); + expect(bodies[1]).not.toHaveProperty("reason"); + }); + + it("declines created transaction with correct reason", async () => { + const cardId = `${account2}-card`; + const txId = "decline-created-test"; + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + amount: 499, + 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?.payload).toMatchObject({ + type: "panda", + bodies: [{ action: "created", status: "declined", reason: "merchant blocked" }], + }); + }); + + it("merges declined created event with prior pending created event", async () => { + const cardId = `${account2}-card`; + const txId = "decline-created-merge-test"; + + await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { ...authorization.json.body.spend, amount: 499, cardId, status: "pending" }, + }, + }, + }); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + amount: 499, + 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?.payload).toMatchObject({ + type: "panda", + bodies: [{ action: "created" }, { action: "created", status: "declined", reason: "insufficient funds" }], + }); + }); + describe("with fake timers", () => { beforeEach(() => vi.useFakeTimers()); @@ -2102,6 +2322,155 @@ describe("concurrency", () => { expect(mutex?.isLocked()).toBe(true); }); }); + + describe("push notifications", () => { + it("sends notification when transaction request fails with InsufficientAccountLiquidity", async () => { + const sendPushNotificationSpy = vi.spyOn(onesignal, "sendPushNotification"); + const txId = "insufficient-liquidity-notification-test"; + + const maxWithdraw = await publicClient.readContract({ + address: inject("MarketUSDC"), + abi: marketAbi, + functionName: "maxWithdraw", + args: [account], + }); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + body: { + ...authorization.json.body, + id: txId, + spend: { ...authorization.json.body.spend, cardId: "card", amount: Number(maxWithdraw) / 1e4 + 100 }, + }, + }, + }); + + expect(response.status).toBe(557); + await vi.waitFor(() => expect(sendPushNotificationSpy).toHaveBeenCalled()); + const call = sendPushNotificationSpy.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + userId: account, + headings: { en: "Exa Card purchase rejected" }, + }); + expect(call?.contents.en).toContain("Transaction at 99999 for $9.00 rejected: insufficient funds"); + }); + + it("sends notification when declined transaction is completed", async () => { + const sendPushNotificationSpy = vi.spyOn(onesignal, "sendPushNotification"); + + const cardId = `${account2}-card`; + const txId = "declined-notification-test"; + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + amount: 700, + cardId, + status: "declined", + declinedReason: "merchant_blocked", + }, + }, + }, + }); + + expect(response.status).toBe(200); + expect(sendPushNotificationSpy).toHaveBeenCalled(); + const call = sendPushNotificationSpy.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + userId: account2, + headings: { en: "Exa Card purchase rejected" }, + }); + expect(call?.contents.en).toContain("Transaction at 99999 for $9.00 rejected: merchant blocked"); + }); + + it("does not send notification for unrecognized decline reason", async () => { + const sendPushNotificationSpy = vi.spyOn(onesignal, "sendPushNotification"); + + const cardId = `${account2}-card`; + const txId = "unrecognized-decline-notification-test"; + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + amount: 700, + cardId, + status: "declined", + declinedReason: "account credit limit exceeded", + }, + }, + }, + }); + + expect(response.status).toBe(200); + expect(sendPushNotificationSpy).not.toHaveBeenCalled(); + }); + + it("does not send duplicate notifications for concurrent declined transactions", async () => { + const sendPushNotificationSpy = vi.spyOn(onesignal, "sendPushNotification"); + + const cardId = `${account2}-card`; + const txId = `concurrent-declined-${crypto.randomUUID()}`; + + const payload = { + ...authorization, + json: { + ...authorization.json, + action: "created" as const, + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + amount: 500, + cardId, + status: "declined" as const, + declinedReason: "insufficient_funds", + }, + }, + }, + }; + + await Promise.all([appClient.index.$post(payload), appClient.index.$post(payload)]); + + expect(sendPushNotificationSpy).toHaveBeenCalledTimes(1); + }); + + it("does not send notification for unknown error", async () => { + const sendPushNotificationSpy = vi.spyOn(onesignal, "sendPushNotification"); + + vi.spyOn(traceClient, "traceCall").mockRejectedValueOnce(new Error("unexpected trace error")); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + body: { + ...authorization.json.body, + spend: { ...authorization.json.body.spend, cardId: "card", amount: 100 }, + }, + }, + }); + + expect(response.status).toBe(569); + expect(sendPushNotificationSpy).not.toHaveBeenCalled(); + }); + }); }); const authorization = { From 9e5525bc411008418d95dfa1e12c2fc52a461df7 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Fri, 6 Mar 2026 15:19:27 -0300 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A6=96=20server:=20filter=20out=20dec?= =?UTF-8?q?lined=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/activity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/api/activity.ts b/server/api/activity.ts index 40877a5db..b4f02ca79 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -35,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, { @@ -427,6 +428,7 @@ export default new Hono().get( }), ] .filter((value: T | undefined): value is T => value !== undefined) + .filter((item) => chain.id === anvil.id || !("status" in item && item.status === "declined")) .toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id)); if (maturity !== undefined && pdf) {