Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wise-comics-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add pax for platinum to signature card upgrades
57 changes: 51 additions & 6 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import {
} from "valibot";

import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
import { SIGNATURE_PRODUCT_ID } from "@exactly/common/panda";
import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda";
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 { addCapita, deriveAssociateId } from "../utils/pax";
import { getAccount } from "../utils/persona";
import { customer } from "../utils/sardine";
import { track } from "../utils/segment";
import validatorHook from "../utils/validatorHook";
Expand Down Expand Up @@ -329,16 +331,26 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
where: eq(credentials.id, credentialId),
columns: { account: true, pandaId: true },
with: {
cards: { columns: { id: true, status: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) },
cards: {
columns: { id: true, status: true, productId: true },
where: inArray(cards.status, ["ACTIVE", "FROZEN", "DELETED"]),
},
},
});
if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500);
const account = parse(Address, credential.account);
setUser({ id: account });

if (!credential.pandaId) return c.json({ code: "no panda", legacy: "panda id not found" }, 403);
let cardCount = credential.cards.length;
for (const card of credential.cards) {

let isUpgradeFromPlatinum = credential.cards.some(
(card) => card.status === "DELETED" && card.productId === PLATINUM_PRODUCT_ID,
);

const activeCards = credential.cards.filter((card) => card.status === "ACTIVE" || card.status === "FROZEN");

let cardCount = activeCards.length;
for (const card of activeCards) {
try {
await getCard(parse(CardUUID, card.id));
} catch (error) {
Expand All @@ -349,6 +361,9 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
await database.update(cards).set({ status: "DELETED" }).where(eq(cards.id, card.id));
cardCount--;
setContext("cryptomate card deleted", { id: card.id });
if (card.productId === PLATINUM_PRODUCT_ID) {
isUpgradeFromPlatinum = true;
}
} else {
throw error;
}
Expand All @@ -367,6 +382,8 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
.values([{ id: card.id, credentialId, lastFour: card.last4, mode, productId: SIGNATURE_PRODUCT_ID }]);
track({ event: "CardIssued", userId: account, properties: { productId: SIGNATURE_PRODUCT_ID } });

if (isUpgradeFromPlatinum) handlePlatinumUpgrade(credentialId, account);

customer({
flow: { name: "card.issued", type: "payment_method_link" },
customer: { id: credentialId, type: "customer" },
Expand Down Expand Up @@ -445,12 +462,12 @@ async function encryptPIN(pin: string) {
secretKeyBase64Buffer,
);
const sessionId = secretKeyBase64BufferEncrypted.toString("base64");

const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-128-gcm", Buffer.from(secret, "hex"), iv);
const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();

return {
data: Buffer.concat([encrypted, authTag]).toString("base64"),
iv: iv.toString("base64"),
Expand Down Expand Up @@ -565,3 +582,31 @@ function buildBaseResponse(example = "string") {
legacy: pipe(string(), metadata({ examples: [example] })),
});
}

function handlePlatinumUpgrade(credentialId: string, account: InferOutput<typeof Address>) {
getAccount(credentialId, "basic")
.then((personaAccount) => {
if (!personaAccount) throw new Error("no persona account found");
const attributes = personaAccount.attributes;
const documents = attributes.fields.documents.value;
if (!documents[0]) throw new Error("no identity document found");

return addCapita({
firstName: attributes["name-first"],
lastName: attributes["name-last"],
birthdate: attributes.birthdate,
document: documents[0].value.id_number.value,
email: attributes["email-address"],
phone: attributes["phone-number"],
internalId: deriveAssociateId(account),
product: "travel insurance",
});
})
.catch((error: unknown) => {
const isPaxConfigError = error instanceof Error && error.message.includes("missing pax");
captureException(error, {
level: isPaxConfigError ? "warning" : "error",
extra: { credentialId, account, productId: SIGNATURE_PRODUCT_ID, scope: "basic", isPaxConfigError },
});
});
}
238 changes: 237 additions & 1 deletion server/test/api/card.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import "../mocks/auth";
import "../mocks/deployments";
import "../mocks/keeper";
import "../mocks/pax";
import "../mocks/persona";
import "../mocks/sentry";

import { eq } from "drizzle-orm";
Expand All @@ -17,6 +19,8 @@ import app from "../../api/card";
import database, { cards, credentials } from "../../database";
import keeper from "../../utils/keeper";
import * as panda from "../../utils/panda";
import * as pax from "../../utils/pax";
import * as persona from "../../utils/persona";

const appClient = testClient(app);

Expand Down Expand Up @@ -76,7 +80,7 @@ describe("authenticated", () => {
);
});

afterEach(() => vi.restoreAllMocks());
afterEach(() => vi.resetAllMocks());

it("returns 404 card not found", async () => {
const response = await appClient.index.$get(
Expand Down Expand Up @@ -227,6 +231,238 @@ describe("authenticated", () => {
expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "1224", productId: SIGNATURE_PRODUCT_ID });
});

it("adds user to pax when signature card is issued (upgrade from platinum)", async () => {
const testCredentialId = "pax-test";
const testAccount = padHex("0x999", { size: 20 });
await database.insert(credentials).values({
id: testCredentialId,
publicKey: new Uint8Array(),
account: testAccount,
factory: inject("ExaAccountFactory"),
pandaId: "pax-test-panda",
});

await database.insert(cards).values({
id: "old-platinum-card",
credentialId: testCredentialId,
lastFour: "0000",
status: "DELETED",
productId: PLATINUM_PRODUCT_ID,
});

const deletedCard = await database.query.cards.findFirst({
where: eq(cards.id, "old-platinum-card"),
});
expect(deletedCard?.status).toBe("DELETED");
expect(deletedCard?.productId).toBe(PLATINUM_PRODUCT_ID);

const mockAccount = {
id: "acc_123",
type: "account" as const,
attributes: {
"name-first": "John",
"name-middle": null,
"name-last": "Doe",
birthdate: "1990-01-01",
"email-address": "john@example.com",
"phone-number": "+1234567890",
"country-code": "US",
"address-street-1": "123 Main St",
"address-street-2": null,
"address-city": "New York",
"address-subdivision": "NY",
"address-postal-code": "10001",
"social-security-number": null,
fields: {
name: {
value: {
first: { value: "John" },
middle: { value: null },
last: { value: "Doe" },
},
},
address: {
value: {
street_1: { value: "123 Main St" },
street_2: { value: null },
city: { value: "New York" },
subdivision: { value: "NY" },
postal_code: { value: "10001" },
},
},
documents: {
value: [
{
value: {
id_class: { value: "dl" },
id_number: { value: "DOC123456" },
id_issuing_country: { value: "US" },
id_document_id: { value: "doc_id_123" },
},
},
],
},
},
},
};

vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount);
vi.spyOn(pax, "addCapita").mockResolvedValueOnce({});
vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" });

const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } });

expect(response.status).toBe(200);

await vi.waitFor(() => {
expect(pax.addCapita).toHaveBeenCalledWith({
firstName: "John",
lastName: "Doe",
birthdate: "1990-01-01",
document: "DOC123456",
email: "john@example.com",
phone: "+1234567890",
internalId: expect.stringMatching(/.+/) as string,
product: "travel insurance",
});
});

expect(persona.getAccount).toHaveBeenCalledWith(testCredentialId, "basic");
});

