diff --git a/apps/deploy-web/tests/seeders/payment.ts b/apps/deploy-web/tests/seeders/payment.ts
index 1b098bb901..d96fd09690 100644
--- a/apps/deploy-web/tests/seeders/payment.ts
+++ b/apps/deploy-web/tests/seeders/payment.ts
@@ -21,6 +21,22 @@ export const createMockPaymentMethod = (overrides = {}) => ({
...overrides
});
+export const createMockLinkPaymentMethod = (overrides = {}) => ({
+ id: `pm_${faker.string.alphanumeric(24)}`,
+ type: "link",
+ link: {
+ email: faker.internet.email()
+ },
+ billing_details: {
+ name: faker.person.fullName(),
+ email: faker.internet.email()
+ },
+ created: new Date().getTime(),
+ validated: true,
+ isDefault: false,
+ ...overrides
+});
+
export const createMockDiscount = (overrides = {}) => ({
id: `di_${faker.string.alphanumeric(24)}`,
coupon: {
diff --git a/packages/http-sdk/src/stripe/stripe.types.ts b/packages/http-sdk/src/stripe/stripe.types.ts
index 2914933bcf..60f309de0b 100644
--- a/packages/http-sdk/src/stripe/stripe.types.ts
+++ b/packages/http-sdk/src/stripe/stripe.types.ts
@@ -40,6 +40,9 @@ export interface PaymentMethod {
exp_month: number;
exp_year: number;
};
+ link?: {
+ email?: string;
+ };
}
export interface Charge {
From 7b693fb3ee562ba43f34eb22763e1149e1277281 Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Tue, 17 Feb 2026 22:49:28 -0500
Subject: [PATCH 2/8] fix: tests
---
.../__snapshots__/docs.spec.ts.snap | 30 +++++++++++++++++++
packages/http-sdk/src/stripe/stripe.types.ts | 4 +--
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap
index 4ce9ff68af..d1bf52679d 100644
--- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap
+++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap
@@ -12872,6 +12872,16 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"isDefault": {
"type": "boolean",
},
+ "link": {
+ "nullable": true,
+ "properties": {
+ "email": {
+ "nullable": true,
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
"type": {
"type": "string",
},
@@ -13028,6 +13038,16 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"isDefault": {
"type": "boolean",
},
+ "link": {
+ "nullable": true,
+ "properties": {
+ "email": {
+ "nullable": true,
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
"type": {
"type": "string",
},
@@ -13523,6 +13543,16 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"isDefault": {
"type": "boolean",
},
+ "link": {
+ "nullable": true,
+ "properties": {
+ "email": {
+ "nullable": true,
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
"type": {
"type": "string",
},
diff --git a/packages/http-sdk/src/stripe/stripe.types.ts b/packages/http-sdk/src/stripe/stripe.types.ts
index 60f309de0b..2efbd28796 100644
--- a/packages/http-sdk/src/stripe/stripe.types.ts
+++ b/packages/http-sdk/src/stripe/stripe.types.ts
@@ -41,8 +41,8 @@ export interface PaymentMethod {
exp_year: number;
};
link?: {
- email?: string;
- };
+ email?: string | null;
+ } | null;
}
export interface Charge {
From c156e493b5aa56db6168124e4b706cf2ad952101 Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:56:50 -0500
Subject: [PATCH 3/8] fix(billing): improve payment method schema validation
and optimize service methods
---
.../src/billing/http-schemas/stripe.schema.ts | 98 ++++++++++---------
.../billing/services/stripe/stripe.service.ts | 6 +-
.../PaymentMethodsView/PaymentMethodsRow.tsx | 12 +--
.../PaymentMethodCard/PaymentMethodCard.tsx | 4 +-
4 files changed, 61 insertions(+), 59 deletions(-)
diff --git a/apps/api/src/billing/http-schemas/stripe.schema.ts b/apps/api/src/billing/http-schemas/stripe.schema.ts
index c1f5b45652..68fd94c26b 100644
--- a/apps/api/src/billing/http-schemas/stripe.schema.ts
+++ b/apps/api/src/billing/http-schemas/stripe.schema.ts
@@ -13,53 +13,57 @@ export const PaymentMethodMarkAsDefaultInputSchema = z.object({
})
});
-export const PaymentMethodSchema = z.object({
- type: z.string(),
- validated: z.boolean().optional(),
- isDefault: z.boolean().optional(),
- card: z
- .object({
- brand: z.string().nullable(),
- last4: z.string().nullable(),
- exp_month: z.number(),
- exp_year: z.number(),
- funding: z.string().nullable().optional(),
- country: z.string().nullable().optional(),
- network: z.string().nullable().optional(),
- three_d_secure_usage: z
- .object({
- supported: z.boolean().nullable().optional()
- })
- .nullable()
- .optional()
- })
- .nullable()
- .optional(),
- link: z
- .object({
- email: z.string().nullable().optional()
- })
- .nullable()
- .optional(),
- billing_details: z
- .object({
- address: z
- .object({
- city: z.string().nullable(),
- country: z.string().nullable(),
- line1: z.string().nullable(),
- line2: z.string().nullable(),
- postal_code: z.string().nullable(),
- state: z.string().nullable()
- })
- .nullable()
- .optional(),
- email: z.string().nullable().optional(),
- name: z.string().nullable().optional(),
- phone: z.string().nullable().optional()
- })
- .optional()
-});
+export const PaymentMethodSchema = z
+ .object({
+ type: z.string(),
+ validated: z.boolean().optional(),
+ isDefault: z.boolean().optional(),
+ card: z
+ .object({
+ brand: z.string().nullable(),
+ last4: z.string().nullable(),
+ exp_month: z.number(),
+ exp_year: z.number(),
+ funding: z.string().nullable().optional(),
+ country: z.string().nullable().optional(),
+ network: z.string().nullable().optional(),
+ three_d_secure_usage: z
+ .object({
+ supported: z.boolean().nullable().optional()
+ })
+ .nullable()
+ .optional()
+ })
+ .nullable()
+ .optional(),
+ link: z
+ .object({
+ email: z.string().nullable().optional()
+ })
+ .nullable()
+ .optional(),
+ billing_details: z
+ .object({
+ address: z
+ .object({
+ city: z.string().nullable(),
+ country: z.string().nullable(),
+ line1: z.string().nullable(),
+ line2: z.string().nullable(),
+ postal_code: z.string().nullable(),
+ state: z.string().nullable()
+ })
+ .nullable()
+ .optional(),
+ email: z.string().nullable().optional(),
+ name: z.string().nullable().optional(),
+ phone: z.string().nullable().optional()
+ })
+ .optional()
+ })
+ .refine(data => !!(data.card || data.link), {
+ message: "At least one of card or link must be provided"
+ });
export const PaymentMethodsResponseSchema = z.object({
data: z.array(PaymentMethodSchema)
diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts
index 4e349996f0..e6d143fa26 100644
--- a/apps/api/src/billing/services/stripe/stripe.service.ts
+++ b/apps/api/src/billing/services/stripe/stripe.service.ts
@@ -155,7 +155,7 @@ export class StripeService extends Stripe {
return { ...remote, validated: local.isValidated, isDefault: local.isDefault };
}
- const fingerprint = StripeService.extractFingerprint(remote);
+ const fingerprint = this.extractFingerprint(remote);
assert(fingerprint, 403, "Payment method cannot be set as default. No identifiable fingerprint found.");
@@ -696,7 +696,7 @@ export class StripeService extends Stripe {
currentUserId
});
- const fingerprints = paymentMethods.map(paymentMethod => StripeService.extractFingerprint(paymentMethod)).filter(Boolean) as string[];
+ const fingerprints = paymentMethods.map(paymentMethod => this.extractFingerprint(paymentMethod)).filter(Boolean) as string[];
if (!fingerprints.length) {
return false;
@@ -935,7 +935,7 @@ export class StripeService extends Stripe {
}
}
- private static extractFingerprint(paymentMethod: Stripe.PaymentMethod): string | undefined {
+ private extractFingerprint(paymentMethod: Stripe.PaymentMethod): string | undefined {
if (paymentMethod.card?.fingerprint) {
return paymentMethod.card.fingerprint;
}
diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx
index 4dabc67733..5123085edb 100644
--- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx
+++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx
@@ -43,7 +43,7 @@ export const PaymentMethodsRow: React.FC
= ({
setOpen(false);
};
- const card = useCallback((paymentMethod: PaymentMethod) => {
+ const paymentMethodLabel = useMemo(() => {
if (paymentMethod.card) {
return (
<>
@@ -58,9 +58,9 @@ export const PaymentMethodsRow: React.FC = ({
}
return <>{capitalizeFirstLetter(paymentMethod.type)}>;
- }, []);
+ }, [paymentMethod]);
- const validUntil = useCallback((paymentMethod: PaymentMethod) => {
+ const validUntilContent = useMemo(() => {
if (!paymentMethod.card) {
return null;
}
@@ -71,9 +71,7 @@ export const PaymentMethodsRow: React.FC = ({
{month}/{paymentMethod.card.exp_year}
>
);
- }, []);
-
- const validUntilContent = validUntil(paymentMethod);
+ }, [paymentMethod]);
const defaultBadge = useMemo(() => {
if (!paymentMethod.isDefault) {
@@ -101,7 +99,7 @@ export const PaymentMethodsRow: React.FC = ({
return (
- {card(paymentMethod)} {defaultBadge}
+ {paymentMethodLabel} {defaultBadge}
{validUntilContent && Valid until {validUntilContent}}
{hasOtherPaymentMethods && (
diff --git a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx
index 7112a8bc76..ac71410fbc 100644
--- a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx
+++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx
@@ -1,5 +1,5 @@
"use client";
-import React from "react";
+import React, { useMemo } from "react";
import type { PaymentMethod } from "@akashnetwork/http-sdk";
import { Badge, Button, Card, CardDescription, CardHeader, CardTitle, RadioGroupItem } from "@akashnetwork/ui/components";
import { CheckCircle, CreditCard } from "iconoir-react";
@@ -62,7 +62,7 @@ export const PaymentMethodCard: React.FC = ({
onRemove(method.id);
};
- const display = getPaymentMethodDisplay(method);
+ const display = useMemo(() => getPaymentMethodDisplay(method), [method]);
if (isSelectable) {
// Selection mode - used in payment page
From 6272bdb9b5754bc6eafe77490ad4f8c35067b4c1 Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Wed, 18 Feb 2026 16:13:26 -0500
Subject: [PATCH 4/8] fix: tests
---
.../PaymentMethodCard.spec.tsx | 160 ++++++++++++++++++
.../PaymentMethodCard/PaymentMethodCard.tsx | 48 ++++--
2 files changed, 190 insertions(+), 18 deletions(-)
create mode 100644 apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
diff --git a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
new file mode 100644
index 0000000000..5b62f73187
--- /dev/null
+++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
@@ -0,0 +1,160 @@
+import "@testing-library/jest-dom";
+
+import React from "react";
+import type { PaymentMethod } from "@akashnetwork/http-sdk";
+
+import { DEPENDENCIES, PaymentMethodCard } from "./PaymentMethodCard";
+
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { createMockLinkPaymentMethod, createMockPaymentMethod } from "@tests/seeders/payment";
+import { MockComponents } from "@tests/unit/mocks";
+
+describe(PaymentMethodCard.name, () => {
+ describe("display mode (default)", () => {
+ it("renders card payment method with brand and last4", () => {
+ setup({
+ method: createMockPaymentMethod({
+ card: { brand: "visa", last4: "4242", funding: "credit", exp_month: 12, exp_year: 2025 }
+ })
+ });
+
+ expect(screen.getByText(/VISA •••• 4242/)).toBeInTheDocument();
+ expect(screen.getByText(/Expires 12\/2025/)).toBeInTheDocument();
+ });
+
+ it("renders link payment method with email", () => {
+ setup({
+ method: createMockLinkPaymentMethod({ link: { email: "user@example.com" } })
+ });
+
+ expect(screen.getByText(/Link \(user@example\.com\)/)).toBeInTheDocument();
+ });
+
+ it("renders link payment method without email", () => {
+ setup({
+ method: createMockLinkPaymentMethod({ link: undefined })
+ });
+
+ expect(screen.getByText("Link")).toBeInTheDocument();
+ });
+
+ it("renders unknown payment method type with capitalized name", () => {
+ const method: PaymentMethod = {
+ id: "pm_test",
+ type: "sepa_debit",
+ created: Date.now(),
+ validated: true
+ };
+
+ setup({ method });
+
+ expect(screen.getByText("Sepa_debit")).toBeInTheDocument();
+ });
+
+ it("passes onRemove handler to Button with correct method id", () => {
+ const onRemove = jest.fn();
+ const { dependencies } = setup({
+ method: createMockPaymentMethod({ id: "pm_abc" }),
+ onRemove
+ });
+
+ const buttonProps = (dependencies.Button as jest.Mock).mock.calls[0][0];
+ act(() => {
+ buttonProps.onClick({ stopPropagation: jest.fn() });
+ });
+
+ expect(onRemove).toHaveBeenCalledWith("pm_abc");
+ });
+
+ it("passes disabled state to Button when isRemoving is true", () => {
+ const { dependencies } = setup({ isRemoving: true });
+
+ const buttonProps = (dependencies.Button as jest.Mock).mock.calls[0][0];
+ expect(buttonProps.disabled).toBe(true);
+ });
+ });
+
+ describe("selection mode", () => {
+ it("renders RadioGroupItem with method id", () => {
+ const { dependencies } = setup({
+ isSelectable: true,
+ method: createMockPaymentMethod({ id: "pm_sel" })
+ });
+
+ const radioProps = (dependencies.RadioGroupItem as jest.Mock).mock.calls[0][0];
+ expect(radioProps.value).toBe("pm_sel");
+ expect(radioProps.id).toBe("pm_sel");
+ });
+
+ it("calls onSelect with method id when clicked", () => {
+ const onSelect = jest.fn();
+ setup({
+ isSelectable: true,
+ onSelect,
+ method: createMockPaymentMethod({ id: "pm_click" })
+ });
+
+ fireEvent.click(screen.getByText(/•••• /));
+
+ expect(onSelect).toHaveBeenCalledWith("pm_click");
+ });
+
+ it("renders Remove button when not trialing", () => {
+ const { dependencies } = setup({ isSelectable: true, isTrialing: false });
+
+ expect((dependencies.Button as jest.Mock).mock.calls.length).toBeGreaterThan(0);
+ });
+
+ it("does not render Remove button when trialing", () => {
+ const { dependencies } = setup({ isSelectable: true, isTrialing: true });
+
+ expect((dependencies.Button as jest.Mock).mock.calls.length).toBe(0);
+ });
+
+ it("does not show expiry for link methods", () => {
+ setup({
+ isSelectable: true,
+ method: createMockLinkPaymentMethod()
+ });
+
+ expect(screen.queryByText(/Expires/)).not.toBeInTheDocument();
+ });
+
+ it("shows expiry for card methods", () => {
+ setup({
+ isSelectable: true,
+ method: createMockPaymentMethod({
+ card: { brand: "mastercard", last4: "1234", funding: "debit", exp_month: 3, exp_year: 2027 }
+ })
+ });
+
+ expect(screen.getByText(/Expires 3\/2027/)).toBeInTheDocument();
+ });
+ });
+
+ function setup(
+ input: {
+ method?: PaymentMethod;
+ isRemoving?: boolean;
+ onRemove?: jest.Mock;
+ isSelectable?: boolean;
+ isSelected?: boolean;
+ onSelect?: jest.Mock;
+ showValidationBadge?: boolean;
+ isTrialing?: boolean;
+ } = {}
+ ) {
+ const dependencies = MockComponents(DEPENDENCIES);
+ const props = {
+ method: createMockPaymentMethod(),
+ isRemoving: false,
+ onRemove: jest.fn(),
+ dependencies,
+ ...input
+ };
+
+ const result = render();
+
+ return { ...result, dependencies };
+ }
+});
diff --git a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx
index ac71410fbc..fa860f30da 100644
--- a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx
+++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx
@@ -6,6 +6,18 @@ import { CheckCircle, CreditCard } from "iconoir-react";
import { capitalizeFirstLetter } from "@src/utils/stringUtils";
+export const DEPENDENCIES = {
+ Badge,
+ Button,
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ RadioGroupItem,
+ CheckCircle,
+ CreditCard
+};
+
function getPaymentMethodDisplay(method: PaymentMethod): { label: string; expiry: string | null } {
if (method.card) {
return {
@@ -32,13 +44,12 @@ interface PaymentMethodCardProps {
method: PaymentMethod;
isRemoving: boolean;
onRemove: (paymentMethodId: string) => void;
- // Selection mode props
isSelectable?: boolean;
isSelected?: boolean;
onSelect?: (paymentMethodId: string) => void;
- // Display mode props
showValidationBadge?: boolean;
isTrialing?: boolean;
+ dependencies?: typeof DEPENDENCIES;
}
export const PaymentMethodCard: React.FC = ({
@@ -49,7 +60,8 @@ export const PaymentMethodCard: React.FC = ({
isSelected = false,
onSelect,
showValidationBadge = true,
- isTrialing = false
+ isTrialing = false,
+ dependencies: d = DEPENDENCIES
}) => {
const handleCardClick = () => {
if (isSelectable && onSelect) {
@@ -74,7 +86,7 @@ export const PaymentMethodCard: React.FC = ({
onClick={handleCardClick}
>
-
+
{display.label}
@@ -83,9 +95,9 @@ export const PaymentMethodCard: React.FC
= ({
{!isTrialing && (
-
+
)}
);
@@ -93,30 +105,30 @@ export const PaymentMethodCard: React.FC = ({
// Display mode - used in onboarding
return (
-
-
+
+
-
+
- {display.label}
- {display.expiry && {display.expiry}}
+ {display.label}
+ {display.expiry && {display.expiry}}
{showValidationBadge && method.validated && (
-
-
-
+
+
+
)}
-
+
-
-
+
+
);
};
From 74e7dff66a8da5a7b5f12eae505b82443cafb7b6 Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Wed, 18 Feb 2026 16:53:37 -0500
Subject: [PATCH 5/8] fix(billing): add test coverage for link payment method
branches
Co-Authored-By: Claude Opus 4.6
---
.../http-schemas/stripe.schema.spec.ts | 26 +++++-
.../services/stripe/stripe.service.spec.ts | 84 +++++++++++++++++++
2 files changed, 109 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/billing/http-schemas/stripe.schema.spec.ts b/apps/api/src/billing/http-schemas/stripe.schema.spec.ts
index c5d0ea9b2e..276855fb17 100644
--- a/apps/api/src/billing/http-schemas/stripe.schema.spec.ts
+++ b/apps/api/src/billing/http-schemas/stripe.schema.spec.ts
@@ -1,8 +1,32 @@
import { secondsInDay } from "date-fns/constants";
-import { CustomerTransactionsCsvExportQuerySchema, CustomerTransactionsQuerySchema } from "./stripe.schema";
+import { CustomerTransactionsCsvExportQuerySchema, CustomerTransactionsQuerySchema, PaymentMethodSchema } from "./stripe.schema";
describe("Stripe Schema", () => {
+ describe("PaymentMethodSchema", () => {
+ it("accepts payment method with card", () => {
+ const result = PaymentMethodSchema.parse({
+ type: "card",
+ card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2025 }
+ });
+ expect(result.type).toBe("card");
+ expect(result.card?.brand).toBe("visa");
+ });
+
+ it("accepts payment method with link", () => {
+ const result = PaymentMethodSchema.parse({
+ type: "link",
+ link: { email: "user@test.com" }
+ });
+ expect(result.type).toBe("link");
+ expect(result.link?.email).toBe("user@test.com");
+ });
+
+ it("rejects payment method without card or link", () => {
+ expect(() => PaymentMethodSchema.parse({ type: "unknown" })).toThrow("At least one of card or link must be provided");
+ });
+ });
+
describe("CustomerTransactionsQuerySchema", () => {
it("accepts no dates", () => {
const out = CustomerTransactionsQuerySchema.parse({});
diff --git a/apps/api/src/billing/services/stripe/stripe.service.spec.ts b/apps/api/src/billing/services/stripe/stripe.service.spec.ts
index 37f2d44a2d..202b1cebc5 100644
--- a/apps/api/src/billing/services/stripe/stripe.service.spec.ts
+++ b/apps/api/src/billing/services/stripe/stripe.service.spec.ts
@@ -1,4 +1,5 @@
import { createMongoAbility } from "@casl/ability";
+import crypto from "crypto";
import type Stripe from "stripe";
import { mock } from "vitest-mock-extended";
@@ -125,6 +126,43 @@ describe(StripeService.name, () => {
});
});
+ it("sanitizes link email from payment method details", async () => {
+ const { service } = setup();
+ const mockCharge = createTestCharge({
+ id: "ch_link",
+ payment_method_details: { type: "link", link: { email: "user@test.com" } } as unknown as Stripe.Charge.PaymentMethodDetails
+ });
+ const mockCharges = {
+ data: [mockCharge],
+ has_more: false
+ } as unknown as Stripe.Response>;
+
+ jest.spyOn(service.charges, "list").mockResolvedValue(mockCharges);
+
+ const result = await service.getCustomerTransactions(TEST_CONSTANTS.CUSTOMER_ID);
+ expect(result.transactions[0].paymentMethod).toEqual({
+ type: "link",
+ link: { email: undefined }
+ });
+ });
+
+ it("returns null paymentMethod when payment_method_details is null", async () => {
+ const { service } = setup();
+ const mockCharge = createTestCharge({
+ id: "ch_null_pm",
+ payment_method_details: null as unknown as Stripe.Charge.PaymentMethodDetails
+ });
+ const mockCharges = {
+ data: [mockCharge],
+ has_more: false
+ } as unknown as Stripe.Response>;
+
+ jest.spyOn(service.charges, "list").mockResolvedValue(mockCharges);
+
+ const result = await service.getCustomerTransactions(TEST_CONSTANTS.CUSTOMER_ID);
+ expect(result.transactions[0].paymentMethod).toBeNull();
+ });
+
it("calls charges.list with endingBefore parameter", async () => {
const { service } = setup();
const mockCharge = createTestCharge({ id: "ch_456", amount: 2000 });
@@ -974,6 +1012,52 @@ describe(StripeService.name, () => {
expect(paymentMethodRepository.findOthersTrialingByFingerprint).toHaveBeenCalledWith(expectedFingerprints, currentUserId);
});
+ it("should extract fingerprint from link payment method email", async () => {
+ const { service, paymentMethodRepository } = setup();
+ const currentUserId = TEST_CONSTANTS.USER_ID;
+ const expectedFingerprint = `link_${crypto.createHash("sha256").update("user@test.com").digest("hex")}`;
+ const paymentMethods = [
+ generatePaymentMethod({
+ id: "pm_link",
+ type: "link",
+ card: null,
+ link: { email: "User@Test.com" }
+ } as unknown as Parameters[0])
+ ];
+
+ paymentMethodRepository.findOthersTrialingByFingerprint.mockResolvedValue(undefined);
+
+ const result = await service.hasDuplicateTrialAccount(paymentMethods, currentUserId);
+
+ expect(result).toBe(false);
+ expect(paymentMethodRepository.findOthersTrialingByFingerprint).toHaveBeenCalledWith([expectedFingerprint], currentUserId);
+ });
+
+ it("should handle mixed card and link payment methods", async () => {
+ const { service, paymentMethodRepository } = setup();
+ const currentUserId = TEST_CONSTANTS.USER_ID;
+ const expectedLinkFingerprint = `link_${crypto.createHash("sha256").update("user@test.com").digest("hex")}`;
+ const paymentMethods = [
+ generatePaymentMethod({
+ id: "pm_card",
+ card: { fingerprint: "fp_card_123" }
+ }),
+ generatePaymentMethod({
+ id: "pm_link",
+ type: "link",
+ card: null,
+ link: { email: "user@test.com" }
+ } as unknown as Parameters[0])
+ ];
+
+ paymentMethodRepository.findOthersTrialingByFingerprint.mockResolvedValue(undefined);
+
+ const result = await service.hasDuplicateTrialAccount(paymentMethods, currentUserId);
+
+ expect(result).toBe(false);
+ expect(paymentMethodRepository.findOthersTrialingByFingerprint).toHaveBeenCalledWith(["fp_card_123", expectedLinkFingerprint], currentUserId);
+ });
+
it("should resolve with false provided empty payment methods array", async () => {
const { service, paymentMethodRepository } = setup();
const currentUserId = TEST_CONSTANTS.USER_ID;
From 1389803b1bf8c52fac8aa86a35dddc34a5e0eadb Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Thu, 19 Feb 2026 15:32:22 -0500
Subject: [PATCH 6/8] fix(billing): rename nested describe blocks to follow
'when' convention
---
.../shared/PaymentMethodCard/PaymentMethodCard.spec.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
index 5b62f73187..492197d682 100644
--- a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
+++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
@@ -10,7 +10,7 @@ import { createMockLinkPaymentMethod, createMockPaymentMethod } from "@tests/see
import { MockComponents } from "@tests/unit/mocks";
describe(PaymentMethodCard.name, () => {
- describe("display mode (default)", () => {
+ describe("when in display mode (default)", () => {
it("renders card payment method with brand and last4", () => {
setup({
method: createMockPaymentMethod({
@@ -74,7 +74,7 @@ describe(PaymentMethodCard.name, () => {
});
});
- describe("selection mode", () => {
+ describe("when in selection mode", () => {
it("renders RadioGroupItem with method id", () => {
const { dependencies } = setup({
isSelectable: true,
From 1c8ee549e63e95874499981e65269f459147773f Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Thu, 19 Feb 2026 16:13:26 -0500
Subject: [PATCH 7/8] fix(billing): migrate PaymentMethodCard spec from jest to
vitest
After rebasing to main, the test file still used jest globals.
Replaced jest.fn/jest.Mock with vitest equivalents.
---
.../PaymentMethodCard.spec.tsx | 25 +++++++++----------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
index 492197d682..b8da61672c 100644
--- a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
+++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx
@@ -1,7 +1,6 @@
-import "@testing-library/jest-dom";
-
import React from "react";
import type { PaymentMethod } from "@akashnetwork/http-sdk";
+import { describe, expect, it, type Mock, vi } from "vitest";
import { DEPENDENCIES, PaymentMethodCard } from "./PaymentMethodCard";
@@ -52,15 +51,15 @@ describe(PaymentMethodCard.name, () => {
});
it("passes onRemove handler to Button with correct method id", () => {
- const onRemove = jest.fn();
+ const onRemove = vi.fn();
const { dependencies } = setup({
method: createMockPaymentMethod({ id: "pm_abc" }),
onRemove
});
- const buttonProps = (dependencies.Button as jest.Mock).mock.calls[0][0];
+ const buttonProps = (dependencies.Button as Mock).mock.calls[0][0];
act(() => {
- buttonProps.onClick({ stopPropagation: jest.fn() });
+ buttonProps.onClick({ stopPropagation: vi.fn() });
});
expect(onRemove).toHaveBeenCalledWith("pm_abc");
@@ -69,7 +68,7 @@ describe(PaymentMethodCard.name, () => {
it("passes disabled state to Button when isRemoving is true", () => {
const { dependencies } = setup({ isRemoving: true });
- const buttonProps = (dependencies.Button as jest.Mock).mock.calls[0][0];
+ const buttonProps = (dependencies.Button as Mock).mock.calls[0][0];
expect(buttonProps.disabled).toBe(true);
});
});
@@ -81,13 +80,13 @@ describe(PaymentMethodCard.name, () => {
method: createMockPaymentMethod({ id: "pm_sel" })
});
- const radioProps = (dependencies.RadioGroupItem as jest.Mock).mock.calls[0][0];
+ const radioProps = (dependencies.RadioGroupItem as Mock).mock.calls[0][0];
expect(radioProps.value).toBe("pm_sel");
expect(radioProps.id).toBe("pm_sel");
});
it("calls onSelect with method id when clicked", () => {
- const onSelect = jest.fn();
+ const onSelect = vi.fn();
setup({
isSelectable: true,
onSelect,
@@ -102,13 +101,13 @@ describe(PaymentMethodCard.name, () => {
it("renders Remove button when not trialing", () => {
const { dependencies } = setup({ isSelectable: true, isTrialing: false });
- expect((dependencies.Button as jest.Mock).mock.calls.length).toBeGreaterThan(0);
+ expect((dependencies.Button as Mock).mock.calls.length).toBeGreaterThan(0);
});
it("does not render Remove button when trialing", () => {
const { dependencies } = setup({ isSelectable: true, isTrialing: true });
- expect((dependencies.Button as jest.Mock).mock.calls.length).toBe(0);
+ expect((dependencies.Button as Mock).mock.calls.length).toBe(0);
});
it("does not show expiry for link methods", () => {
@@ -136,10 +135,10 @@ describe(PaymentMethodCard.name, () => {
input: {
method?: PaymentMethod;
isRemoving?: boolean;
- onRemove?: jest.Mock;
+ onRemove?: Mock;
isSelectable?: boolean;
isSelected?: boolean;
- onSelect?: jest.Mock;
+ onSelect?: Mock;
showValidationBadge?: boolean;
isTrialing?: boolean;
} = {}
@@ -148,7 +147,7 @@ describe(PaymentMethodCard.name, () => {
const props = {
method: createMockPaymentMethod(),
isRemoving: false,
- onRemove: jest.fn(),
+ onRemove: vi.fn(),
dependencies,
...input
};
From 0094b92db36522d4206ee9eaf3f402ef7031752a Mon Sep 17 00:00:00 2001
From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com>
Date: Thu, 19 Feb 2026 17:07:05 -0500
Subject: [PATCH 8/8] fix(billing): support link payment methods in webhooks
and fix auto-reload scheduling
Webhook handlers only extracted fingerprints from card payment methods,
causing Link types to never get local DB records. This made setting Link
as default fail with 403. Also fix auto-reload enable failing with 500
when a stale singleton job already existed in the queue.
---
.../stripe-webhook.service.integration.ts | 74 ++++++++++++++++---
.../stripe-webhook/stripe-webhook.service.ts | 14 ++--
.../billing/services/stripe/stripe.service.ts | 2 +-
.../wallet-settings.service.integration.ts | 6 +-
.../wallet-settings.service.ts | 2 +-
5 files changed, 78 insertions(+), 20 deletions(-)
diff --git a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
index b817ec1b18..1ae09f76e6 100644
--- a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
+++ b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
@@ -551,6 +551,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_abc123";
userRepository.findOneBy.mockResolvedValue(mockUser);
+ stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-123",
@@ -569,7 +570,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
- fingerprint
+ card: { fingerprint }
});
await service.handlePaymentMethodAttached(event);
@@ -582,6 +583,47 @@ describe(StripeWebhookService.name, () => {
expect(stripeService.markRemotePaymentMethodAsDefault).toHaveBeenCalledWith(paymentMethodId, mockUser);
});
+ it("creates payment method for link type", async () => {
+ const { service, userRepository, paymentMethodRepository, stripeService } = setup();
+ const mockUser = createTestUser({ stripeCustomerId: "cus_123" });
+ const paymentMethodId = "pm_link_123";
+ const linkFingerprint = "link_abc123hash";
+
+ userRepository.findOneBy.mockResolvedValue(mockUser);
+ stripeService.extractFingerprint.mockReturnValue(linkFingerprint);
+ paymentMethodRepository.upsert.mockResolvedValue({
+ paymentMethod: {
+ id: "local-pm-link-123",
+ userId: mockUser.id,
+ fingerprint: linkFingerprint,
+ paymentMethodId,
+ isDefault: true,
+ isValidated: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ isNew: true
+ });
+ stripeService.markRemotePaymentMethodAsDefault.mockResolvedValue(undefined);
+
+ const event = createPaymentMethodAttachedEvent({
+ id: paymentMethodId,
+ customer: mockUser.stripeCustomerId!,
+ type: "link",
+ link: { email: "user@test.com" }
+ });
+
+ await service.handlePaymentMethodAttached(event);
+
+ expect(stripeService.extractFingerprint).toHaveBeenCalled();
+ expect(paymentMethodRepository.upsert).toHaveBeenCalledWith({
+ userId: mockUser.id,
+ fingerprint: linkFingerprint,
+ paymentMethodId
+ });
+ expect(stripeService.markRemotePaymentMethodAsDefault).toHaveBeenCalledWith(paymentMethodId, mockUser);
+ });
+
it("handles duplicate webhook delivery gracefully (idempotency)", async () => {
const { service, userRepository, paymentMethodRepository, stripeService } = setup();
const mockUser = createTestUser({ stripeCustomerId: "cus_123" });
@@ -589,6 +631,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_abc123";
userRepository.findOneBy.mockResolvedValue(mockUser);
+ stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-123",
@@ -606,7 +649,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
- fingerprint
+ card: { fingerprint }
});
await service.handlePaymentMethodAttached(event);
@@ -623,6 +666,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_def456";
userRepository.findOneBy.mockResolvedValue(mockUser);
+ stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-456",
@@ -640,7 +684,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
- fingerprint
+ card: { fingerprint }
});
await service.handlePaymentMethodAttached(event);
@@ -655,6 +699,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_abc123";
userRepository.findOneBy.mockResolvedValue(mockUser);
+ stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-123",
@@ -673,7 +718,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
- fingerprint
+ card: { fingerprint }
});
// Should not throw
@@ -686,7 +731,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: "pm_123",
customer: null,
- fingerprint: "fp_abc123"
+ card: { fingerprint: "fp_abc123" }
});
await service.handlePaymentMethodAttached(event);
@@ -703,7 +748,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: "pm_123",
customer: "cus_unknown",
- fingerprint: "fp_abc123"
+ card: { fingerprint: "fp_abc123" }
});
await service.handlePaymentMethodAttached(event);
@@ -713,15 +758,16 @@ describe(StripeWebhookService.name, () => {
});
it("returns early when fingerprint is missing", async () => {
- const { service, userRepository, paymentMethodRepository } = setup();
+ const { service, userRepository, paymentMethodRepository, stripeService } = setup();
const mockUser = createTestUser({ stripeCustomerId: "cus_123" });
userRepository.findOneBy.mockResolvedValue(mockUser);
+ stripeService.extractFingerprint.mockReturnValue(undefined);
const event = createPaymentMethodAttachedEvent({
id: "pm_123",
customer: mockUser.stripeCustomerId!,
- fingerprint: null
+ card: { fingerprint: null }
});
await service.handlePaymentMethodAttached(event);
@@ -837,7 +883,13 @@ describe(StripeWebhookService.name, () => {
} as Stripe.InvoicePaymentSucceededEvent;
}
- function createPaymentMethodAttachedEvent(params: { id: string; customer: string | null; fingerprint: string | null }): Stripe.PaymentMethodAttachedEvent {
+ function createPaymentMethodAttachedEvent(params: {
+ id: string;
+ customer: string | null;
+ type?: string;
+ card?: { fingerprint: string | null };
+ link?: { email: string };
+ }): Stripe.PaymentMethodAttachedEvent {
return {
id: "evt_123",
type: "payment_method.attached",
@@ -845,7 +897,9 @@ describe(StripeWebhookService.name, () => {
object: {
id: params.id,
customer: params.customer,
- card: params.fingerprint ? { fingerprint: params.fingerprint } : undefined
+ type: params.type ?? "card",
+ card: params.card,
+ link: params.link
} as Stripe.PaymentMethod
}
} as Stripe.PaymentMethodAttachedEvent;
diff --git a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
index 6bf74d6cd8..22644288c9 100644
--- a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
+++ b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
@@ -382,17 +382,18 @@ export class StripeWebhookService {
return;
}
- const fingerprint = paymentMethod.card?.fingerprint;
+ assertIsPayingUser(user);
+
+ const fingerprint = this.stripe.extractFingerprint(paymentMethod);
if (!fingerprint) {
this.logger.error({
event: "PAYMENT_METHOD_MISSING_FINGERPRINT",
- paymentMethodId: paymentMethod.id
+ paymentMethodId: paymentMethod.id,
+ type: paymentMethod.type
});
return;
}
- assertIsPayingUser(user);
-
// Use upsert for idempotency - handles Stripe webhook retries gracefully
const { paymentMethod: localPaymentMethod, isNew } = await this.paymentMethodRepository.upsert({
userId: user.id,
@@ -428,7 +429,7 @@ export class StripeWebhookService {
async handlePaymentMethodDetached(event: Stripe.PaymentMethodDetachedEvent) {
const paymentMethod = event.data.object;
const customerId = paymentMethod.customer || event.data.previous_attributes?.customer;
- const fingerprint = paymentMethod.card?.fingerprint;
+ const fingerprint = this.stripe.extractFingerprint(paymentMethod);
this.logger.info({
event: "PAYMENT_METHOD_DETACHED",
@@ -440,7 +441,8 @@ export class StripeWebhookService {
if (!fingerprint) {
this.logger.warn({
event: "PAYMENT_METHOD_DETACHED_NO_FINGERPRINT",
- paymentMethodId: paymentMethod.id
+ paymentMethodId: paymentMethod.id,
+ type: paymentMethod.type
});
return;
}
diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts
index e6d143fa26..07a96c0a23 100644
--- a/apps/api/src/billing/services/stripe/stripe.service.ts
+++ b/apps/api/src/billing/services/stripe/stripe.service.ts
@@ -935,7 +935,7 @@ export class StripeService extends Stripe {
}
}
- private extractFingerprint(paymentMethod: Stripe.PaymentMethod): string | undefined {
+ extractFingerprint(paymentMethod: Stripe.PaymentMethod): string | undefined {
if (paymentMethod.card?.fingerprint) {
return paymentMethod.card.fingerprint;
}
diff --git a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.integration.ts b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.integration.ts
index 1ed9724b31..df16bfbff2 100644
--- a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.integration.ts
+++ b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.integration.ts
@@ -83,7 +83,8 @@ describe(WalletSettingService.name, () => {
expect.objectContaining({
id: newSetting.id,
userId: user.id
- })
+ }),
+ { withCleanup: true }
);
});
@@ -139,7 +140,8 @@ describe(WalletSettingService.name, () => {
expect.objectContaining({
id: updatedSetting.id,
userId: user.id
- })
+ }),
+ { withCleanup: true }
);
});
diff --git a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts
index 71c90fb3da..65001e8242 100644
--- a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts
+++ b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts
@@ -113,7 +113,7 @@ export class WalletSettingService {
async #arrangeSchedule(prev?: WalletSettingOutput, next?: WalletSettingOutput) {
if (!prev?.autoReloadEnabled && next?.autoReloadEnabled) {
- await this.walletReloadJobService.scheduleForWalletSetting(next);
+ await this.walletReloadJobService.scheduleForWalletSetting(next, { withCleanup: true });
}
}