Skip to content

Commit d250c14

Browse files
committed
✨ server: expose unknown decline reasons (minimal utils variant)
1 parent 94f45d0 commit d250c14

6 files changed

Lines changed: 129 additions & 86 deletions

File tree

server/api/activity.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import { effectiveRate, WAD } from "@exactly/lib";
5555

5656
import { cards, credentials, transactions } from "../database/schema";
5757
import { collectors as cryptomateCollectors } from "../utils/cryptomate";
58-
import { decline, collectors as pandaCollectors } from "../utils/panda";
58+
import { declineMessage, collectors as pandaCollectors } from "../utils/panda";
5959
import publicClient from "../utils/publicClient";
6060
import Statement from "../utils/Statement";
6161
import validatorHook from "../utils/validatorHook";
@@ -519,20 +519,16 @@ export const PandaActivity = pipe(
519519
}),
520520
transform(({ bodies, borrows, hashes, type }) => {
521521
const requestedBody = bodies.findLast((body) => body.action === "requested" && body.status === "declined");
522-
const requested = decline(requestedBody?.body.spend.declinedReason ?? requestedBody?.reason);
522+
const requestedReason = requestedBody?.body.spend.declinedReason ?? requestedBody?.reason;
523523
const operations = hashes
524524
.map((hash, index) => {
525525
const borrow = borrows[index];
526526
const body = bodies[index];
527-
const provider = body?.body.spend.declinedReason;
527+
const provider = body?.body.spend.declinedReason === "" ? undefined : body?.body.spend.declinedReason;
528528
const generic = (provider ?? body?.reason)?.toLowerCase() === "webhook declined";
529529
const local = body?.action === "requested" && body.status === "declined";
530-
const reason =
531-
local || generic
532-
? requested?.matched
533-
? requested.message
534-
: "transaction declined"
535-
: (decline(provider)?.message ?? body?.reason);
530+
const mapped = declineMessage(local || generic ? requestedReason : provider);
531+
const reason = (local || generic ? (mapped ?? "transaction declined") : (mapped ?? provider)) ?? body?.reason;
536532
const validation = safeParse(
537533
{ 0: DebitActivity, 1: CreditActivity }[borrow?.events.length ?? 0] ?? InstallmentsActivity,
538534
{

server/hooks/panda.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,9 @@ import t, { f } from "../i18n";
5555
import {
5656
collectors,
5757
createMutex,
58-
decline,
58+
declineMessage,
5959
getMutex,
6060
Payload,
61-
requestedDeclineReason,
6261
signIssuerOp,
6362
TransactionPayload,
6463
type Transaction,
@@ -528,12 +527,12 @@ export default function hook({
528527
eq(transactions.cardId, payload.body.spend.cardId),
529528
),
530529
})
531-
.then((transaction) => requestedDeclineReason(transaction?.payload))
530+
.then((transaction) => getRequestedDeclineReason(transaction?.payload))
532531
: undefined;
533532
const raw = requested ?? provider;
534-
const resolved = decline(raw);
533+
const mapped = declineMessage(raw);
535534
const accepted = await reject(payload, jsonBody, raw ?? "transaction declined", database);
536-
if (accepted && payload.action === "created" && requested === undefined && resolved?.matched === false) {
535+
if (accepted && payload.action === "created" && requested === undefined && raw && !mapped) {
537536
captureMessage("unknown panda decline reason", {
538537
level: "warning",
539538
tags: { reason: raw },
@@ -543,9 +542,9 @@ export default function hook({
543542
sendDeclinedNotification(
544543
account,
545544
payload.body.spend,
546-
requested === undefined || resolved?.matched
547-
? (resolved?.message ?? "transaction declined")
548-
: "transaction declined",
545+
requested === undefined
546+
? (mapped ?? raw ?? "transaction declined")
547+
: (mapped ?? "transaction declined"),
549548
onesignal,
550549
).catch((error: unknown) => captureException(error, { level: "error" }));
551550
}
@@ -1170,6 +1169,27 @@ class PandaError extends Error {
11701169
}
11711170
}
11721171

1172+
function getRequestedDeclineReason(transactionPayload: unknown) {
1173+
const payload = v.safeParse(
1174+
v.object({
1175+
bodies: v.array(
1176+
v.looseObject({
1177+
action: v.string(),
1178+
body: v.looseObject({ spend: v.looseObject({ declinedReason: v.nullish(v.string()) }) }),
1179+
reason: v.optional(v.string()),
1180+
status: v.optional(v.string()),
1181+
}),
1182+
),
1183+
}),
1184+
transactionPayload,
1185+
);
1186+
if (!payload.success) return;
1187+
const requested = payload.output.bodies.findLast(
1188+
({ action, status }) => action === "requested" && status === "declined",
1189+
);
1190+
return requested?.body.spend.declinedReason ?? requested?.reason;
1191+
}
1192+
11731193
async function sendDeclinedNotification(
11741194
account: Address,
11751195
spend: v.InferOutput<typeof Transaction>["body"]["spend"],

server/test/api/activity.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,39 @@ describe.concurrent("authenticated", () => {
852852
expect(result.output.reason).toBe("frozen card");
853853
});
854854

855+
it("uses the requested reason when a webhook decline has an empty provider reason", () => {
856+
const result = safeParse(PandaActivity, {
857+
type: "panda",
858+
hashes: [zeroHash, zeroHash],
859+
borrows: [null, null],
860+
bodies: [
861+
{
862+
action: "requested",
863+
createdAt: "2024-01-15T10:59:00.000Z",
864+
status: "declined",
865+
body: {
866+
id: "declined-tx-empty-provider-reason",
867+
spend: { ...spendTemplate, declinedReason: "frozenCard" },
868+
},
869+
},
870+
{
871+
action: "created",
872+
createdAt: "2024-01-15T11:00:00.000Z",
873+
status: "declined",
874+
reason: "webhook declined",
875+
body: {
876+
id: "declined-tx-empty-provider-reason",
877+
spend: { ...spendTemplate, declinedReason: "" },
878+
},
879+
},
880+
],
881+
});
882+
883+
expect(result.success).toBe(true);
884+
assert.ok(result.success);
885+
expect(result.output.reason).toBe("frozen card");
886+
});
887+
855888
it("ignores a non-declined requested operation when finding a decline reason", () => {
856889
const result = safeParse(PandaActivity, {
857890
type: "panda",

server/test/utils/panda.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@ const panda = { ...Panda, ...createPanda({ key: "panda", url: "https://panda.tes
2525

2626
describe("decline reasons", () => {
2727
it.each([
28-
["frozenCard", "frozen card", true],
29-
["InsufficientAccountLiquidity", "insufficient funds", true],
30-
["card canceled", "card canceled", true],
28+
["frozenCard", "frozen card"],
29+
["frozen card", "frozen card"],
30+
["InsufficientAccountLiquidity", "insufficient funds"],
31+
["card canceled", "card canceled"],
3132
[
3233
"advertising services (mcc 7311) transaction velocity limit reached, more than 40 transactions were attempted",
3334
"advertising limit reached",
34-
true,
3535
],
36-
["new provider decline", "new provider decline", false],
37-
])("resolves %s as %s with matched=%s", (reason, message, matched) => {
38-
expect(Panda.decline(reason)).toStrictEqual({ message, matched });
36+
["new provider decline", undefined],
37+
])("maps %s to %s", (reason, message) => {
38+
expect(Panda.declineMessage(reason)).toStrictEqual(message);
3939
});
4040
});
4141

server/utils/panda.ts

Lines changed: 31 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
picklist,
2727
pipe,
2828
regex,
29-
safeParse,
3029
string,
3130
transform,
3231
tuple,
@@ -668,59 +667,37 @@ export const collectors: Address[] = (
668667
}[chain.id] ?? ["0xDb90CDB64CfF03f254e4015C4F705C3F3C834400"]
669668
).map((address) => parse(Address, address));
670669

671-
const declineMessages: Record<string, string> = {
672-
"account credit limit exceeded": "transaction declined",
673-
"block atm (mcc 6011) transaction exceeding 250.00 usd": "atm limit reached. maximum 250 usd per transaction.",
674-
"blocked merchant": "this merchant is not accepted",
675-
"blocked mcc": "this merchant is not accepted",
676-
"card canceled": "card canceled",
677-
"card not activated": "card not active",
678-
"card spending limit exceeded": "card limit exceeded",
679-
"cvv mismatch": "transaction declined",
680-
"cvv2 match fail": "transaction declined",
681-
"expiry mismatch": "transaction declined",
682-
frozencard: "frozen card", // cspell:ignore frozencard
683-
"frozen card": "frozen card",
684-
insufficientaccountliquidity: "insufficient funds", // cspell:ignore insufficientaccountliquidity
685-
insufficient_funds: "insufficient funds",
686-
"invalid pin": "invalid pin",
687-
"invalid pin attempt limit exceeded": "too many invalid pin attempts",
688-
merchant_blocked: "this merchant is not accepted",
689-
"triggers for transactions from mcc 6050 and 6051": "this merchant is not accepted",
690-
"webhook declined": "transaction declined",
691-
} as const;
692-
const declinePatterns = [
693-
["advertising services (mcc 7311) transaction velocity limit reached", "advertising limit reached"],
694-
["atm (mcc 6011) transaction velocity limit reached", "atm limit reached"],
695-
["automatic fuel dispenser velocity limit reached", "fuel limit reached"],
696-
] as const;
697-
698-
export function decline(reason?: null | string) {
699-
if (!reason) return;
700-
const normalized = reason.toLowerCase();
701-
const message = declineMessages[normalized] ?? declinePatterns.find(([pattern]) => normalized.includes(pattern))?.[1];
702-
return { matched: message !== undefined, message: message ?? reason };
703-
}
704-
705-
export function requestedDeclineReason(payload: unknown) {
706-
const parsed = safeParse(
707-
object({
708-
bodies: array(
709-
looseObject({
710-
action: string(),
711-
body: looseObject({ spend: looseObject({ declinedReason: nullish(string()) }) }),
712-
reason: optional(string()),
713-
status: optional(string()),
714-
}),
715-
),
716-
}),
717-
payload,
718-
);
719-
if (!parsed.success) return;
720-
const requested = parsed.output.bodies.findLast(
721-
({ action, status }) => action === "requested" && status === "declined",
722-
);
723-
return requested?.body.spend.declinedReason ?? requested?.reason;
670+
export function declineMessage(reason?: null | string) {
671+
return reason
672+
? ({
673+
"account credit limit exceeded": "transaction declined",
674+
"block atm (mcc 6011) transaction exceeding 250.00 usd": "atm limit reached. maximum 250 usd per transaction.",
675+
"blocked merchant": "this merchant is not accepted",
676+
"blocked mcc": "this merchant is not accepted",
677+
"card canceled": "card canceled",
678+
"card not activated": "card not active",
679+
"card spending limit exceeded": "card limit exceeded",
680+
"cvv mismatch": "transaction declined",
681+
"cvv2 match fail": "transaction declined",
682+
"expiry mismatch": "transaction declined",
683+
frozencard: "frozen card", // cspell:ignore frozencard
684+
"frozen card": "frozen card",
685+
insufficientaccountliquidity: "insufficient funds", // cspell:ignore insufficientaccountliquidity
686+
insufficient_funds: "insufficient funds",
687+
"invalid pin": "invalid pin",
688+
"invalid pin attempt limit exceeded": "too many invalid pin attempts",
689+
merchant_blocked: "this merchant is not accepted",
690+
"triggers for transactions from mcc 6050 and 6051": "this merchant is not accepted",
691+
"webhook declined": "transaction declined",
692+
}[reason.toLowerCase()] ??
693+
(
694+
[
695+
["advertising services (mcc 7311) transaction velocity limit reached", "advertising limit reached"],
696+
["atm (mcc 6011) transaction velocity limit reached", "atm limit reached"],
697+
["automatic fuel dispenser velocity limit reached", "fuel limit reached"],
698+
] as const
699+
).find(([pattern]) => reason.toLowerCase().includes(pattern))?.[1])
700+
: undefined;
724701
}
725702

726703
// TODO remove code below

server/workers/hook/worker.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as v from "valibot";
77

88
import { attempts, name, type Job } from "./job";
99
import { credentials, transactions } from "../../database/schema";
10-
import { decline, requestedDeclineReason } from "../../utils/panda";
10+
import { declineMessage } from "../../utils/panda";
1111
import createWorker from "../worker";
1212

1313
import type * as schema from "../../database/schema";
@@ -81,13 +81,30 @@ export default function worker({
8181
payload.action !== "completed" &&
8282
payload.body.spend.declinedReason?.toLowerCase() === "webhook declined"
8383
) {
84-
const reason = await database.query.transactions
85-
.findFirst({
86-
columns: { payload: true },
87-
where: and(eq(transactions.id, payload.body.id), eq(transactions.cardId, payload.body.spend.cardId)),
88-
})
89-
.then((transaction) => requestedDeclineReason(transaction?.payload));
90-
if (reason) payload.body.spend.declinedReason = decline(reason)?.matched ? reason : "webhook declined";
84+
const stored = v.safeParse(
85+
v.object({
86+
bodies: v.array(
87+
v.looseObject({
88+
action: v.string(),
89+
body: v.looseObject({ spend: v.looseObject({ declinedReason: v.nullish(v.string()) }) }),
90+
reason: v.optional(v.string()),
91+
status: v.optional(v.string()),
92+
}),
93+
),
94+
}),
95+
await database.query.transactions
96+
.findFirst({
97+
columns: { payload: true },
98+
where: and(eq(transactions.id, payload.body.id), eq(transactions.cardId, payload.body.spend.cardId)),
99+
})
100+
.then((transaction) => transaction?.payload),
101+
);
102+
const requested = stored.success
103+
? stored.output.bodies.findLast(({ action, status }) => action === "requested" && status === "declined")
104+
: undefined;
105+
const reason = requested?.body.spend.declinedReason ?? requested?.reason;
106+
if (reason && declineMessage(reason)) payload.body.spend.declinedReason = reason;
107+
else if (reason) payload.body.spend.declinedReason = "webhook declined";
91108
}
92109
const timestamp = new Date().toISOString();
93110
const outbound = v.safeParse(

0 commit comments

Comments
 (0)