Skip to content

Commit 1d0aa0f

Browse files
committed
✨ server: add wallet provisioning endpoint
1 parent e6efb1b commit 1d0aa0f

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

.changeset/chilly-suns-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✨ add wallet provisioning endpoint

server/api/card.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,17 @@ import { Address } from "@exactly/common/validation";
3131
import database, { cards, credentials } from "../database";
3232
import auth from "../middleware/auth";
3333
import { sendPushNotification } from "../utils/onesignal";
34-
import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda";
34+
import {
35+
autoCredit,
36+
createCard,
37+
getCard,
38+
getPIN,
39+
getProcessorDetails,
40+
getSecrets,
41+
getUser,
42+
setPIN,
43+
updateCard,
44+
} from "../utils/panda";
3545
import { addCapita, deriveAssociateId } from "../utils/pax";
3646
import { getAccount } from "../utils/persona";
3747
import { customer } from "../utils/sardine";
@@ -566,6 +576,56 @@ async function encryptPIN(pin: string) {
566576
if (!mutex.isLocked()) mutexes.delete(credentialId);
567577
});
568578
},
579+
)
580+
.get(
581+
"/wallet",
582+
auth(),
583+
describeRoute({
584+
summary: "Get wallet provisioning credentials",
585+
tags: ["Card"],
586+
security: [{ credentialAuth: [] }],
587+
validateResponse: true,
588+
responses: {
589+
200: {
590+
description: "Wallet provisioning credentials",
591+
content: {
592+
"application/json": {
593+
schema: resolver(object({ cardId: string(), cardSecret: string() }), { errorMode: "ignore" }),
594+
},
595+
},
596+
},
597+
403: {
598+
description: "Forbidden",
599+
content: {
600+
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
601+
},
602+
},
603+
404: {
604+
description: "Not found",
605+
content: {
606+
"application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
607+
},
608+
},
609+
},
610+
}),
611+
async (c) => {
612+
const { credentialId } = c.req.valid("cookie");
613+
const credential = await database.query.credentials.findFirst({
614+
where: eq(credentials.id, credentialId),
615+
columns: { pandaId: true },
616+
with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } },
617+
});
618+
if (!credential) return c.json({ code: "no credential" }, 500);
619+
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
620+
if (!credential.cards[0]) return c.json({ code: "no card" }, 404);
621+
try {
622+
const provisioning = await getProcessorDetails(credential.cards[0].id);
623+
return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret }, 200);
624+
} catch (error) {
625+
if (error instanceof ServiceError && error.status === 404) return c.json({ code: "no card" }, 404);
626+
throw error;
627+
}
628+
},
569629
);
570630

571631
const CardUUID = pipe(string(), uuid());

server/test/api/card.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,113 @@ describe("authenticated", () => {
797797
expect(card?.status).toBe("DELETED");
798798
});
799799

800+
describe("wallet", () => {
801+
const walletCardId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
802+
const walletFrozenCardId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
803+
804+
beforeAll(async () => {
805+
await database.insert(credentials).values([
806+
{
807+
id: "wallet-active",
808+
publicKey: new Uint8Array(),
809+
account: padHex("0xaa01", { size: 20 }),
810+
factory: inject("ExaAccountFactory"),
811+
pandaId: "wallet-active",
812+
},
813+
{
814+
id: "wallet-frozen",
815+
publicKey: new Uint8Array(),
816+
account: padHex("0xaa02", { size: 20 }),
817+
factory: inject("ExaAccountFactory"),
818+
pandaId: "wallet-frozen",
819+
},
820+
{
821+
id: "wallet-no-card",
822+
publicKey: new Uint8Array(),
823+
account: padHex("0xaa03", { size: 20 }),
824+
factory: inject("ExaAccountFactory"),
825+
pandaId: "wallet-no-card",
826+
},
827+
{
828+
id: "wallet-no-panda",
829+
publicKey: new Uint8Array(),
830+
account: padHex("0xaa04", { size: 20 }),
831+
factory: inject("ExaAccountFactory"),
832+
},
833+
]);
834+
await database.insert(cards).values([
835+
{ id: walletCardId, credentialId: "wallet-active", lastFour: "0001" },
836+
{ id: walletFrozenCardId, credentialId: "wallet-frozen", lastFour: "0002", status: "FROZEN" },
837+
{ id: "wallet-deleted", credentialId: "wallet-no-card", lastFour: "0003", status: "DELETED" },
838+
]);
839+
});
840+
841+
it("returns credentials for active card", async () => {
842+
vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({
843+
processorCardId: "proc-active",
844+
timeBasedSecret: "secret-active",
845+
});
846+
847+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
848+
849+
expect(response.status).toBe(200);
850+
await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-active", cardSecret: "secret-active" });
851+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
852+
});
853+
854+
it("returns credentials for frozen card", async () => {
855+
vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({
856+
processorCardId: "proc-frozen",
857+
timeBasedSecret: "secret-frozen",
858+
});
859+
860+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-frozen" } });
861+
862+
expect(response.status).toBe(200);
863+
await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-frozen", cardSecret: "secret-frozen" });
864+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletFrozenCardId);
865+
});
866+
867+
it("returns 500 when panda api fails", async () => {
868+
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Rain", 500, "internal error"));
869+
870+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
871+
872+
expect(response.status).toBe(500);
873+
});
874+
875+
it("returns 404 when panda card is stale", async () => {
876+
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 404, "not found"));
877+
878+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
879+
880+
expect(response.status).toBe(404);
881+
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
882+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
883+
});
884+
885+
it("returns 404 when only deleted card", async () => {
886+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-card" } });
887+
888+
expect(response.status).toBe(404);
889+
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
890+
});
891+
892+
it("returns 403 when no panda customer", async () => {
893+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-panda" } });
894+
895+
expect(response.status).toBe(403);
896+
await expect(response.json()).resolves.toStrictEqual({ code: "no panda" });
897+
});
898+
899+
it("returns 500 when credential not found", async () => {
900+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "nonexistent" } });
901+
902+
expect(response.status).toBe(500);
903+
await expect(response.json()).resolves.toStrictEqual({ code: "no credential" });
904+
});
905+
});
906+
800907
describe("migration", () => {
801908
it("creates a panda card having a cm card with upgraded plugin", async () => {
802909
await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]);

server/utils/panda.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ export async function getCard(cardId: string) {
111111
return await request(CardResponse, `/issuing/cards/${cardId}`);
112112
}
113113

114+
export function getProcessorDetails(cardId: string) {
115+
return request(
116+
object({ processorCardId: string(), timeBasedSecret: string() }),
117+
`/issuing/cards/${cardId}/processorDetails`,
118+
);
119+
}
120+
114121
export async function updateCard(card: {
115122
billing?: {
116123
city: string;

0 commit comments

Comments
 (0)