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..3d82b7b2e 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -1,6 +1,6 @@ import { captureException, setContext, setUser } from "@sentry/node"; import { Mutex } from "async-mutex"; -import { eq, inArray, ne } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import { Hono } from "hono"; import { describeRoute } from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; @@ -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"; @@ -336,6 +338,17 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str const account = parse(Address, credential.account); setUser({ id: account }); + // check if this is an upgrade from platinum to signature + const deletedPlatinumCard = await database.query.cards.findFirst({ + where: and( + eq(cards.credentialId, credentialId), + eq(cards.status, "DELETED"), + eq(cards.productId, PLATINUM_PRODUCT_ID), + ), + columns: { id: true }, + }); + const isUpgradeFromPlatinum = !!deletedPlatinumCard; + 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) { @@ -367,6 +380,10 @@ 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) { + addCapitaForPlatinumUpgrade(credentialId, account); + } + customer({ flow: { name: "card.issued", type: "payment_method_link" }, customer: { id: credentialId, type: "customer" }, @@ -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"), @@ -565,3 +582,30 @@ function buildBaseResponse(example = "string") { legacy: pipe(string(), metadata({ examples: [example] })), }); } + +function addCapitaForPlatinumUpgrade(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) => { + captureException(error, { + level: "error", + extra: { credentialId, account, productId: SIGNATURE_PRODUCT_ID, scope: "basic" }, + }); + }); +} diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index c72f6ff3a..c37491847 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,10 @@ describe("authenticated", () => { ); }); - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); it("returns 404 card not found", async () => { const response = await appClient.index.$get( @@ -227,6 +234,257 @@ 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 () => { + // setup: create a test credential that doesn't have a card yet + 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", + }); + + // simulate upgrade: add a deleted platinum card + await database.insert(cards).values({ + id: "old-platinum-card", + credentialId: testCredentialId, + lastFour: "0000", + status: "DELETED", + productId: PLATINUM_PRODUCT_ID, + }); + + // verify the deleted platinum card exists to confirm upgrade scenario + 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); + + // mock persona account response with kyc data + 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); + + // wait for async pax call to complete + 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 () => { + // setup: create a new user with no previous cards + 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", + }); + + // no deleted platinum card = not an upgrade, just a new user + + 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 }); + + // pax should NOT be called for new users (no upgrade) + expect(pax.addCapita).not.toHaveBeenCalled(); + }); + + it("handles pax api error during signature card creation", async () => { + // setup + 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", + }); + + // simulate upgrade: add a deleted platinum card + 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 } }); + + // card creation should still succeed even if pax fails + 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 () => { + // setup + 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", + }); + + // simulate upgrade: add a deleted platinum card + await database.insert(cards).values({ + id: "old-platinum-card-no-account", + credentialId: testCredentialId, + lastFour: "0000", + status: "DELETED", + productId: PLATINUM_PRODUCT_ID, + }); + + vi.spyOn(persona, "getAccount").mockResolvedValueOnce( + null as unknown as Awaited>, + ); + 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 } }); + + // card creation should succeed even without persona account + expect(response.status).toBe(200); + + // pax should NOT be called when account is missing + 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..3e10372ae 100644 --- a/server/test/mocks/pax.ts +++ b/server/test/mocks/pax.ts @@ -1,7 +1,12 @@ import { vi } from "vitest"; +import type { CapitaRequest } from "../../utils/pax"; +import type { InferInput } from "valibot"; + vi.mock("../../utils/pax", async (importOriginal) => ({ ...(await importOriginal()), - addCapita: vi.fn<() => Promise>>().mockResolvedValue({}), - removeCapita: vi.fn<() => Promise>().mockResolvedValue(), + addCapita: vi + .fn<(data: InferInput & { internalId: string }) => Promise>>() + .mockResolvedValue({}), + removeCapita: vi.fn<(internalId: string) => Promise>().mockResolvedValue(), }));