Skip to content

Commit 2f6644b

Browse files
committed
✨ server: add wallet provisioning endpoint
1 parent dba36d8 commit 2f6644b

4 files changed

Lines changed: 215 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: 78 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,71 @@ 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+
return c.json(
635+
{ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<
636+
typeof WalletResponse
637+
>,
638+
200,
639+
);
640+
} catch (error) {
641+
if (error instanceof ServiceError && error.status === 404) return c.json({ code: "no card" }, 404);
642+
if (noUser(error)) return c.json({ code: "no panda" }, 403);
643+
throw error;
644+
}
645+
},
569646
);
570647

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

server/test/api/card.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,131 @@ 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 403 when panda user is not approved", async () => {
886+
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(
887+
new ServiceError(
888+
"Panda",
889+
403,
890+
'{"message":"User exists but is not approved yet","error":"ForbiddenError","statusCode":403}',
891+
"ForbiddenError",
892+
"User exists but is not approved yet",
893+
),
894+
);
895+
896+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });
897+
898+
expect(response.status).toBe(403);
899+
await expect(response.json()).resolves.toStrictEqual({ code: "no panda" });
900+
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
901+
});
902+
903+
it("returns 404 when only deleted card", async () => {
904+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-card" } });
905+
906+
expect(response.status).toBe(404);
907+
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
908+
});
909+
910+
it("returns 403 when no panda customer", async () => {
911+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-panda" } });
912+
913+
expect(response.status).toBe(403);
914+
await expect(response.json()).resolves.toStrictEqual({ code: "no panda" });
915+
});
916+
917+
it("returns 500 when credential not found", async () => {
918+
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "nonexistent" } });
919+
920+
expect(response.status).toBe(500);
921+
await expect(response.json()).resolves.toStrictEqual({ code: "no credential" });
922+
});
923+
});
924+
800925
describe("migration", () => {
801926
it("creates a panda card having a cm card with upgraded plugin", async () => {
802927
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)