Skip to content

Commit 6e97eda

Browse files
committed
🐛 server: make card creation idempotent
1 parent 28982d6 commit 6e97eda

6 files changed

Lines changed: 327 additions & 17 deletions

File tree

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+
🐛 make card creation idempotent

server/api/card.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
createCard,
4747
getApplicationStatus,
4848
getCard,
49+
getCards,
4950
getNonce,
5051
getPIN,
5152
getProcessorDetails,
@@ -431,6 +432,14 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
431432
},
432433
},
433434
},
435+
409: {
436+
description: "Conflict",
437+
content: {
438+
"application/json": {
439+
schema: resolver(object({ code: literal("card limit reached") }), { errorMode: "ignore" }),
440+
},
441+
},
442+
},
434443
},
435444
}),
436445
async (c) => {
@@ -453,6 +462,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
453462
setUser({ id: account });
454463

455464
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
465+
const pandaId = credential.pandaId;
456466

457467
let isUpgradeFromPlatinum = credential.cards.some(
458468
({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID,
@@ -480,26 +490,44 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
480490
}
481491
if (cardCount > 0) return c.json({ code: "already created" }, 400);
482492
try {
483-
const kyc = await getApplicationStatus(credential.pandaId);
493+
const kyc = await getApplicationStatus(pandaId);
484494
if (kyc.applicationStatus !== "approved") {
485495
return c.json({ code: "kyc not approved" }, 403);
486496
}
487-
const card = await createCard(
488-
credential.pandaId,
489-
SIGNATURE_PRODUCT_ID,
490-
await getAccount(credentialId, "cardLimit")
491-
.then((persona) =>
492-
persona?.attributes.fields.card_limit_usd?.value == null
493-
? undefined
494-
: persona.attributes.fields.card_limit_usd.value * 100,
495-
)
496-
.catch((error: unknown): undefined => {
497-
captureException(error, {
498-
level: "error",
499-
contexts: { details: { credentialId, scope: "cardLimit" } },
497+
const card = await getCards(pandaId)
498+
.then((pandaCards) => pandaCards.find(({ status }) => status === "active"))
499+
.then(async (orphan) => {
500+
if (orphan) {
501+
captureException(new Error("orphan card adopted"), {
502+
level: "warning",
503+
fingerprint: ["orphan-card-adopted"],
504+
extra: {
505+
credentialId,
506+
pandaId,
507+
cardId: orphan.id,
508+
},
500509
});
501-
}),
502-
);
510+
return orphan;
511+
} else {
512+
return createCard(
513+
pandaId,
514+
SIGNATURE_PRODUCT_ID,
515+
await getAccount(credentialId, "cardLimit")
516+
.then((persona) =>
517+
persona?.attributes.fields.card_limit_usd?.value == null
518+
? undefined
519+
: persona.attributes.fields.card_limit_usd.value * 100,
520+
)
521+
.catch((error: unknown): undefined => {
522+
captureException(error, {
523+
level: "error",
524+
contexts: { details: { credentialId, scope: "cardLimit" } },
525+
});
526+
}),
527+
);
528+
}
529+
});
530+
503531
let mode = 0;
504532
try {
505533
if (await autoCredit(account)) mode = 1;
@@ -551,6 +579,18 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
551579
200,
552580
);
553581
} catch (error) {
582+
if (
583+
error instanceof ServiceError &&
584+
error.status === 400 &&
585+
error.message.includes("maximum number of cards allowed")
586+
) {
587+
captureException(error, {
588+
level: "warning",
589+
fingerprint: ["card-limit-reached"],
590+
extra: { credentialId, pandaId },
591+
});
592+
return c.json({ code: "card limit reached" }, 409);
593+
}
554594
const issue = noUser(error);
555595
if (!issue) throw error;
556596
const hasCardHistory = credential.cards.length > 0;
@@ -567,7 +607,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
567607
extra: {
568608
credentialId,
569609
hasCardHistory,
570-
pandaId: credential.pandaId,
610+
pandaId,
571611
statuses: credential.cards.map(({ status }) => status),
572612
userIssue: issue.type,
573613
},

server/test/api/card.test.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ describe("authenticated", () => {
134134
afterEach(() => vi.resetAllMocks());
135135
beforeEach(() => {
136136
vi.spyOn(persona, "getAccount").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined
137+
vi.spyOn(panda, "getCards").mockResolvedValue([]);
137138
});
138139

139140
it("returns 404 card not found", async () => {
@@ -532,6 +533,232 @@ describe("authenticated", () => {
532533
expect(captureException).not.toHaveBeenCalled();
533534
});
534535

536+
it("returns 409 card limit reached when panda rejects with the max cards error", async () => {
537+
const credentialId = "card-limit-reached";
538+
await database.insert(credentials).values({
539+
id: credentialId,
540+
publicKey: new Uint8Array(),
541+
account: padHex("0x4042", { size: 20 }),
542+
factory: inject("ExaAccountFactory"),
543+
pandaId: credentialId,
544+
});
545+
546+
vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" });
547+
const createCard = vi
548+
.spyOn(panda, "createCard")
549+
.mockRejectedValueOnce(
550+
new ServiceError(
551+
"Panda",
552+
400,
553+
'{"message":"User has reached the maximum number of cards allowed: 3","error":"BadRequestError","statusCode":400}',
554+
),
555+
);
556+
557+
const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } });
558+
559+
expect(response.status).toBe(409);
560+
await expect(response.json()).resolves.toStrictEqual({ code: "card limit reached" });
561+
expect(createCard).toHaveBeenCalledOnce();
562+
expect(captureException).toHaveBeenCalledExactlyOnceWith(expect.any(ServiceError) as ServiceError, {
563+
level: "warning",
564+
fingerprint: ["card-limit-reached"],
565+
extra: { credentialId, pandaId: credentialId },
566+
});
567+
const persisted = await database.query.cards.findFirst({ where: eq(cards.credentialId, credentialId) });
568+
expect(persisted).toBeUndefined();
569+
});
570+
571+
it("throws when createCard fails with an unrelated 400 error", async () => {
572+
const credentialId = "card-bad-request";
573+
await database.insert(credentials).values({
574+
id: credentialId,
575+
publicKey: new Uint8Array(),
576+
account: padHex("0x4043", { size: 20 }),
577+
factory: inject("ExaAccountFactory"),
578+
pandaId: credentialId,
579+
});
580+
581+
vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" });
582+
const createCard = vi
583+
.spyOn(panda, "createCard")
584+
.mockRejectedValueOnce(
585+
new ServiceError("Panda", 400, '{"message":"Invalid request","error":"BadRequestError","statusCode":400}'),
586+
);
587+
588+
const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } });
589+
590+
expect(response.status).toBe(500);
591+
expect(createCard).toHaveBeenCalledOnce();
592+
expect(captureException).not.toHaveBeenCalled();
593+
});
594+
595+
it("adopts an existing active panda card instead of creating a duplicate", async () => {
596+
const credentialId = "orphan-adopt";
597+
const orphanId = "00000000-0000-4000-8000-0000000000aa";
598+
await database.insert(credentials).values({
599+
id: credentialId,
600+
publicKey: new Uint8Array(),
601+
account: padHex("0x4051", { size: 20 }),
602+
factory: inject("ExaAccountFactory"),
603+
pandaId: credentialId,
604+
});
605+
606+
vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" });
607+
vi.spyOn(panda, "getCards").mockResolvedValueOnce([
608+
{ id: orphanId, status: "active", last4: "4242", expirationMonth: "9", expirationYear: "2029" },
609+
]);
610+
const createCard = vi.spyOn(panda, "createCard");
611+
const getAccount = vi.spyOn(persona, "getAccount");
612+
613+
const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } });
614+
615+
expect(response.status).toBe(200);
616+
await expect(response.json()).resolves.toStrictEqual({
617+
status: "ACTIVE",
618+
lastFour: "4242",
619+
cardId: orphanId,
620+
productId: SIGNATURE_PRODUCT_ID,
621+
});
622+
expect(createCard).not.toHaveBeenCalled();
623+
expect(getAccount).not.toHaveBeenCalled();
624+
const adopted = await database.query.cards.findFirst({
625+
columns: { id: true, status: true, lastFour: true, productId: true },
626+
where: eq(cards.credentialId, credentialId),
627+
});
628+
expect(adopted).toStrictEqual({
629+
id: orphanId,
630+
status: "ACTIVE",
631+
lastFour: "4242",
632+
productId: SIGNATURE_PRODUCT_ID,
633+
});
634+
expect(captureException).toHaveBeenCalledExactlyOnceWith(expect.any(Error) as Error, {
635+
level: "warning",
636+
fingerprint: ["orphan-card-adopted"],
637+
extra: { credentialId, pandaId: credentialId, cardId: orphanId },
638+
});
639+
});
640+
641+
it("adopts only the first active card when panda has multiple orphans", async () => {
642+
const credentialId = "orphan-multi";
643+
const first = "00000000-0000-4000-8000-0000000000c1";
644+
const second = "00000000-0000-4000-8000-0000000000c2";
645+
await database.insert(credentials).values({
646+
id: credentialId,
647+
publicKey: new Uint8Array(),
648+
account: padHex("0x4053", { size: 20 }),
649+
factory: inject("ExaAccountFactory"),
650+
pandaId: credentialId,
651+
});
652+
653+
vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" });
654+
vi.spyOn(panda, "getCards").mockResolvedValueOnce([
655+
{ id: first, status: "active", last4: "4444", expirationMonth: "9", expirationYear: "2029" },
656+
{ id: second, status: "active", last4: "5555", expirationMonth: "9", expirationYear: "2029" },
657+
{
658+
id: "00000000-0000-4000-8000-0000000000c3",
659+
status: "canceled",
660+
last4: "6666",
661+
expirationMonth: "9",
662+
expirationYear: "2029",
663+
},
664+
]);
665+
const createCard = vi.spyOn(panda, "createCard");
666+
667+
const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } });
668+
669+
expect(response.status).toBe(200);
670+
await expect(response.json()).resolves.toStrictEqual({
671+
status: "ACTIVE",
672+
lastFour: "4444",
673+
cardId: first,
674+
productId: SIGNATURE_PRODUCT_ID,
675+
});
676+
expect(createCard).not.toHaveBeenCalled();
677+
const persisted = await database.query.cards.findMany({
678+
columns: { id: true },
679+
where: eq(cards.credentialId, credentialId),
680+
});
681+
expect(persisted).toStrictEqual([{ id: first }]);
682+
expect(captureException).toHaveBeenCalledExactlyOnceWith(expect.any(Error) as Error, {
683+
level: "warning",
684+
fingerprint: ["orphan-card-adopted"],
685+
extra: { credentialId, pandaId: credentialId, cardId: first },
686+
});
687+
});
688+
689+
it("creates a new card when panda has only non-active cards", async () => {
690+
const credentialId = "orphan-nonactive";
691+
const createdId = "00000000-0000-4000-8000-0000000000bb";
692+
await database.insert(credentials).values({
693+
id: credentialId,
694+
publicKey: new Uint8Array(),
695+
account: padHex("0x4052", { size: 20 }),
696+
factory: inject("ExaAccountFactory"),
697+
pandaId: credentialId,
698+
});
699+
700+
vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" });
701+
vi.spyOn(panda, "getCards").mockResolvedValueOnce([
702+
{
703+
id: "00000000-0000-4000-8000-0000000000b1",
704+
status: "canceled",
705+
last4: "1111",
706+
expirationMonth: "9",
707+
expirationYear: "2029",
708+
},
709+
{
710+
id: "00000000-0000-4000-8000-0000000000b2",
711+
status: "locked",
712+
last4: "2222",
713+
expirationMonth: "9",
714+
expirationYear: "2029",
715+
},
716+
]);
717+
const createCard = vi
718+
.spyOn(panda, "createCard")
719+
.mockResolvedValueOnce({ ...cardTemplate, id: createdId, last4: "3333" });
720+
721+
const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } });
722+
723+
expect(response.status).toBe(200);
724+
await expect(response.json()).resolves.toStrictEqual({
725+
status: "ACTIVE",
726+
lastFour: "3333",
727+
cardId: createdId,
728+
productId: SIGNATURE_PRODUCT_ID,
729+
});
730+
expect(createCard).toHaveBeenCalledOnce();
731+
expect(captureException).not.toHaveBeenCalled();
732+
const created = await database.query.cards.findFirst({
733+
columns: { id: true },
734+
where: eq(cards.credentialId, credentialId),
735+
});
736+
expect(created).toStrictEqual({ id: createdId });
737+
});
738+
739+
it("throws and does not create a card when getCards fails", async () => {
740+
const credentialId = "orphan-list-fail";
741+
await database.insert(credentials).values({
742+
id: credentialId,
743+
publicKey: new Uint8Array(),
744+
account: padHex("0x4054", { size: 20 }),
745+
factory: inject("ExaAccountFactory"),
746+
pandaId: credentialId,
747+
});
748+
749+
vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" });
750+
vi.spyOn(panda, "getCards").mockRejectedValueOnce(new ServiceError("Panda", 500, "internal error"));
751+
const createCard = vi.spyOn(panda, "createCard");
752+
753+
const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } });
754+
755+
expect(response.status).toBe(500);
756+
expect(createCard).not.toHaveBeenCalled();
757+
expect(captureException).not.toHaveBeenCalled();
758+
const persisted = await database.query.cards.findFirst({ where: eq(cards.credentialId, credentialId) });
759+
expect(persisted).toBeUndefined();
760+
});
761+
535762
it("returns 403 no panda when getApplicationStatus reports user not found", async () => {
536763
const credentialId = "stale-panda-id";
537764
await database.insert(credentials).values({

server/test/e2e.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise<typeof panda>) =>
8585
return Promise.resolve({ id });
8686
}),
8787
getCard: vi.fn().mockImplementation((cardId: string) => Promise.resolve(cards.get(cardId))),
88+
getCards: vi.fn().mockResolvedValue([]),
8889
getPIN: vi.fn().mockResolvedValue({ pin: null }),
8990
getProcessorDetails: vi
9091
.fn()

0 commit comments

Comments
 (0)