diff --git a/.changeset/many-steaks-slide.md b/.changeset/many-steaks-slide.md new file mode 100644 index 0000000000..f487524551 --- /dev/null +++ b/.changeset/many-steaks-slide.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ expose unknown decline reasons diff --git a/server/api/activity.ts b/server/api/activity.ts index ff2235cd49..bb92436d4e 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -524,17 +524,17 @@ export const PandaActivity = pipe( .map((hash, index) => { const borrow = borrows[index]; const body = bodies[index]; - const declinedReason = - body?.body.spend.declinedReason?.toLowerCase() === "webhook declined" - ? (requestedReason ?? body.body.spend.declinedReason) - : body?.body.spend.declinedReason; + const provider = body?.body.spend.declinedReason || undefined; + const generic = (provider ?? body?.reason)?.toLowerCase() === "webhook declined"; + const source = generic ? requestedReason : provider; + const reason = + declineMessage(source) ?? + (generic ? "transaction declined" : (source ?? body?.reason ?? "transaction declined")); const validation = safeParse( { 0: DebitActivity, 1: CreditActivity }[borrow?.events.length ?? 0] ?? InstallmentsActivity, { ...body, - ...(body?.status === "declined" && { - reason: declineMessage(declinedReason) ?? body.reason ?? "transaction declined", - }), + ...(body?.status === "declined" && { reason }), forceCapture: body?.action === "completed" && !bodies.some((b) => b.action === "created"), type, hash, @@ -547,19 +547,9 @@ export const PandaActivity = pipe( }) .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, reason: "transaction declined" }; - } - return operation; - } - return operations.findLast((b) => b.action === "requested"); - })(); + const declined = + operations.findLast((b) => b.action === "created" && b.status === "declined") ?? + operations.findLast((b) => b.action === "requested" && b.status === "declined"); const flow = operations.reduce<{ completed: (typeof operations)[number] | undefined; diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 6a24f1caee..419d71708d 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1,6 +1,7 @@ import { vValidator } from "@hono/valibot-validator"; import { captureException, + captureMessage, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, setContext, @@ -170,7 +171,7 @@ export default function hook({ if (card.status === "FROZEN") { trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "frozen-card", segment); - await reject(payload, jsonBody, "frozenCard", database); + await reject(payload, jsonBody, "frozenCard"); return c.json({ code: "frozen card", rejectionCode: "NOT_PERMITTED" }, 403 as UnofficialStatusCode); } @@ -378,7 +379,7 @@ export default function hook({ } if (error.message !== "Replay" && error.message !== "tx reverted") { - await reject(payload, jsonBody, error.message, database); + await reject(payload, jsonBody, error.message); } return c.json( @@ -396,7 +397,7 @@ export default function hook({ ); captureException(error, { level: "error", tags: { unhandled: true } }); - await reject(payload, jsonBody, error instanceof Error ? error.message : "unexpected error", database); + await reject(payload, jsonBody, "unexpected error"); return c.json({ code: "ouch", rejectionCode: "UNKNOWN" }, 569 as UnofficialStatusCode); } @@ -515,19 +516,33 @@ export default function hook({ mutex?.release(); setContext("mutex", { locked: mutex?.isLocked() }); - const requestedReason = - payload.body.spend.declinedReason?.toLowerCase() === "webhook declined" - ? await getRequestedDeclineReason(payload.body.id, payload.body.spend.cardId, database) + const provider = payload.body.spend.declinedReason || undefined; + const requested = + provider?.toLowerCase() === "webhook declined" + ? await database.query.transactions + .findFirst({ + columns: { payload: true }, + where: and( + eq(transactions.id, payload.body.id), + eq(transactions.cardId, payload.body.spend.cardId), + ), + }) + .then((transaction) => getRequestedDeclineReason(transaction?.payload)) : undefined; - const rawDeclineReason = requestedReason ?? payload.body.spend.declinedReason; - if ( - (await reject(payload, jsonBody, rawDeclineReason ?? "transaction declined", database)) && - payload.action === "created" - ) { + const raw = requested ?? provider; + const mapped = declineMessage(raw); + const accepted = await reject(payload, jsonBody, raw ?? "transaction declined"); + if (accepted && payload.action === "created") { + if (requested === undefined && raw && !mapped) { + captureMessage("unknown panda decline reason", { + level: "warning", + tags: { reason: raw }, + }); + } sendDeclinedNotification( account, payload.body.spend, - declineMessage(rawDeclineReason) ?? "transaction declined", + mapped ?? (requested === undefined ? raw : undefined) ?? "transaction declined", onesignal, ).catch((error: unknown) => captureException(error, { level: "error" })); } @@ -887,6 +902,53 @@ export default function hook({ } }, ); + async function reject(payload: v.InferOutput, jsonBody: unknown, declineReason: string) { + const { spend } = payload.body; + const transactionId = payload.body.id ?? payload.id; + + const rawBody = v.parse(v.looseObject({ body: v.looseObject({ spend: v.looseObject({}) }) }), jsonBody); + const createdAt = getCreatedAt(payload) ?? new Date().toISOString(); + const declinedBody = { + ...rawBody, + ...(payload.action === "requested" && { + body: { ...rawBody.body, spend: { ...rawBody.body.spend, declinedReason: declineReason } }, + }), + createdAt, + status: "declined", + }; + + 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 + )`, + }, + ...(payload.action === "created" && { + setWhere: sql`NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(${transactions.payload}::jsonb->'bodies', '[]'::jsonb)) AS body + WHERE body->>'id' = ${payload.id} + )`, + }), + }) + .returning({ id: transactions.id }) + .then((result) => result.length > 0) + .catch((error: unknown) => { + captureException(error, { level: "error" }); + }); + } return { app, ready: Promise.resolve() }; } @@ -1152,13 +1214,7 @@ class PandaError extends Error { } } -async function getRequestedDeclineReason(transactionId: string, cardId: string, database: Database) { - const transaction = await database.query.transactions.findFirst({ - columns: { payload: true }, - where: and(eq(transactions.id, transactionId), eq(transactions.cardId, cardId)), - }); - if (!transaction) return; - +function getRequestedDeclineReason(transactionPayload: unknown) { const payload = v.safeParse( v.object({ bodies: v.array( @@ -1166,13 +1222,16 @@ async function getRequestedDeclineReason(transactionId: string, cardId: string, action: v.string(), body: v.looseObject({ spend: v.looseObject({ declinedReason: v.nullish(v.string()) }) }), reason: v.optional(v.string()), + status: v.optional(v.string()), }), ), }), - transaction.payload, + transactionPayload, ); if (!payload.success) return; - const requested = payload.output.bodies.findLast(({ action }) => action === "requested"); + const requested = payload.output.bodies.findLast( + ({ action, status }) => action === "requested" && status === "declined", + ); return requested?.body.spend.declinedReason ?? requested?.reason; } @@ -1192,56 +1251,3 @@ async function sendDeclinedNotification( }), }); } - -async function reject( - payload: v.InferOutput, - jsonBody: unknown, - declineReason: string, - database: Database, -) { - const { spend } = payload.body; - const transactionId = payload.body.id ?? payload.id; - - const rawBody = v.parse(v.looseObject({ body: v.looseObject({ spend: v.looseObject({}) }) }), jsonBody); - const createdAt = getCreatedAt(payload) ?? new Date().toISOString(); - const declinedBody = { - ...rawBody, - ...(payload.action === "requested" && { - body: { ...rawBody.body, spend: { ...rawBody.body.spend, declinedReason: declineReason } }, - }), - createdAt, - status: "declined", - }; - - 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 - )`, - }, - ...(payload.action === "created" && { - setWhere: sql`NOT EXISTS ( - SELECT 1 - FROM jsonb_array_elements(COALESCE(${transactions.payload}::jsonb->'bodies', '[]'::jsonb)) AS body - WHERE body->>'id' = ${payload.id} - )`, - }), - }) - .returning({ id: transactions.id }) - .then((result) => result.length > 0) - .catch((error: unknown) => { - captureException(error, { level: "error" }); - }); -} diff --git a/server/test/api/activity.test.ts b/server/test/api/activity.test.ts index 0040f18178..bb97adc701 100644 --- a/server/test/api/activity.test.ts +++ b/server/test/api/activity.test.ts @@ -770,7 +770,7 @@ describe.concurrent("authenticated", () => { expect(result.output.reason).toBe(reason); }); - it("uses a generic reason for an unknown raw decline", () => { + it("uses the raw reason for an unknown decline", () => { const result = safeParse(PandaActivity, { type: "panda", hashes: [zeroHash], @@ -790,7 +790,7 @@ describe.concurrent("authenticated", () => { expect(result.success).toBe(true); assert.ok(result.success); - expect(result.output.reason).toBe("transaction declined"); + expect(result.output.reason).toBe("unknown provider decline"); }); it("hides a legacy webhook decline without a requested operation", () => { @@ -849,6 +849,39 @@ describe.concurrent("authenticated", () => { expect(result.output.reason).toBe("frozen card"); }); + it("uses the requested reason when a webhook decline has an empty provider reason", () => { + const result = safeParse(PandaActivity, { + type: "panda", + hashes: [zeroHash, zeroHash], + borrows: [null, null], + bodies: [ + { + action: "requested", + createdAt: "2024-01-15T10:59:00.000Z", + status: "declined", + body: { + id: "declined-tx-empty-provider-reason", + spend: { ...spendTemplate, declinedReason: "frozenCard" }, + }, + }, + { + action: "created", + createdAt: "2024-01-15T11:00:00.000Z", + status: "declined", + reason: "webhook declined", + body: { + id: "declined-tx-empty-provider-reason", + spend: { ...spendTemplate, declinedReason: "" }, + }, + }, + ], + }); + + expect(result.success).toBe(true); + assert.ok(result.success); + expect(result.output.reason).toBe("frozen card"); + }); + it("ignores a non-declined requested operation when finding a decline reason", () => { const result = safeParse(PandaActivity, { type: "panda", @@ -881,6 +914,80 @@ describe.concurrent("authenticated", () => { expect(result.output.reason).toBe("transaction declined"); }); + it("hides an unknown local requested reason", () => { + const result = safeParse(PandaActivity, { + type: "panda", + hashes: [zeroHash, zeroHash], + borrows: [null, null], + bodies: [ + { + action: "requested", + createdAt: "2024-01-15T10:59:00.000Z", + status: "declined", + body: { + id: "declined-tx-local-unknown", + spend: { ...spendTemplate, declinedReason: "bad collection" }, + }, + }, + { + action: "created", + createdAt: "2024-01-15T11:00:00.000Z", + status: "declined", + body: { + id: "declined-tx-local-unknown", + spend: { ...spendTemplate, status: "declined", declinedReason: "webhook declined" }, + }, + }, + ], + }); + + expect(result.success).toBe(true); + assert.ok(result.success); + expect(result.output.reason).toBe("transaction declined"); + }); + + it("uses the last valid requested reason with nested precedence", () => { + const result = safeParse(PandaActivity, { + type: "panda", + hashes: [zeroHash, zeroHash, zeroHash], + borrows: [null, null, null], + bodies: [ + { + action: "requested", + createdAt: "2024-01-15T10:57:00.000Z", + status: "declined", + reason: "bad collection", + body: { + id: "declined-tx-last-requested", + spend: { ...spendTemplate, declinedReason: "frozenCard" }, + }, + }, + { + action: "requested", + createdAt: "2024-01-15T10:58:00.000Z", + status: "declined", + reason: "InsufficientAccountLiquidity", + body: { + id: "declined-tx-last-requested", + spend: { ...spendTemplate }, + }, + }, + { + action: "created", + createdAt: "2024-01-15T11:00:00.000Z", + status: "declined", + body: { + id: "declined-tx-last-requested", + spend: { ...spendTemplate, status: "declined", declinedReason: "webhook declined" }, + }, + }, + ], + }); + + expect(result.success).toBe(true); + assert.ok(result.success); + expect(result.output.reason).toBe("insufficient funds"); + }); it("parses declined transaction with requested action alongside created", () => { const result = safeParse(PandaActivity, { type: "panda", diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 337ec0ef9f..4e42345693 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -6,7 +6,7 @@ import * as segment from "../mocks/segment"; import "../mocks/sentry"; import "../mocks/wallet"; -import { captureException, setUser } from "@sentry/node"; +import { captureException, captureMessage, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; import { parse } from "valibot"; @@ -3056,11 +3056,54 @@ describe("concurrency", () => { contents: t("Transaction at {{merchantName}} for {{amount}} rejected: {{reason}}", { amount: f(authorization.json.body.spend.localAmount / 100, authorization.json.body.spend.localCurrency), merchantName: authorization.json.body.spend.merchantName, - reason: t("transaction declined"), + reason: t("frozen card"), }), }); }); + it("hides unknown local reasons for generic provider declines", async () => { + const sendPushNotificationSpy = sendPushNotificationMock; + const cardId = `${account2}-card`; + const txId = `unknown-local-reason-${crypto.randomUUID()}`; + await database.insert(transactions).values({ + id: txId, + cardId, + hashes: [zeroHash], + payload: { + type: "panda", + bodies: [{ action: "requested", status: "declined", body: { spend: { declinedReason: "bad collection" } } }], + }, + }); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: txId, + spend: { + ...authorization.json.body.spend, + cardId, + status: "declined", + declinedReason: "webhook declined", + }, + }, + }, + }); + + await vi.waitFor(() => expect(sendPushNotificationSpy).toHaveBeenCalled()); + expect(response.status).toBe(200); + expect(sendPushNotificationSpy.mock.calls[0]?.[0]).toMatchObject({ + contents: t("Transaction at {{merchantName}} for {{amount}} rejected: {{reason}}", { + amount: f(authorization.json.body.spend.localAmount / 100, authorization.json.body.spend.localCurrency), + merchantName: authorization.json.body.spend.merchantName, + reason: t("transaction declined"), + }), + }); + expect(captureMessage).not.toHaveBeenCalled(); + }); it("recovers a local decline reason and ignores duplicate created events", async () => { const sendPushNotificationSpy = sendPushNotificationMock; const cardId = `${account2}-card`; @@ -3132,7 +3175,7 @@ describe("concurrency", () => { authorizationUpdateAmount: 100, authorizedAt: new Date().toISOString(), status: "declined" as const, - declinedReason: "merchant_blocked", + declinedReason: "unknown provider decline", }, }, }; @@ -3141,12 +3184,13 @@ describe("concurrency", () => { await appClient.index.$post({ ...authorization, json: updatedEvent }); expect(sendPushNotificationSpy).not.toHaveBeenCalled(); + expect(vi.mocked(captureMessage)).not.toHaveBeenCalled(); expect(await database.query.transactions.findFirst({ where: eq(transactions.id, txId) })).toMatchObject({ payload: { type: "panda", bodies: [ - { action: "updated", status: "declined", body: { spend: { declinedReason: "merchant_blocked" } } }, - { action: "updated", status: "declined", body: { spend: { declinedReason: "merchant_blocked" } } }, + { action: "updated", status: "declined", body: { spend: { declinedReason: "unknown provider decline" } } }, + { action: "updated", status: "declined", body: { spend: { declinedReason: "unknown provider decline" } } }, ], }, }); @@ -3182,9 +3226,11 @@ describe("concurrency", () => { ["invalid pin", "invalid pin"], ["invalid pin attempt limit exceeded", "too many invalid pin attempts"], ["triggers for transactions from mcc 6050 and 6051", "this merchant is not accepted"], - ["webhook declined", "transaction declined"], - ["unknown provider decline", "transaction declined"], - ])("stores raw %s and notifies with %s", async (declinedReason, notificationReason) => { + ["", "transaction declined", false], + ["webhook declined", "transaction declined", false], + ["unknown provider decline", "unknown provider decline", true], + ["unexpected error", "transaction declined", false], + ])("stores raw %s and notifies with %s", async (...[reason, notification, unknown]) => { const sendPushNotificationSpy = sendPushNotificationMock; const txId = crypto.randomUUID(); @@ -3203,7 +3249,7 @@ describe("concurrency", () => { amount: 700, cardId: `${account2}-card`, status: "declined", - declinedReason, + declinedReason: reason, }, }, }, @@ -3214,7 +3260,7 @@ describe("concurrency", () => { expect(transaction).toMatchObject({ payload: { type: "panda", - bodies: [{ action: "created", status: "declined", body: { spend: { declinedReason } } }], + bodies: [{ action: "created", status: "declined", body: { spend: { declinedReason: reason } } }], }, }); expect(transaction).not.toHaveProperty("payload.bodies[0].reason"); @@ -3225,9 +3271,12 @@ describe("concurrency", () => { contents: t("Transaction at {{merchantName}} for {{amount}} rejected: {{reason}}", { amount: f(authorization.json.body.spend.localAmount / 100, authorization.json.body.spend.localCurrency), merchantName: authorization.json.body.spend.merchantName, - reason: t(notificationReason), + reason: t(notification), }), }); + expect(vi.mocked(captureMessage).mock.calls).toStrictEqual( + unknown ? [["unknown panda decline reason", { level: "warning", tags: { reason } }]] : [], + ); }); it("does not send duplicate notifications for concurrent declined transactions", async () => { @@ -3249,7 +3298,7 @@ describe("concurrency", () => { amount: 500, cardId, status: "declined" as const, - declinedReason: "insufficient_funds", + declinedReason: "unknown provider decline", }, }, }, @@ -3258,10 +3307,15 @@ describe("concurrency", () => { await Promise.all([appClient.index.$post(payload), appClient.index.$post(payload)]); expect(sendPushNotificationSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(captureMessage)).toHaveBeenCalledExactlyOnceWith("unknown panda decline reason", { + level: "warning", + tags: { reason: "unknown provider decline" }, + }); }); it("does not send notification for unknown error", async () => { const sendPushNotificationSpy = sendPushNotificationMock; + const txId = crypto.randomUUID(); vi.spyOn(traceClient, "traceCall").mockRejectedValueOnce(new Error("unexpected trace error")); @@ -3271,7 +3325,7 @@ describe("concurrency", () => { ...authorization.json, body: { ...authorization.json.body, - id: crypto.randomUUID(), + id: txId, spend: { ...authorization.json.body.spend, cardId: "card", amount: 100 }, }, }, @@ -3283,6 +3337,42 @@ describe("concurrency", () => { rejectionCode: "UNKNOWN", }); expect(sendPushNotificationSpy).not.toHaveBeenCalled(); + expect(captureMessage).not.toHaveBeenCalled(); + expect(await database.query.transactions.findFirst({ where: eq(transactions.id, txId) })).toMatchObject({ + payload: { + type: "panda", + bodies: [ + { action: "requested", status: "declined", body: { spend: { declinedReason: "unexpected error" } } }, + ], + }, + }); + }); + + it("stores unexpected error when a non-panda error escapes authorization", async () => { + const txId = crypto.randomUUID(); + vi.spyOn(Panda, "signIssuerOp").mockRejectedValueOnce(new Error("sign failed")); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + body: { + ...authorization.json.body, + id: txId, + spend: { ...authorization.json.body.spend, cardId: "card", amount: 100 }, + }, + }, + }); + + expect(response.status).toBe(569); + expect(await database.query.transactions.findFirst({ where: eq(transactions.id, txId) })).toMatchObject({ + payload: { + type: "panda", + bodies: [ + { action: "requested", status: "declined", body: { spend: { declinedReason: "unexpected error" } } }, + ], + }, + }); }); it("does not add a reason when a created decline has no raw reason", async () => { diff --git a/server/test/utils/panda.test.ts b/server/test/utils/panda.test.ts index 6d91d1b31d..4a4beaa7c2 100644 --- a/server/test/utils/panda.test.ts +++ b/server/test/utils/panda.test.ts @@ -23,6 +23,24 @@ vi.mock("@exactly/common/generated/chain", async (importOriginal) => ({ const panda = { ...Panda, ...createPanda({ key: "panda", url: "https://panda.test" }) }; +describe("decline reasons", () => { + it.each([ + ["frozenCard", "frozen card"], + ["frozen card", "frozen card"], + ["InsufficientAccountLiquidity", "insufficient funds"], + ["card canceled", "card canceled"], + ["bad collection", "transaction declined"], + ["unexpected error", "transaction declined"], + [ + "advertising services (mcc 7311) transaction velocity limit reached, more than 40 transactions were attempted", + "advertising limit reached", + ], + ["new provider decline", undefined], + ])("maps %s to %s", (reason, message) => { + expect(Panda.declineMessage(reason)).toStrictEqual(message); + }); +}); + describe("panda request", () => { it("extracts entity from url on not found", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ diff --git a/server/test/workers/hook.test.ts b/server/test/workers/hook.test.ts index 28b2a0ecd3..88087a4533 100644 --- a/server/test/workers/hook.test.ts +++ b/server/test/workers/hook.test.ts @@ -369,7 +369,7 @@ describe("hook worker", () => { hashes: [zeroHash], payload: { type: "panda", - bodies: [{ action: "requested", body: { spend: { declinedReason: "frozenCard" } } }], + bodies: [{ action: "requested", status: "declined", body: { spend: { declinedReason: "frozenCard" } } }], }, }, ]); @@ -388,7 +388,10 @@ describe("hook worker", () => { id: "tx-wk-requested-reason", cardId: "webhook-card", hashes: [zeroHash], - payload: { type: "panda", bodies: [{ action: "requested", body: { spend: {} }, reason: "high risk" }] }, + payload: { + type: "panda", + bodies: [{ action: "requested", status: "declined", body: { spend: {} }, reason: "high risk" }], + }, }, ]); transaction("wk-requested-reason", { declinedReason: "webhook declined", status: "declined" }); @@ -397,7 +400,7 @@ describe("hook worker", () => { await jobFinished("wk-requested-reason"); const body = parse(string(), mockFetch.mock.calls[0]?.[1]?.body); - expect(JSON.parse(body)).toMatchObject({ body: { spend: { declinedReason: "high risk" } } }); + expect(JSON.parse(body)).toMatchObject({ body: { spend: { declinedReason: "webhook declined" } } }); }); it("keeps webhook declined without a transaction", async () => { @@ -416,7 +419,7 @@ describe("hook worker", () => { id: "tx-wk-no-requested", cardId: "webhook-card", hashes: [zeroHash], - payload: { type: "panda", bodies: [{ action: "created", body: { spend: {} } }] }, + payload: { type: "panda", bodies: [{ action: "created", status: "declined", body: { spend: {} } }] }, }, ]); transaction("wk-no-requested", { declinedReason: "webhook declined", status: "declined" }); @@ -438,6 +441,38 @@ describe("hook worker", () => { expect(JSON.parse(body)).toMatchObject({ body: { spend: { declinedReason: "blocked mcc" } } }); }); + it("delivers direct unknown provider reasons unchanged", async () => { + transaction("wk-unknown", { declinedReason: "provider internal failure", status: "declined" }); + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(() => Promise.resolve(new Response("OK"))); + + await jobFinished("wk-unknown"); + + const body = parse(string(), mockFetch.mock.calls[0]?.[1]?.body); + expect(JSON.parse(body)).toMatchObject({ + body: { spend: { declinedReason: "provider internal failure", status: "declined" } }, + }); + }); + + it("ignores non-declined requested bodies", async () => { + await database.insert(transactions).values([ + { + id: "tx-wk-pending-request", + cardId: "webhook-card", + hashes: [zeroHash], + payload: { + type: "panda", + bodies: [{ action: "requested", status: "pending", body: { spend: { declinedReason: "frozenCard" } } }], + }, + }, + ]); + transaction("wk-pending-request", { declinedReason: "webhook declined", status: "declined" }); + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(() => Promise.resolve(new Response("OK"))); + + await jobFinished("wk-pending-request"); + + const body = parse(string(), mockFetch.mock.calls[0]?.[1]?.body); + expect(JSON.parse(body)).toMatchObject({ body: { spend: { declinedReason: "webhook declined" } } }); + }); it("skips requested transactions", async () => { transaction("wk-requested", {}, "requested"); const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(() => Promise.resolve(new Response("OK"))); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index e23eafa7c2..839d78645b 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -671,6 +671,7 @@ export function declineMessage(reason?: null | string) { return reason ? ({ "account credit limit exceeded": "transaction declined", + "bad collection": "transaction declined", "block atm (mcc 6011) transaction exceeding 250.00 usd": "atm limit reached. maximum 250 usd per transaction.", "blocked merchant": "this merchant is not accepted", "blocked mcc": "this merchant is not accepted", @@ -681,12 +682,14 @@ export function declineMessage(reason?: null | string) { "cvv2 match fail": "transaction declined", "expiry mismatch": "transaction declined", frozencard: "frozen card", // cspell:ignore frozencard + "frozen card": "frozen card", insufficientaccountliquidity: "insufficient funds", // cspell:ignore insufficientaccountliquidity insufficient_funds: "insufficient funds", "invalid pin": "invalid pin", "invalid pin attempt limit exceeded": "too many invalid pin attempts", merchant_blocked: "this merchant is not accepted", "triggers for transactions from mcc 6050 and 6051": "this merchant is not accepted", + "unexpected error": "transaction declined", "webhook declined": "transaction declined", }[reason.toLowerCase()] ?? ( diff --git a/server/workers/hook/worker.ts b/server/workers/hook/worker.ts index 8358b52114..212d61f614 100644 --- a/server/workers/hook/worker.ts +++ b/server/workers/hook/worker.ts @@ -7,6 +7,7 @@ import * as v from "valibot"; import { attempts, name, type Job } from "./job"; import { credentials, transactions } from "../../database/schema"; +import { declineMessage } from "../../utils/panda"; import createWorker from "../worker"; import type * as schema from "../../database/schema"; @@ -87,6 +88,7 @@ export default function worker({ action: v.string(), body: v.looseObject({ spend: v.looseObject({ declinedReason: v.nullish(v.string()) }) }), reason: v.optional(v.string()), + status: v.optional(v.string()), }), ), }), @@ -98,10 +100,10 @@ export default function worker({ .then((transaction) => transaction?.payload), ); const requested = stored.success - ? stored.output.bodies.findLast(({ action }) => action === "requested") + ? stored.output.bodies.findLast(({ action, status }) => action === "requested" && status === "declined") : undefined; const reason = requested?.body.spend.declinedReason ?? requested?.reason; - if (reason) payload.body.spend.declinedReason = reason; + if (reason) payload.body.spend.declinedReason = declineMessage(reason) ? reason : "webhook declined"; } const timestamp = new Date().toISOString(); const outbound = v.safeParse(