From bc54ccffd8f07200baf6a6ca413d60b8c31faf14 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 26 Mar 2026 10:37:45 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20server:=20forward=20exchange=20rate?= =?UTF-8?q?=20to=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shy-foxes-trade.md | 5 +++ docs/src/content/docs/webhooks.md | 2 ++ server/hooks/panda.ts | 11 ++++++ server/test/hooks/panda.test.ts | 60 ++++++++++++++++++++++++++++--- 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 .changeset/shy-foxes-trade.md diff --git a/.changeset/shy-foxes-trade.md b/.changeset/shy-foxes-trade.md new file mode 100644 index 0000000000..9a0a6491ee --- /dev/null +++ b/.changeset/shy-foxes-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward exchange rate to webhooks diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 1612201311..f4d5328d0f 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -553,6 +553,7 @@ The onchain receipt will be present only if a onchain transaction is necessary. | body.spend.authorizedAmount | integer | The authorized amount | 100 | | body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | | body.spend.declinedReason? | string | Decline message | webhook declined | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | ### Transaction updated event @@ -620,6 +621,7 @@ if an onchain transaction is necessary. | body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | | body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | | body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | ### User updated diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index e09c007959..95a00bbef3 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -107,6 +107,7 @@ const Transaction = v.variant("action", [ ...BaseTransaction.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.optional(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -156,6 +157,7 @@ const Transaction = v.variant("action", [ enrichedMerchantIcon: v.nullish(v.string()), enrichedMerchantName: v.nullish(v.string()), enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -1317,6 +1319,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac ...payload, ...(receipt && { receipt }), timestamp, + ...(payload.action !== "updated" && + payload.body.spend.currency !== payload.body.spend.localCurrency && { + body: { + ...payload.body, + spend: { ...payload.body.spend, exchangeRate: payload.body.spend.exchangeRate }, + }, + }), }), webhook.transaction?.[payload.action] ?? webhook.url, webhook.secret, @@ -1371,6 +1380,7 @@ const Webhook = v.variant("resource", [ ...BaseWebhook.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -1409,6 +1419,7 @@ const Webhook = v.variant("resource", [ enrichedMerchantIcon: v.nullish(v.string()), enrichedMerchantName: v.nullish(v.string()), enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 9b8fc94154..dd30667d2e 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2166,7 +2166,7 @@ describe("webhooks", () => { afterEach(() => vi.resetAllMocks()); - it("forwards transaction created", async () => { + it("forwards transaction created with exchangeRate", async () => { const cardId = `${webhookAccount}-card`; const fetch = globalThis.fetch; let publish = false; @@ -2190,6 +2190,9 @@ describe("webhooks", () => { cardId, userId: webhookAccount, amount: 100, + localAmount: 85, + localCurrency: "eur", + exchangeRate: 1.176_470_588_2, authorizedAt: new Date().toISOString(), }, }, @@ -2198,11 +2201,48 @@ describe("webhooks", () => { await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.176_470_588_2 } }, + }); + }); + + it("forwards transaction created without exchangeRate when same currency", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "same-currency-tx", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); }); - it("forwards transaction updated", async () => { + it("forwards transaction updated without exchangeRate", async () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; @@ -2227,6 +2267,8 @@ describe("webhooks", () => { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount, + localCurrency: "eur", + localAmount: 6800, authorizedAt: new Date().toISOString(), status: "pending", authorizationUpdateAmount: 98, @@ -2238,11 +2280,11 @@ describe("webhooks", () => { await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); }); - it("forwards transaction completed", async () => { + it("forwards transaction completed with exchangeRate", async () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; @@ -2267,6 +2309,9 @@ describe("webhooks", () => { cardId, userId: webhookAccount, amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, authorizedAt: new Date().toISOString(), }, }, @@ -2287,6 +2332,9 @@ describe("webhooks", () => { postedAt: new Date().toISOString(), status: "completed", amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, authorizedAmount: 99, }, }, @@ -2296,8 +2344,10 @@ describe("webhooks", () => { await vi.waitUntil(() => publishCounter > 1, 60_000); const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.178_571_428_6 } }, + }); }); it("forwards card updated active", async () => {