it("does not add user to pax for new signature card (no upgrade)", async () => {
const testCredentialId = "new-user-test";
await database.insert(credentials).values({
id: testCredentialId,
publicKey: new Uint8Array(),
account: padHex("0x888", { size: 20 }),
factory: inject("ExaAccountFactory"),
pandaId: "new-user-panda",
});

vi.spyOn(pax, "addCapita").mockResolvedValueOnce({});
vi.spyOn(panda, "createCard").mockResolvedValueOnce({
...cardTemplate,
id: "new-user-card",
last4: "8888",
});

const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } });

expect(response.status).toBe(200);
const json = await response.json();
expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "8888", productId: SIGNATURE_PRODUCT_ID });

expect(pax.addCapita).not.toHaveBeenCalled();
});

it("handles pax api error during signature card creation", async () => {
const testCredentialId = "pax-error-test";
await database.insert(credentials).values({
id: testCredentialId,
publicKey: new Uint8Array(),
account: padHex("0x777", { size: 20 }),
factory: inject("ExaAccountFactory"),
pandaId: "pax-error-panda",
});

await database.insert(cards).values({
id: "old-platinum-error",
credentialId: testCredentialId,
lastFour: "0001",
status: "DELETED",
productId: PLATINUM_PRODUCT_ID,
});

const mockAccount = {
id: "acc_456",
type: "account" as const,
attributes: {
"name-first": "Jane",
"name-middle": null,
"name-last": "Smith",
birthdate: "1985-05-15",
"email-address": "jane@example.com",
"phone-number": "+9876543210",
"country-code": "US",
"address-street-1": "456 Oak Ave",
"address-street-2": null,
"address-city": "Boston",
"address-subdivision": "MA",
"address-postal-code": "02101",
"social-security-number": null,
fields: {
name: {
value: {
first: { value: "Jane" },
middle: { value: null },
last: { value: "Smith" },
},
},
address: {
value: {
street_1: { value: "456 Oak Ave" },
street_2: { value: null },
city: { value: "Boston" },
subdivision: { value: "MA" },
postal_code: { value: "02101" },
},
},
documents: {
value: [
{
value: {
id_class: { value: "passport" },
id_number: { value: "ABC987654" },
id_issuing_country: { value: "US" },
id_document_id: { value: "doc_id_456" },
},
},
],
},
},
},
};

vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount);
vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error"));
vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" });

const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } });

expect(response.status).toBe(200);
const json = await response.json();
expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "6666", productId: SIGNATURE_PRODUCT_ID });
});

it("handles missing persona account during signature card creation", async () => {
const testCredentialId = "no-account-test";
await database.insert(credentials).values({
id: testCredentialId,
publicKey: new Uint8Array(),
account: padHex("0x666", { size: 20 }),
factory: inject("ExaAccountFactory"),
pandaId: "no-account-panda",
});

await database.insert(cards).values({
id: "old-platinum-card-no-account",
credentialId: testCredentialId,
lastFour: "0000",
status: "DELETED",
productId: PLATINUM_PRODUCT_ID,
});

vi.spyOn(pax, "addCapita").mockResolvedValueOnce({});
vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "no-account-card", last4: "7777" });

const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } });

expect(response.status).toBe(200);

expect(pax.addCapita).not.toHaveBeenCalled();
});

it("cancels a card", async () => {
const cardResponse = { ...cardTemplate, id: "cardForCancel", last4: "1224", status: "active" as const };
vi.spyOn(panda, "createCard").mockResolvedValueOnce(cardResponse);
Expand Down
4 changes: 2 additions & 2 deletions server/test/mocks/pax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { vi } from "vitest";

vi.mock("../../utils/pax", async (importOriginal) => ({
...(await importOriginal()),
addCapita: vi.fn<() => Promise<Record<string, never>>>().mockResolvedValue({}),
removeCapita: vi.fn<() => Promise<void>>().mockResolvedValue(),
addCapita: vi.fn<(data: { internalId: string }) => Promise<Record<string, never>>>().mockResolvedValue({}),
removeCapita: vi.fn<(internalId: string) => Promise<void>>().mockResolvedValue(),
}));
Loading
Loading