diff --git a/.changeset/wise-comics-know.md b/.changeset/wise-comics-know.md new file mode 100644 index 000000000..0878d6212 --- /dev/null +++ b/.changeset/wise-comics-know.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add pax for platinum to signature card upgrades diff --git a/server/api/card.ts b/server/api/card.ts index a74712079..33961f884 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -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"; @@ -329,7 +331,10 @@ 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); @@ -337,8 +342,15 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str 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( + ({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID, + ); + + const activeCards = credential.cards.filter(({ status }) => status === "ACTIVE" || status === "FROZEN"); + + let cardCount = activeCards.length; + for (const card of activeCards) { try { await getCard(parse(CardUUID, card.id)); } catch (error) { @@ -349,6 +361,7 @@ 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; } @@ -367,6 +380,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" }, @@ -445,12 +460,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"), @@ -565,3 +580,31 @@ function buildBaseResponse(example = "string") { legacy: pipe(string(), metadata({ examples: [example] })), }); } + +function handlePlatinumUpgrade(credentialId: string, account: InferOutput) { + 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 }, + }); + }); +} diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index c72f6ff3a..84d5f9993 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -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"; @@ -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); @@ -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( @@ -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); diff --git a/server/test/mocks/pax.ts b/server/test/mocks/pax.ts index a9120a118..7b96d0aa2 100644 --- a/server/test/mocks/pax.ts +++ b/server/test/mocks/pax.ts @@ -2,6 +2,6 @@ import { vi } from "vitest"; vi.mock("../../utils/pax", async (importOriginal) => ({ ...(await importOriginal()), - addCapita: vi.fn<() => Promise>>().mockResolvedValue({}), - removeCapita: vi.fn<() => Promise>().mockResolvedValue(), + addCapita: vi.fn<(data: { internalId: string }) => Promise>>().mockResolvedValue({}), + removeCapita: vi.fn<(internalId: string) => Promise>().mockResolvedValue(), })); diff --git a/server/utils/pax.ts b/server/utils/pax.ts index d72edacea..661a4f166 100644 --- a/server/utils/pax.ts +++ b/server/utils/pax.ts @@ -1,17 +1,5 @@ import { captureException, setContext } from "@sentry/node"; -import { - description, - email, - flatten, - object, - pipe, - safeParse, - string, - ValiError, - type BaseIssue, - type BaseSchema, - type InferInput, -} from "valibot"; +import { flatten, object, safeParse, ValiError, type BaseIssue, type BaseSchema } from "valibot"; import { encodePacked, keccak256 } from "viem"; import type { Address } from "@exactly/common/validation"; @@ -27,17 +15,16 @@ const associateIdSecret = process.env.PAX_ASSOCIATE_ID_KEY; const ASSOCIATE_ID_LENGTH = 10; -export const CapitaRequest = object({ - firstName: string(), - lastName: string(), - document: string(), - birthdate: string(), - email: pipe(string(), email()), - phone: string(), - product: pipe(string(), description("the product name to add the capita to")), -}); - -export async function addCapita(data: InferInput & { internalId: string }) { +export async function addCapita(data: { + birthdate: string; + document: string; + email: string; + firstName: string; + internalId: string; + lastName: string; + phone: string; + product: string; +}) { return await request(object({}), "/api/capita", data, "POST"); }