diff --git a/.changeset/brave-otters-listen.md b/.changeset/brave-otters-listen.md new file mode 100644 index 0000000000..c46740f8b0 --- /dev/null +++ b/.changeset/brave-otters-listen.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix forwarded card webhook status diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 6cb41bbe1e..738cef62c1 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -653,5 +653,5 @@ This webhook is currently triggered when a user adds their card to a digital wal | body.last4 | string | last 4 digits of the card | 7392 | | body.limit.amount | number | spending limit amount | 1000000 | | body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | -| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" \| "INACTIVE" | current status of the card | ACTIVE | +| body.status | "ACTIVE" \| "LOCKED" \| "DELETED" \| "INACTIVE" | current status of the card | ACTIVE | | body.tokenWallets | ["Apple"] \| ["Google Pay"] \| undefined | array of token wallets | ["Apple"] | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 102b911191..018dc57f0d 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1503,7 +1503,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac timestamp, body: { ...payload.body, - status: { active: "ACTIVE", locked: "FROZEN", canceled: "DELETED", notActivated: "INACTIVE" }[ + status: { active: "ACTIVE", locked: "LOCKED", canceled: "DELETED", notActivated: "INACTIVE" }[ payload.body.status ], }, @@ -1634,7 +1634,7 @@ const Webhook = v.variant("resource", [ amount: v.number(), frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), }), - status: v.picklist(["ACTIVE", "FROZEN", "DELETED", "INACTIVE"]), + status: v.picklist(["ACTIVE", "LOCKED", "DELETED", "INACTIVE"]), tokenWallets: v.nullish(v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))])), }), }), diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 90892f3d94..9d27395269 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -3425,6 +3425,58 @@ describe("webhooks", () => { 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))).toStrictEqual({ + id: cardUpdated.json.id, + timestamp: expect.any(String) as string, + resource: "card", + action: "updated", + body: { + id: cardUpdated.json.body.id, + last4: cardUpdated.json.body.last4, + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + status: "ACTIVE", + tokenWallets: ["Apple"], + }, + }); + }); + + it("forwards card updated locked", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...cardLocked, + json: { + ...cardLocked.json, + body: { + ...cardLocked.json.body, + userId: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_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))).toStrictEqual({ + id: cardLocked.json.id, + timestamp: expect.any(String) as string, + resource: "card", + action: "updated", + body: { + id: cardLocked.json.body.id, + last4: cardLocked.json.body.last4, + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + status: "LOCKED", + }, + }); }); it("forwards card updated canceled", async () => { @@ -3452,6 +3504,18 @@ describe("webhooks", () => { 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))).toStrictEqual({ + id: cardCanceled.json.id, + timestamp: expect.any(String) as string, + resource: "card", + action: "updated", + body: { + id: cardCanceled.json.body.id, + last4: cardCanceled.json.body.last4, + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + status: "DELETED", + }, + }); }); it("forwards user updated", async () => { @@ -3606,6 +3670,25 @@ const cardUpdated = { }, } as const; +const cardLocked = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "locked", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + }, + }, +} as const; + const cardCanceled = { header: { signature: "panda-signature" }, json: {