Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shy-foxes-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ forward exchange rate to webhooks
2 changes: 2 additions & 0 deletions docs/src/content/docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -620,6 +621,7 @@ if an onchain transaction is necessary.
| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | <https://storage.googleapis.com/heron-merchant-assets/icons/mrc_BqxmeYFvJmCprexvXUDF7h.png> |
| 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

Expand Down
11 changes: 11 additions & 0 deletions server/hooks/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}),
}),
}),
Expand Down Expand Up @@ -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()),
}),
}),
}),
Expand Down Expand Up @@ -1317,6 +1319,13 @@ async function publish(payload: v.InferOutput<typeof Payload>, 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,
Expand Down Expand Up @@ -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()),
}),
}),
}),
Expand Down Expand Up @@ -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()),
}),
}),
}),
Expand Down
60 changes: 55 additions & 5 deletions server/test/hooks/panda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(),
},
},
Expand All @@ -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`;

Expand All @@ -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,
Expand All @@ -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`;

Expand All @@ -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(),
},
},
Expand All @@ -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,
},
},
Expand All @@ -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 () => {
Expand Down
Loading