Skip to content
Open
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
52 changes: 48 additions & 4 deletions server/api/card.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 @@ -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;
Comment on lines +341 to +350

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Pax enrollment missed for platinum users upgrading via migration path

Users whose platinum card gets deleted during the migration process will not be enrolled in pax travel insurance, even though they are effectively upgrading from platinum to signature.

Click to expand

Root Cause

The isUpgradeFromPlatinum check at lines 342-350 queries for cards with status: "DELETED" and productId: PLATINUM_PRODUCT_ID before the migration loop at lines 354-369 runs. The migration loop marks cards as DELETED if they don't exist in Panda.

Scenario

  1. User has an ACTIVE platinum card that no longer exists in Panda
  2. deletedPlatinumCard query runs and finds nothing (card is still ACTIVE)
  3. isUpgradeFromPlatinum is set to false
  4. Migration loop runs, marks the platinum card as DELETED
  5. New signature card is created
  6. Pax enrollment is skipped because isUpgradeFromPlatinum is false

Actual vs Expected

  • Actual: User upgrading from platinum via migration doesn't get pax travel insurance
  • Expected: User should be enrolled in pax since they had a platinum card that was deleted as part of upgrading to signature

Impact

Users who had platinum cards that were migrated/deleted during the upgrade process miss out on the travel insurance benefit they should receive.

Recommendation: Move the isUpgradeFromPlatinum check to after the migration loop, or track which cards get deleted during migration and check if any of them were platinum cards. For example:

let isUpgradeFromPlatinum = !!deletedPlatinumCard;
for (const card of credential.cards) {
  // ... existing logic ...
  if (/* card deleted */ && card.productId === PLATINUM_PRODUCT_ID) {
    isUpgradeFromPlatinum = true;
  }
}

Note: This also requires adding productId to the cards query at line 334.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


Comment on lines +342 to +351
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A user can be enrolled in Pax multiple times by repeatedly creating and deleting a signature card, as the upgrade check doesn't track previous enrollments.
Severity: MEDIUM

Suggested Fix

To prevent duplicate enrollments, introduce a mechanism to track whether a user has already received the Pax upgrade benefit. This could be a new flag in the user's profile or a separate table that logs Pax enrollments. Before calling addCapitaForPlatinumUpgrade, check this flag to ensure the user hasn't been enrolled before.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: server/api/card.ts#L342-L351

Potential issue: The logic for Pax enrollment during a card upgrade can be triggered
multiple times for the same user. A user with a previously deleted platinum card who
creates a signature card gets enrolled in Pax. If they then delete this signature card
and create another one, the system will enroll them in Pax again. This happens because
the upgrade check only looks for a deleted platinum card and doesn't verify if the user
has already been enrolled. The check preventing multiple active cards (`cardCount > 0`)
doesn't account for deleted signature cards, allowing the re-creation flow. This can
lead to duplicate travel insurance policies and potential billing issues.

Did we get this right? 👍 / 👎 to inform future reviews.

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) {
Expand Down Expand Up @@ -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);
}
Comment on lines +383 to +385
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider using void operator to make fire-and-forget intent explicit.

The fire-and-forget pattern is appropriate here to avoid blocking card creation. However, prepending void makes the intent explicit and suppresses potential linter warnings about floating promises.

♻️ Suggested change
          if (isUpgradeFromPlatinum) {
-           addCapitaForPlatinumUpgrade(credentialId, account);
+           void addCapitaForPlatinumUpgrade(credentialId, account);
          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isUpgradeFromPlatinum) {
addCapitaForPlatinumUpgrade(credentialId, account);
}
if (isUpgradeFromPlatinum) {
void addCapitaForPlatinumUpgrade(credentialId, account);
}
🤖 Prompt for AI Agents
In `@server/api/card.ts` around lines 383 - 385, The call to
addCapitaForPlatinumUpgrade(credentialId, account) is intended as
fire-and-forget but currently returns a floating promise; prefix the call with
the void operator to make the intent explicit and satisfy linters (i.e., change
the invocation inside the isUpgradeFromPlatinum block to void
addCapitaForPlatinumUpgrade(credentialId, account)). Ensure you do not await it
and keep any surrounding logic unchanged so card creation remains non-blocking.


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,30 @@ function buildBaseResponse(example = "string") {
legacy: pipe(string(), metadata({ examples: [example] })),
});
}

function addCapitaForPlatinumUpgrade(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) => {
captureException(error, {
level: "error",
extra: { credentialId, account, productId: SIGNATURE_PRODUCT_ID, scope: "basic" },
});
});
}
260 changes: 259 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,10 @@ describe("authenticated", () => {
);
});

afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it("returns 404 card not found", async () => {
const response = await appClient.index.$get(
Expand Down Expand Up @@ -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<ReturnType<typeof persona.getAccount>>,
);
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);
Expand Down
Loading
Loading