diff --git a/.changeset/chilly-suns-dress.md b/.changeset/chilly-suns-dress.md new file mode 100644 index 000000000..0a290b34f --- /dev/null +++ b/.changeset/chilly-suns-dress.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add wallet provisioning endpoint diff --git a/server/api/card.ts b/server/api/card.ts index dee223d5d..338e680e3 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -31,7 +31,17 @@ import { Address } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; -import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; +import { + autoCredit, + createCard, + getCard, + getPIN, + getProcessorDetails, + getSecrets, + getUser, + setPIN, + updateCard, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; import { customer } from "../utils/sardine"; @@ -77,6 +87,8 @@ const CreatedCardResponse = object({ productId: pipe(string(), metadata({ examples: ["402"] })), }); +const WalletResponse = object({ cardId: string(), cardSecret: string() }); + const UpdateCard = union([ pipe( strictObject({ mode: pipe(number(), integer(), minValue(0), maxValue(MAX_INSTALLMENTS)) }), @@ -566,6 +578,57 @@ async function encryptPIN(pin: string) { if (!mutex.isLocked()) mutexes.delete(credentialId); }); }, + ) + .get( + "/wallet", + auth(), + describeRoute({ + summary: "Get wallet provisioning credentials", + tags: ["Card"], + security: [{ credentialAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Wallet provisioning credentials", + content: { + "application/json": { + schema: resolver(WalletResponse, { errorMode: "ignore" }), + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + }, + }, + 404: { + description: "Not found", + content: { + "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) }, + }, + }, + }, + }), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, credentialId), + columns: { pandaId: true, account: true }, + with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } }, + }); + if (!credential) return c.json({ code: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + if (!credential.cards[0]) return c.json({ code: "no card" }, 404); + try { + const provisioning = await getProcessorDetails(credential.cards[0].id); + return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput, 200); + } catch (error) { + if (error instanceof ServiceError && error.status === 404) return c.json({ code: "no card" }, 404); + throw error; + } + }, ); const CardUUID = pipe(string(), uuid()); diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c24490..80951b2fe 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -797,6 +797,113 @@ describe("authenticated", () => { expect(card?.status).toBe("DELETED"); }); + describe("wallet", () => { + const walletCardId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const walletFrozenCardId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: "wallet-active", + publicKey: new Uint8Array(), + account: padHex("0xaa01", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "wallet-active", + }, + { + id: "wallet-frozen", + publicKey: new Uint8Array(), + account: padHex("0xaa02", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "wallet-frozen", + }, + { + id: "wallet-no-card", + publicKey: new Uint8Array(), + account: padHex("0xaa03", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "wallet-no-card", + }, + { + id: "wallet-no-panda", + publicKey: new Uint8Array(), + account: padHex("0xaa04", { size: 20 }), + factory: inject("ExaAccountFactory"), + }, + ]); + await database.insert(cards).values([ + { id: walletCardId, credentialId: "wallet-active", lastFour: "0001" }, + { id: walletFrozenCardId, credentialId: "wallet-frozen", lastFour: "0002", status: "FROZEN" }, + { id: "wallet-deleted", credentialId: "wallet-no-card", lastFour: "0003", status: "DELETED" }, + ]); + }); + + it("returns credentials for active card", async () => { + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-active", + timeBasedSecret: "secret-active", + }); + + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-active", cardSecret: "secret-active" }); + expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId); + }); + + it("returns credentials for frozen card", async () => { + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-frozen", + timeBasedSecret: "secret-frozen", + }); + + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-frozen" } }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-frozen", cardSecret: "secret-frozen" }); + expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletFrozenCardId); + }); + + it("returns 500 when panda api fails", async () => { + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Rain", 500, "internal error")); + + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } }); + + expect(response.status).toBe(500); + }); + + it("returns 404 when panda card is stale", async () => { + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 404, "not found")); + + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId); + }); + + it("returns 404 when only deleted card", async () => { + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-card" } }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + }); + + it("returns 403 when no panda customer", async () => { + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-panda" } }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + }); + + it("returns 500 when credential not found", async () => { + const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "nonexistent" } }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toStrictEqual({ code: "no credential" }); + }); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bc..a621dd483 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -111,6 +111,13 @@ export async function getCard(cardId: string) { return await request(CardResponse, `/issuing/cards/${cardId}`); } +export async function getProcessorDetails(cardId: string) { + return await request( + object({ processorCardId: string(), timeBasedSecret: string() }), + `/issuing/cards/${cardId}/processorDetails`, + ); +} + export async function updateCard(card: { billing?: { city: string;