-
Notifications
You must be signed in to change notification settings - Fork 4
✨️ 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
Changes from all commits
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 | ||
| --- | ||
|
|
||
| 🥅 improve activity api error context |
| 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.
aguxez marked this conversation as resolved.
aguxez marked this conversation as resolved.
|
| 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.
aguxez marked this conversation as resolved.
|
||
| .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"); | ||
|
sentry[bot] marked this conversation as resolved.
Comment on lines
+558
to
+568
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: Declined 'requested' transactions without a Suggested FixIn the Prompt for AI Agent |
||
| }) | ||
|
aguxez marked this conversation as 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.
|
||
| 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.
|
||
| } | ||
| return operation; | ||
| } | ||
| return operations.findLast((b) => b.action === "requested"); | ||
|
aguxez marked this conversation as resolved.
|
||
| })(); | ||
|
aguxez marked this conversation as resolved.
aguxez marked this conversation as resolved.
aguxez marked this conversation as 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": | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| f[operation.action] = operation; | ||
| break; | ||
|
|
||
| case "requested": | ||
| return f; | ||
|
aguxez marked this conversation as resolved.
sentry[bot] marked this conversation as 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.
sentry[bot] marked this conversation as 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 { | ||
|
sentry[bot] marked this conversation as resolved.
|
||
|
|
@@ -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.
aguxez marked this conversation as 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.
aguxez marked this conversation as resolved.
|
||
| ...(declined && { reason: declined.reason ?? "transaction declined" }), | ||
|
aguxez marked this conversation as 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.
|
||
| 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.
|
||
| 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.