Skip to content

Commit 1cf381c

Browse files
committed
✨ server: add wallet provisioning endpoint
1 parent e76f034 commit 1cf381c

4 files changed

Lines changed: 218 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: 79 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";
@@ -77,6 +87,8 @@ const CreatedCardResponse = object({
7787
productId: pipe(string(), metadata({ examples: ["402"] })),
7888
});
7989

90+
const WalletResponse = object({ cardId: string(), cardSecret: string() });
91+
8092
const UpdateCard = union([
8193
pipe(
8294
strictObject({ mode: pipe(number(), integer(), minValue(0), maxValue(MAX_INSTALLMENTS)) }),
@@ -566,6 +578,72 @@ async function encryptPIN(pin: string) {
566578
if (!mutex.isLocked()) mutexes.delete(credentialId);
567579
});
568580
},
581+
)
582+
.get(
583+
"/wallet",
584+
auth(),
585+
describeRoute({
586+
summary: "Get wallet provisioning credentials",
587+
tags: ["Card"],
588+
security: [{ credentialAuth: [] }],
589+
validateResponse: true,
590+
responses: {
591+
200: {
592+
description: "Wallet provisioning credentials",
593+
content: {
594+
"application/json": {
595+
schema: resolver(WalletResponse, { errorMode: "ignore" }),
596+
},
597+
},
598+
},
599+
403: {
600+
description: "Forbidden",
601+
content: {
602+
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
603+
},
604+
},
605+
404: {
606+
description: "Not found",
607+
content: {
608+
"application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
609+
},
610+
},
611+
500: {
612+
description: "Internal server error",
613+
content: {
614+
"application/json": {
615+
schema: resolver(object({ code: literal("no credential") }), { errorMode: "ignore" }),
616+
},
617+
},
618+
},
619+
},
620+
}),
621+
async (c) => {
622+
const { credentialId } = c.req.valid("cookie");
623+
const credential = await database.query.credentials.findFirst({
624+
where: eq(credentials.id, credentialId),
625+
columns: { pandaId: true, account: true },
626+
with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } },
627+
});
628+
if (!credential) return c.json({ code: "no credential" }, 500);
629+
setUser({ id: parse(Address, credential.account) });
630+
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
631+
if (!credential.cards[0]) return c.json({ code: "no card" }, 404);
632+
try {
633+
const provisioning = await getProcessorDetails(credential.cards[0].id);
634+
c.header("Cache-Control", "no-store");
635+
return c.json(
636+
{ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<
637+
typeof WalletResponse
638+
>,
639+
200,
640+
);
641+
} catch (error) {
642+
if (error instanceof ServiceError && error.status === 404) return c.json({ code: "no card" }, 404);
643+
if (noUser(error)) return c.json({ code: "no panda" }, 403);
644+
throw error;
645+
}
646+
},
569647
);
570648

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

server/test/api/card.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,133 @@ 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+
expect(response.headers.get("Cache-Control")).toBe("no-store");
851+
await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-active", cardSecret: "secret-active" });
852+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
853+
});
854+
855+
it("returns credentials for frozen card", async () => {
856+
vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({
857+
processorCardId: "proc-frozen",
858+
timeBasedSecret: "secret-frozen",
859+
});
860+
861+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-frozen" } });
862+
863+
expect(response.status).toBe(200);
864+
expect(response.headers.get("Cache-Control")).toBe("no-store");
865+
await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-frozen", cardSecret: "secret-frozen" });
866+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletFrozenCardId);
867+
});
868+
869+
it("returns 500 when panda api fails", async () => {
870+
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Rain", 500, "internal error"));
871+
872+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
873+
874+
expect(response.status).toBe(500);
875+
});
876+
877+
it("returns 404 when panda card is stale", async () => {
878+
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 404, "not found"));
879+
880+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
881+
882+
expect(response.status).toBe(404);
883+
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
884+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
885+
});
886+
887+
it("returns 403 when panda user is not approved", async () => {
888+
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(
889+
new ServiceError(
890+
"Panda",
891+
403,
892+
'{"message":"User exists but is not approved yet","error":"ForbiddenError","statusCode":403}',
893+
"ForbiddenError",
894+
"User exists but is not approved yet",
895+
),
896+
);
897+
898+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
899+
900+
expect(response.status).toBe(403);
901+
await expect(response.json()).resolves.toStrictEqual({ code: "no panda" });
902+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
903+
});
904+
905+
it("returns 404 when only deleted card", async () => {
906+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-card" } });
907+
908+
expect(response.status).toBe(404);
909+
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
910+
});
911+
912+
it("returns 403 when no panda customer", async () => {
913+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-panda" } });
914+
915+
expect(response.status).toBe(403);
916+
await expect(response.json()).resolves.toStrictEqual({ code: "no panda" });
917+
});
918+
919+
it("returns 500 when credential not found", async () => {
920+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "nonexistent" } });
921+
922+
expect(response.status).toBe(500);
923+
await expect(response.json()).resolves.toStrictEqual({ code: "no credential" });
924+
});
925+
});
926+
800927
describe("migration", () => {
801928
it("creates a panda card having a cm card with upgraded plugin", async () => {
802929
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 async function getProcessorDetails(cardId: string) {
115+
return await 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)