From e30df1b7aa9304be0076518a51474f5257447e18 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 16 Jun 2026 16:21:38 -0400 Subject: [PATCH] fix(payments): Success page displays latest invoice details on a previous subscription --- .../cart/src/lib/cart.service.spec.ts | 140 +++++++++++++++++- libs/payments/cart/src/lib/cart.service.ts | 58 ++++++-- .../customer/src/lib/invoice.manager.spec.ts | 43 ++++++ .../customer/src/lib/invoice.manager.ts | 17 +++ .../stripe/src/lib/stripe.client.spec.ts | 21 +++ libs/payments/stripe/src/lib/stripe.client.ts | 9 ++ 6 files changed, 274 insertions(+), 14 deletions(-) diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index 824a6f13ec7..8c0ea2dba59 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -2270,6 +2270,7 @@ describe('CartService', () => { }); describe('getCart', () => { + const mockPaymentIntentInvoiceId = 'in_mock_from_intent'; const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockCustomerSession = StripeResponseFactory( StripeCustomerSessionFactory() @@ -2473,6 +2474,13 @@ describe('CartService', () => { jest .spyOn(paymentMethodManager, 'retrieve') .mockResolvedValue(mockPaymentMethod); + jest + .spyOn(paymentIntentManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory( + StripePaymentIntentFactory({ invoice: mockPaymentIntentInvoiceId }) + ) + ); const result = await cartService.getCart(mockCart.id); expect(result).toEqual({ @@ -2508,8 +2516,11 @@ describe('CartService', () => { customer: mockCustomer, taxAddress: mockCart.taxAddress, }); + expect(paymentIntentManager.retrieve).toHaveBeenCalledWith( + mockCart.stripeIntentId + ); expect(invoiceManager.preview).toHaveBeenCalledWith( - mockSubscription.latest_invoice + mockPaymentIntentInvoiceId ); }); @@ -3010,12 +3021,125 @@ describe('CartService', () => { }); describe('CartState.SUCCESS', () => { + const mockInvoiceId = 'in_mock_invoice_id'; const mockCart = ResultCartFactory({ state: CartState.SUCCESS, }); beforeEach(() => { jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart); + jest + .spyOn(paymentIntentManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory( + StripePaymentIntentFactory({ invoice: mockInvoiceId }) + ) + ); + }); + + it('uses invoice from PaymentIntent for Stripe carts', async () => { + await cartService.getCart(mockCart.id); + + expect(paymentIntentManager.retrieve).toHaveBeenCalledWith( + mockCart.stripeIntentId + ); + expect(invoiceManager.preview).toHaveBeenCalledWith(mockInvoiceId); + }); + + it('falls back to timestamp lookup when PaymentIntent invoice is null', async () => { + const fallbackInvoiceId = 'in_timestamp_fallback'; + jest + .spyOn(paymentIntentManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory( + StripePaymentIntentFactory({ invoice: null }) + ) + ); + jest + .spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp') + .mockResolvedValue(fallbackInvoiceId); + + await cartService.getCart(mockCart.id); + + expect(paymentIntentManager.retrieve).toHaveBeenCalledWith( + mockCart.stripeIntentId + ); + expect( + invoiceManager.retrieveBySubscriptionBeforeTimestamp + ).toHaveBeenCalledWith( + mockCart.stripeSubscriptionId, + mockCart.updatedAt + ); + expect(invoiceManager.preview).toHaveBeenCalledWith(fallbackInvoiceId); + }); + + it('falls back to subscription invoice list for PayPal carts', async () => { + const paypalCart = ResultCartFactory({ + state: CartState.SUCCESS, + stripeIntentId: null, + }); + const fallbackInvoiceId = 'in_paypal_invoice'; + jest + .spyOn(cartManager, 'fetchCartById') + .mockResolvedValue(paypalCart); + jest + .spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp') + .mockResolvedValue(fallbackInvoiceId); + + await cartService.getCart(paypalCart.id); + + expect(paymentIntentManager.retrieve).not.toHaveBeenCalled(); + expect( + invoiceManager.retrieveBySubscriptionBeforeTimestamp + ).toHaveBeenCalledWith( + paypalCart.stripeSubscriptionId, + paypalCart.updatedAt + ); + expect(invoiceManager.preview).toHaveBeenCalledWith(fallbackInvoiceId); + }); + + it('falls back to timestamp lookup for SetupIntent carts', async () => { + const setupIntentCart = ResultCartFactory({ + state: CartState.SUCCESS, + stripeIntentId: `seti_${faker.string.alphanumeric(14)}`, + }); + const fallbackInvoiceId = 'in_setup_intent_invoice'; + jest + .spyOn(cartManager, 'fetchCartById') + .mockResolvedValue(setupIntentCart); + jest + .spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp') + .mockResolvedValue(fallbackInvoiceId); + + await cartService.getCart(setupIntentCart.id); + + expect(paymentIntentManager.retrieve).not.toHaveBeenCalled(); + expect( + invoiceManager.retrieveBySubscriptionBeforeTimestamp + ).toHaveBeenCalledWith( + setupIntentCart.stripeSubscriptionId, + setupIntentCart.updatedAt + ); + expect(invoiceManager.preview).toHaveBeenCalledWith(fallbackInvoiceId); + }); + + it('does not fall back to subscription.latest_invoice for SUCCESS carts', async () => { + const cartWithSetupIntent = ResultCartFactory({ + state: CartState.SUCCESS, + stripeIntentId: `seti_${faker.string.alphanumeric(14)}`, + }); + jest + .spyOn(cartManager, 'fetchCartById') + .mockResolvedValue(cartWithSetupIntent); + jest + .spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp') + .mockResolvedValue(undefined); + + await expect( + cartService.getCart(cartWithSetupIntent.id) + ).rejects.toThrow(/GetCartLatestInvoicePreviewMissingError/); + + expect(subscriptionManager.retrieve).not.toHaveBeenCalled(); }); it('throws assertion error on missing latestInvoicePreview', async () => { @@ -3191,6 +3315,13 @@ describe('CartService', () => { .mockResolvedValue(mockUpcoming); jest.spyOn(invoiceManager, 'preview').mockResolvedValue(mockLatest); jest.spyOn(paymentMethodManager, 'retrieve').mockResolvedValue(mockPM); + jest + .spyOn(paymentIntentManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory( + StripePaymentIntentFactory({ invoice: mockPaymentIntentInvoiceId }) + ) + ); const result = await cartService.getCart(mockCart.id); expect(result.freeTrialOffer).toBeNull(); @@ -3242,6 +3373,13 @@ describe('CartService', () => { jest .spyOn(subscriptionManager, 'listForCustomer') .mockResolvedValue([mockTrialSubscription]); + jest + .spyOn(paymentIntentManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory( + StripePaymentIntentFactory({ invoice: mockPaymentIntentInvoiceId }) + ) + ); const result = await cartService.getCart(mockCart.id); expect(result.trialStartDate).toBe(mockTrialSubscription.trial_start); diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index 7ff1d901bb0..9f3a1b4a7ae 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -1154,20 +1154,52 @@ export class CartService { subscriptions && cart.state !== CartState.FAIL ) { - const subscription = - subscriptions.find( - (subscription) => subscription.id === cart.stripeSubscriptionId - ) || - (await this.subscriptionManager.retrieve(cart.stripeSubscriptionId)); + let invoiceId: string | undefined; + + // For SUCCESS carts, pin to the original purchase invoice so that + // refreshing the success page after an upgrade in another tab does + // not show the upgrade's line items. + if (cart.state === CartState.SUCCESS) { + // Stripe carts: the PaymentIntent's invoice field is immutable + if (cart.stripeIntentId && isPaymentIntentId(cart.stripeIntentId)) { + const intent = await this.paymentIntentManager.retrieve( + cart.stripeIntentId + ); + invoiceId = intent.invoice ?? undefined; + } - // fetch latest payment info from subscription - assert( - subscription?.latest_invoice, - new GetCartSubscriptionIdCartError(cartId) - ); - latestInvoicePreview = await this.invoiceManager.preview( - subscription.latest_invoice - ); + // PayPal carts (no stripeIntentId): find the most recent invoice + // created on or before the cart's success transition + if (!invoiceId) { + invoiceId = + await this.invoiceManager.retrieveBySubscriptionBeforeTimestamp( + cart.stripeSubscriptionId, + cart.updatedAt + ); + } + } + + // Non-SUCCESS carts: use subscription's latest invoice (safe because + // the subscription hasn't been modified by a subsequent upgrade yet). + // SUCCESS carts must not fall back here — subscription.latest_invoice + // is mutable and would show a later upgrade's invoice. + if (!invoiceId && cart.state !== CartState.SUCCESS) { + const subscription = + subscriptions.find( + (subscription) => subscription.id === cart.stripeSubscriptionId + ) || + (await this.subscriptionManager.retrieve(cart.stripeSubscriptionId)); + + assert( + subscription?.latest_invoice, + new GetCartSubscriptionIdCartError(cartId) + ); + invoiceId = subscription.latest_invoice; + } + + if (invoiceId) { + latestInvoicePreview = await this.invoiceManager.preview(invoiceId); + } } if (cart.state === CartState.SUCCESS) { diff --git a/libs/payments/customer/src/lib/invoice.manager.spec.ts b/libs/payments/customer/src/lib/invoice.manager.spec.ts index 1e6a8f86ac1..c09b21fd9e1 100644 --- a/libs/payments/customer/src/lib/invoice.manager.spec.ts +++ b/libs/payments/customer/src/lib/invoice.manager.spec.ts @@ -362,6 +362,49 @@ describe('InvoiceManager', () => { }); }); + describe('retrieveBySubscriptionBeforeTimestamp', () => { + it('returns the invoice ID when an invoice exists', async () => { + const mockInvoice = StripeInvoiceFactory(); + const mockResponse = StripeResponseFactory( + StripeApiListFactory([mockInvoice]) + ); + + jest + .spyOn(stripeClient, 'invoicesList') + .mockResolvedValue(mockResponse); + + const timestampMs = 1700000000000; + const result = + await invoiceManager.retrieveBySubscriptionBeforeTimestamp( + 'sub_123', + timestampMs + ); + + expect(result).toBe(mockInvoice.id); + expect(stripeClient.invoicesList).toHaveBeenCalledWith({ + subscription: 'sub_123', + created: { lte: Math.floor(timestampMs / 1000) }, + limit: 1, + }); + }); + + it('returns undefined when no invoices exist', async () => { + const mockResponse = StripeResponseFactory(StripeApiListFactory([])); + + jest + .spyOn(stripeClient, 'invoicesList') + .mockResolvedValue(mockResponse); + + const result = + await invoiceManager.retrieveBySubscriptionBeforeTimestamp( + 'sub_123', + 1700000000000 + ); + + expect(result).toBeUndefined(); + }); + }); + describe('retrieve', () => { it('retrieves an invoice', async () => { const mockInvoice = StripeResponseFactory(StripeInvoiceFactory()); diff --git a/libs/payments/customer/src/lib/invoice.manager.ts b/libs/payments/customer/src/lib/invoice.manager.ts index 7fb94a8e494..d85a4e26e1c 100644 --- a/libs/payments/customer/src/lib/invoice.manager.ts +++ b/libs/payments/customer/src/lib/invoice.manager.ts @@ -205,6 +205,23 @@ export class InvoiceManager { return stripeInvoiceToInvoicePreviewDTO(upcomingInvoice); } + /** + * Retrieve the most recent invoice for a subscription created on or before + * a given timestamp. Used to pin the success page to the original purchase + * invoice even after the subscription is later upgraded. + */ + async retrieveBySubscriptionBeforeTimestamp( + subscriptionId: string, + timestampMs: number + ): Promise { + const result = await this.stripeClient.invoicesList({ + subscription: subscriptionId, + created: { lte: Math.floor(timestampMs / 1000) }, + limit: 1, + }); + return result.data[0]?.id; + } + /** * Fetch the invoice preview for the latest invoice associated with a cart */ diff --git a/libs/payments/stripe/src/lib/stripe.client.spec.ts b/libs/payments/stripe/src/lib/stripe.client.spec.ts index b7515d45b47..28d2862729a 100644 --- a/libs/payments/stripe/src/lib/stripe.client.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.client.spec.ts @@ -38,6 +38,8 @@ const mockStripeRetrieveUpcomingInvoice = mockJestFnGenerator(); const mockStripeInvoicesFinalizeInvoice = mockJestFnGenerator(); +const mockStripeInvoicesList = + mockJestFnGenerator(); const mockStripeInvoicesRetrieve = mockJestFnGenerator(); const mockStripePaymentMethodsAttach = @@ -80,6 +82,7 @@ jest.mock('stripe', () => ({ }, invoices: { finalizeInvoice: mockStripeInvoicesFinalizeInvoice, + list: mockStripeInvoicesList, retrieve: mockStripeInvoicesRetrieve, retrieveUpcoming: mockStripeRetrieveUpcomingInvoice, }, @@ -267,6 +270,24 @@ describe('StripeClient', () => { }); }); + describe('invoicesList', () => { + it('works successfully', async () => { + const mockInvoice = StripeInvoiceFactory(); + const mockResponse = StripeResponseFactory( + StripeApiListFactory([mockInvoice]) + ); + + mockStripeInvoicesList.mockResolvedValue(mockResponse); + + const result = await stripeClient.invoicesList({ + subscription: 'sub_123', + limit: 1, + }); + + expect(result).toEqual(mockResponse); + }); + }); + describe('invoicesRetrieve', () => { it('works successfully', async () => { const mockInvoice = StripeInvoiceFactory(); diff --git a/libs/payments/stripe/src/lib/stripe.client.ts b/libs/payments/stripe/src/lib/stripe.client.ts index 3c31898a01e..b5d44147a38 100644 --- a/libs/payments/stripe/src/lib/stripe.client.ts +++ b/libs/payments/stripe/src/lib/stripe.client.ts @@ -230,6 +230,15 @@ export class StripeClient { return result as StripeResponse; } + @CaptureTimingWithStatsD() + async invoicesList(params: Stripe.InvoiceListParams) { + const result = await this.stripe.invoices.list({ + ...params, + expand: undefined, + }); + return result as StripeResponse>; + } + @CaptureTimingWithStatsD() async invoicesRetrieve( id: string,