-
Notifications
You must be signed in to change notification settings - Fork 2
✨️ server: add activity and push notification on card decline #622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4687e86
76113dd
17a2176
95e2d11
9e5525b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✅ add tests for declined transactions |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ add authorization declined handler |
devin-ai-integration[bot] marked this conversation as resolved.
Show resolved
Hide resolved
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 PandaActivity throws "invalid flow" for records containing only "requested" bodies When Race condition timeline
The window between steps 1 and 3 is the bug trigger. If the "created" follow-up webhook never arrives (or arrives to a different server instance), the record is permanently broken. (Refers to lines 610-611) Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import { | |
| bigint, | ||
| boolean, | ||
| digits, | ||
| flatten, | ||
| intersect, | ||
| isoTimestamp, | ||
| length, | ||
|
|
@@ -34,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, { | ||
|
|
@@ -407,7 +409,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 }) => { | ||
|
|
@@ -420,6 +428,7 @@ export default new Hono().get( | |
| }), | ||
| ] | ||
| .filter(<T>(value: T | undefined): value is T => value !== undefined) | ||
| .filter((item) => chain.id === anvil.id || !("status" in item && item.status === "declined")) | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Declined filter hides items in production but not test — intentional but fragile The new filter at Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| .toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id)); | ||
|
|
||
| if (maturity !== undefined && pdf) { | ||
|
|
@@ -535,38 +544,64 @@ 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"); | ||
| }) | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .filter((p) => p.provider === "panda"); | ||
|
|
||
| const declined = (function () { | ||
| const operation = operations.findLast((b) => b.action === "created" && b.status === "declined"); | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (operation) { | ||
| if (operation.reason === "webhook declined") { | ||
| const requested = operations.findLast((b) => b.action === "requested"); | ||
| return requested ? { ...operation, reason: requested.reason } : operation; | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| return operation; | ||
| } | ||
| return operations.findLast((b) => b.action === "requested"); | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| })(); | ||
|
|
||
| const flow = operations.reduce<{ | ||
| completed: (typeof operations)[number] | undefined; | ||
| created: (typeof operations)[number] | undefined; | ||
| 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; | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| default: | ||
| throw new Error("bad action"); | ||
| } | ||
| return f; | ||
| }, | ||
| { created: undefined, updates: [], completed: undefined }, | ||
|
|
@@ -581,7 +616,9 @@ export const PandaActivity = pipe( | |
| timestamp, | ||
| merchant: { city, country, name, state }, | ||
sentry[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } = 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 { | ||
|
Comment on lines
616
to
624
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The Suggested FixModify the Prompt for AI Agent |
||
|
|
@@ -592,42 +629,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, | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| 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), | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Breaking change: The PandaActivity output at Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| ...(declined && { reason: declined.reason ?? "transaction declined" }), | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
| }), | ||
| ); | ||
|
|
||
| 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({ | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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"]) }), | ||
aguxez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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(), | ||
|
|
@@ -673,6 +723,7 @@ function transformCard(activity: InferOutput<typeof CardActivity>) { | |
| 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, | ||
|
|
@@ -685,13 +736,18 @@ function transformCard(activity: InferOutput<typeof CardActivity>) { | |
| 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, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.