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/http-schemas/stripe.schema.ts b/apps/api/src/billing/http-schemas/stripe.schema.ts index 3c293a3ca5..68fd94c26b 100644 --- a/apps/api/src/billing/http-schemas/stripe.schema.ts +++ b/apps/api/src/billing/http-schemas/stripe.schema.ts @@ -13,47 +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(), - 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-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.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; diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index 72be9d7c19..07a96c0a23 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -1,4 +1,5 @@ import type { AnyAbility } from "@casl/ability"; +import crypto from "crypto"; import { stringify } from "csv-stringify"; import assert from "http-assert"; import difference from "lodash/difference"; @@ -154,9 +155,9 @@ export class StripeService extends Stripe { return { ...remote, validated: local.isValidated, isDefault: local.isDefault }; } - const fingerprint = remote.card?.fingerprint; + const fingerprint = this.extractFingerprint(remote); - assert(fingerprint, 403, "Payment method fingerprint is missing"); + assert(fingerprint, 403, "Payment method cannot be set as default. No identifiable fingerprint found."); const newLocal = await this.paymentMethodRepository.accessibleBy(ability, "create").createAsDefault({ userId: user.id, @@ -497,7 +498,12 @@ export class StripeService extends Stripe { currency: charge.currency, status: charge.status, created: charge.created, - paymentMethod: charge.payment_method_details, + paymentMethod: charge.payment_method_details + ? { + ...charge.payment_method_details, + link: charge.payment_method_details.link ? { email: undefined } : undefined + } + : null, receiptUrl: charge.receipt_url, description: charge.description, metadata: charge.metadata @@ -690,7 +696,7 @@ export class StripeService extends Stripe { currentUserId }); - const fingerprints = paymentMethods.map(paymentMethod => paymentMethod.card?.fingerprint).filter(Boolean) as string[]; + const fingerprints = paymentMethods.map(paymentMethod => this.extractFingerprint(paymentMethod)).filter(Boolean) as string[]; if (!fingerprints.length) { return false; @@ -928,4 +934,14 @@ export class StripeService extends Stripe { // Don't fail the test charge if validation update fails - the card is still valid } } + + extractFingerprint(paymentMethod: Stripe.PaymentMethod): string | undefined { + if (paymentMethod.card?.fingerprint) { + return paymentMethod.card.fingerprint; + } + + if (paymentMethod.type === "link" && paymentMethod.link?.email) { + return `link_${crypto.createHash("sha256").update(paymentMethod.link.email.toLowerCase()).digest("hex")}`; + } + } } 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 }); } } 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/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx index 93e1477572..e418b6655c 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx @@ -6,7 +6,7 @@ import { PaymentMethodsRow } from "./PaymentMethodsRow"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createMockPaymentMethod } from "@tests/seeders/payment"; +import { createMockLinkPaymentMethod, createMockPaymentMethod } from "@tests/seeders/payment"; // Mock implementations for dependencies const MockTableRow = ({ children, className }: any) => {children}; @@ -327,7 +327,8 @@ describe(PaymentMethodsRow.name, () => { setup({ paymentMethod }); // Component should still render without crashing - expect(screen.getByText(/Valid until/)).toBeInTheDocument(); + expect(screen.getByText("Card")).toBeInTheDocument(); + expect(screen.queryByText(/Valid until/)).not.toBeInTheDocument(); }); it("handles two-digit month without padding", () => { @@ -345,6 +346,24 @@ describe(PaymentMethodsRow.name, () => { expect(screen.getByText(/12\/2025/)).toBeInTheDocument(); }); + + it("renders Link payment method without card details", () => { + setup({ + paymentMethod: createMockLinkPaymentMethod({ link: undefined }) + }); + + expect(screen.getByText("Link")).toBeInTheDocument(); + expect(screen.queryByText(/Valid until/)).not.toBeInTheDocument(); + }); + + it("renders Link payment method with email", () => { + setup({ + paymentMethod: createMockLinkPaymentMethod({ link: { email: "email@example.com" } }) + }); + + expect(screen.getByText(/Link \(email@example\.com\)/)).toBeInTheDocument(); + expect(screen.queryByText(/Valid until/)).not.toBeInTheDocument(); + }); }); describe("Menu State Management", () => { 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 6d549c67b1..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,22 +43,35 @@ export const PaymentMethodsRow: React.FC = ({ setOpen(false); }; - const card = useCallback((paymentMethod: PaymentMethod) => { - return ( - <> - {capitalizeFirstLetter(paymentMethod.card?.brand || "")} {paymentMethod.card?.funding} **** {paymentMethod.card?.last4} - - ); - }, []); + const paymentMethodLabel = useMemo(() => { + if (paymentMethod.card) { + return ( + <> + {capitalizeFirstLetter(paymentMethod.card.brand || "")} {paymentMethod.card.funding} **** {paymentMethod.card.last4} + + ); + } + + if (paymentMethod.type === "link") { + const email = paymentMethod.link?.email; + return <>{email ? `Link (${email})` : "Link"}; + } - const validUntil = useCallback((paymentMethod: PaymentMethod) => { - const month = paymentMethod.card?.exp_month?.toString().padStart(2, "0"); + return <>{capitalizeFirstLetter(paymentMethod.type)}; + }, [paymentMethod]); + + const validUntilContent = useMemo(() => { + if (!paymentMethod.card) { + return null; + } + + const month = paymentMethod.card.exp_month?.toString().padStart(2, "0"); return ( <> - {month}/{paymentMethod.card?.exp_year} + {month}/{paymentMethod.card.exp_year} ); - }, []); + }, [paymentMethod]); const defaultBadge = useMemo(() => { if (!paymentMethod.isDefault) { @@ -86,9 +99,9 @@ export const PaymentMethodsRow: React.FC = ({ return ( - {card(paymentMethod)} {defaultBadge} + {paymentMethodLabel} {defaultBadge} - Valid until {validUntil(paymentMethod)} + {validUntilContent && Valid until {validUntilContent}} {hasOtherPaymentMethods && ( {!paymentMethod.isDefault && ( 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..b8da61672c --- /dev/null +++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.spec.tsx @@ -0,0 +1,159 @@ +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"; + +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("when in 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 = vi.fn(); + const { dependencies } = setup({ + method: createMockPaymentMethod({ id: "pm_abc" }), + onRemove + }); + + const buttonProps = (dependencies.Button as Mock).mock.calls[0][0]; + act(() => { + buttonProps.onClick({ stopPropagation: vi.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 Mock).mock.calls[0][0]; + expect(buttonProps.disabled).toBe(true); + }); + }); + + describe("when in selection mode", () => { + it("renders RadioGroupItem with method id", () => { + const { dependencies } = setup({ + isSelectable: true, + method: createMockPaymentMethod({ id: "pm_sel" }) + }); + + 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 = vi.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 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 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?: Mock; + isSelectable?: boolean; + isSelected?: boolean; + onSelect?: Mock; + showValidationBadge?: boolean; + isTrialing?: boolean; + } = {} + ) { + const dependencies = MockComponents(DEPENDENCIES); + const props = { + method: createMockPaymentMethod(), + isRemoving: false, + onRemove: vi.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 ef64decaf7..fa860f30da 100644 --- a/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx +++ b/apps/deploy-web/src/components/shared/PaymentMethodCard/PaymentMethodCard.tsx @@ -1,20 +1,55 @@ "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"; +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 { + label: `${method.card.brand?.toUpperCase() || ""} •••• ${method.card.last4 || ""}`, + expiry: `Expires ${method.card.exp_month}/${method.card.exp_year}` + }; + } + + if (method.type === "link") { + const email = method.link?.email; + return { + label: email ? `Link (${email})` : "Link", + expiry: null + }; + } + + return { + label: capitalizeFirstLetter(method.type), + expiry: null + }; +} + 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 = ({ @@ -25,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) { @@ -38,6 +74,8 @@ export const PaymentMethodCard: React.FC = ({ onRemove(method.id); }; + const display = useMemo(() => getPaymentMethodDisplay(method), [method]); + if (isSelectable) { // Selection mode - used in payment page return ( @@ -48,22 +86,18 @@ export const PaymentMethodCard: React.FC = ({ onClick={handleCardClick} >
- +
-
- {method.card?.brand?.toUpperCase()} •••• {method.card?.last4} -
-
- Expires {method.card?.exp_month}/{method.card?.exp_year} -
+
{display.label}
+ {display.expiry &&
{display.expiry}
}
{!isTrialing && ( - + )} ); @@ -71,34 +105,30 @@ export const PaymentMethodCard: React.FC = ({ // Display mode - used in onboarding return ( - - + +
- +
- - {method.card?.brand?.toUpperCase()} •••• {method.card?.last4} - - - Expires {method.card?.exp_month}/{method.card?.exp_year} - + {display.label} + {display.expiry && {display.expiry}}
{showValidationBadge && method.validated && ( - - - + + + )} - +
-
-
+ + ); }; 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..2efbd28796 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 | null; + } | null; } export interface Charge {