Skip to content

Commit d15ef7e

Browse files
committed
fix(payments): Success page displays latest invoice details on a previous subscription
1 parent 3329a35 commit d15ef7e

6 files changed

Lines changed: 274 additions & 14 deletions

File tree

libs/payments/cart/src/lib/cart.service.spec.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,7 @@ describe('CartService', () => {
21202120
});
21212121

21222122
describe('getCart', () => {
2123+
const mockPaymentIntentInvoiceId = 'in_mock_from_intent';
21232124
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
21242125
const mockCustomerSession = StripeResponseFactory(
21252126
StripeCustomerSessionFactory()
@@ -2323,6 +2324,13 @@ describe('CartService', () => {
23232324
jest
23242325
.spyOn(paymentMethodManager, 'retrieve')
23252326
.mockResolvedValue(mockPaymentMethod);
2327+
jest
2328+
.spyOn(paymentIntentManager, 'retrieve')
2329+
.mockResolvedValue(
2330+
StripeResponseFactory(
2331+
StripePaymentIntentFactory({ invoice: mockPaymentIntentInvoiceId })
2332+
)
2333+
);
23262334

23272335
const result = await cartService.getCart(mockCart.id);
23282336
expect(result).toEqual({
@@ -2358,8 +2366,11 @@ describe('CartService', () => {
23582366
customer: mockCustomer,
23592367
taxAddress: mockCart.taxAddress,
23602368
});
2369+
expect(paymentIntentManager.retrieve).toHaveBeenCalledWith(
2370+
mockCart.stripeIntentId
2371+
);
23612372
expect(invoiceManager.preview).toHaveBeenCalledWith(
2362-
mockSubscription.latest_invoice
2373+
mockPaymentIntentInvoiceId
23632374
);
23642375
});
23652376

@@ -2860,12 +2871,125 @@ describe('CartService', () => {
28602871
});
28612872

28622873
describe('CartState.SUCCESS', () => {
2874+
const mockInvoiceId = 'in_mock_invoice_id';
28632875
const mockCart = ResultCartFactory({
28642876
state: CartState.SUCCESS,
28652877
});
28662878

28672879
beforeEach(() => {
28682880
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
2881+
jest
2882+
.spyOn(paymentIntentManager, 'retrieve')
2883+
.mockResolvedValue(
2884+
StripeResponseFactory(
2885+
StripePaymentIntentFactory({ invoice: mockInvoiceId })
2886+
)
2887+
);
2888+
});
2889+
2890+
it('uses invoice from PaymentIntent for Stripe carts', async () => {
2891+
await cartService.getCart(mockCart.id);
2892+
2893+
expect(paymentIntentManager.retrieve).toHaveBeenCalledWith(
2894+
mockCart.stripeIntentId
2895+
);
2896+
expect(invoiceManager.preview).toHaveBeenCalledWith(mockInvoiceId);
2897+
});
2898+
2899+
it('falls back to timestamp lookup when PaymentIntent invoice is null', async () => {
2900+
const fallbackInvoiceId = 'in_timestamp_fallback';
2901+
jest
2902+
.spyOn(paymentIntentManager, 'retrieve')
2903+
.mockResolvedValue(
2904+
StripeResponseFactory(
2905+
StripePaymentIntentFactory({ invoice: null })
2906+
)
2907+
);
2908+
jest
2909+
.spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp')
2910+
.mockResolvedValue(fallbackInvoiceId);
2911+
2912+
await cartService.getCart(mockCart.id);
2913+
2914+
expect(paymentIntentManager.retrieve).toHaveBeenCalledWith(
2915+
mockCart.stripeIntentId
2916+
);
2917+
expect(
2918+
invoiceManager.retrieveBySubscriptionBeforeTimestamp
2919+
).toHaveBeenCalledWith(
2920+
mockCart.stripeSubscriptionId,
2921+
mockCart.updatedAt
2922+
);
2923+
expect(invoiceManager.preview).toHaveBeenCalledWith(fallbackInvoiceId);
2924+
});
2925+
2926+
it('falls back to subscription invoice list for PayPal carts', async () => {
2927+
const paypalCart = ResultCartFactory({
2928+
state: CartState.SUCCESS,
2929+
stripeIntentId: null,
2930+
});
2931+
const fallbackInvoiceId = 'in_paypal_invoice';
2932+
jest
2933+
.spyOn(cartManager, 'fetchCartById')
2934+
.mockResolvedValue(paypalCart);
2935+
jest
2936+
.spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp')
2937+
.mockResolvedValue(fallbackInvoiceId);
2938+
2939+
await cartService.getCart(paypalCart.id);
2940+
2941+
expect(paymentIntentManager.retrieve).not.toHaveBeenCalled();
2942+
expect(
2943+
invoiceManager.retrieveBySubscriptionBeforeTimestamp
2944+
).toHaveBeenCalledWith(
2945+
paypalCart.stripeSubscriptionId,
2946+
paypalCart.updatedAt
2947+
);
2948+
expect(invoiceManager.preview).toHaveBeenCalledWith(fallbackInvoiceId);
2949+
});
2950+
2951+
it('falls back to timestamp lookup for SetupIntent carts', async () => {
2952+
const setupIntentCart = ResultCartFactory({
2953+
state: CartState.SUCCESS,
2954+
stripeIntentId: `seti_${faker.string.alphanumeric(14)}`,
2955+
});
2956+
const fallbackInvoiceId = 'in_setup_intent_invoice';
2957+
jest
2958+
.spyOn(cartManager, 'fetchCartById')
2959+
.mockResolvedValue(setupIntentCart);
2960+
jest
2961+
.spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp')
2962+
.mockResolvedValue(fallbackInvoiceId);
2963+
2964+
await cartService.getCart(setupIntentCart.id);
2965+
2966+
expect(paymentIntentManager.retrieve).not.toHaveBeenCalled();
2967+
expect(
2968+
invoiceManager.retrieveBySubscriptionBeforeTimestamp
2969+
).toHaveBeenCalledWith(
2970+
setupIntentCart.stripeSubscriptionId,
2971+
setupIntentCart.updatedAt
2972+
);
2973+
expect(invoiceManager.preview).toHaveBeenCalledWith(fallbackInvoiceId);
2974+
});
2975+
2976+
it('does not fall back to subscription.latest_invoice for SUCCESS carts', async () => {
2977+
const cartWithSetupIntent = ResultCartFactory({
2978+
state: CartState.SUCCESS,
2979+
stripeIntentId: `seti_${faker.string.alphanumeric(14)}`,
2980+
});
2981+
jest
2982+
.spyOn(cartManager, 'fetchCartById')
2983+
.mockResolvedValue(cartWithSetupIntent);
2984+
jest
2985+
.spyOn(invoiceManager, 'retrieveBySubscriptionBeforeTimestamp')
2986+
.mockResolvedValue(undefined);
2987+
2988+
await expect(
2989+
cartService.getCart(cartWithSetupIntent.id)
2990+
).rejects.toThrow(/GetCartLatestInvoicePreviewMissingError/);
2991+
2992+
expect(subscriptionManager.retrieve).not.toHaveBeenCalled();
28692993
});
28702994

28712995
it('throws assertion error on missing latestInvoicePreview', async () => {
@@ -3041,6 +3165,13 @@ describe('CartService', () => {
30413165
.mockResolvedValue(mockUpcoming);
30423166
jest.spyOn(invoiceManager, 'preview').mockResolvedValue(mockLatest);
30433167
jest.spyOn(paymentMethodManager, 'retrieve').mockResolvedValue(mockPM);
3168+
jest
3169+
.spyOn(paymentIntentManager, 'retrieve')
3170+
.mockResolvedValue(
3171+
StripeResponseFactory(
3172+
StripePaymentIntentFactory({ invoice: mockPaymentIntentInvoiceId })
3173+
)
3174+
);
30443175

30453176
const result = await cartService.getCart(mockCart.id);
30463177
expect(result.freeTrialOffer).toBeNull();
@@ -3092,6 +3223,13 @@ describe('CartService', () => {
30923223
jest
30933224
.spyOn(subscriptionManager, 'listForCustomer')
30943225
.mockResolvedValue([mockTrialSubscription]);
3226+
jest
3227+
.spyOn(paymentIntentManager, 'retrieve')
3228+
.mockResolvedValue(
3229+
StripeResponseFactory(
3230+
StripePaymentIntentFactory({ invoice: mockPaymentIntentInvoiceId })
3231+
)
3232+
);
30953233

30963234
const result = await cartService.getCart(mockCart.id);
30973235
expect(result.trialStartDate).toBe(mockTrialSubscription.trial_start);

libs/payments/cart/src/lib/cart.service.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,20 +1154,52 @@ export class CartService {
11541154
subscriptions &&
11551155
cart.state !== CartState.FAIL
11561156
) {
1157-
const subscription =
1158-
subscriptions.find(
1159-
(subscription) => subscription.id === cart.stripeSubscriptionId
1160-
) ||
1161-
(await this.subscriptionManager.retrieve(cart.stripeSubscriptionId));
1157+
let invoiceId: string | undefined;
1158+
1159+
// For SUCCESS carts, pin to the original purchase invoice so that
1160+
// refreshing the success page after an upgrade in another tab does
1161+
// not show the upgrade's line items.
1162+
if (cart.state === CartState.SUCCESS) {
1163+
// Stripe carts: the PaymentIntent's invoice field is immutable
1164+
if (cart.stripeIntentId && isPaymentIntentId(cart.stripeIntentId)) {
1165+
const intent = await this.paymentIntentManager.retrieve(
1166+
cart.stripeIntentId
1167+
);
1168+
invoiceId = intent.invoice ?? undefined;
1169+
}
11621170

1163-
// fetch latest payment info from subscription
1164-
assert(
1165-
subscription?.latest_invoice,
1166-
new GetCartSubscriptionIdCartError(cartId)
1167-
);
1168-
latestInvoicePreview = await this.invoiceManager.preview(
1169-
subscription.latest_invoice
1170-
);
1171+
// PayPal carts (no stripeIntentId): find the most recent invoice
1172+
// created on or before the cart's success transition
1173+
if (!invoiceId) {
1174+
invoiceId =
1175+
await this.invoiceManager.retrieveBySubscriptionBeforeTimestamp(
1176+
cart.stripeSubscriptionId,
1177+
cart.updatedAt
1178+
);
1179+
}
1180+
}
1181+
1182+
// Non-SUCCESS carts: use subscription's latest invoice (safe because
1183+
// the subscription hasn't been modified by a subsequent upgrade yet).
1184+
// SUCCESS carts must not fall back here — subscription.latest_invoice
1185+
// is mutable and would show a later upgrade's invoice.
1186+
if (!invoiceId && cart.state !== CartState.SUCCESS) {
1187+
const subscription =
1188+
subscriptions.find(
1189+
(subscription) => subscription.id === cart.stripeSubscriptionId
1190+
) ||
1191+
(await this.subscriptionManager.retrieve(cart.stripeSubscriptionId));
1192+
1193+
assert(
1194+
subscription?.latest_invoice,
1195+
new GetCartSubscriptionIdCartError(cartId)
1196+
);
1197+
invoiceId = subscription.latest_invoice;
1198+
}
1199+
1200+
if (invoiceId) {
1201+
latestInvoicePreview = await this.invoiceManager.preview(invoiceId);
1202+
}
11711203
}
11721204

11731205
if (cart.state === CartState.SUCCESS) {

libs/payments/customer/src/lib/invoice.manager.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,49 @@ describe('InvoiceManager', () => {
362362
});
363363
});
364364

365+
describe('retrieveBySubscriptionBeforeTimestamp', () => {
366+
it('returns the invoice ID when an invoice exists', async () => {
367+
const mockInvoice = StripeInvoiceFactory();
368+
const mockResponse = StripeResponseFactory(
369+
StripeApiListFactory([mockInvoice])
370+
);
371+
372+
jest
373+
.spyOn(stripeClient, 'invoicesList')
374+
.mockResolvedValue(mockResponse);
375+
376+
const timestampMs = 1700000000000;
377+
const result =
378+
await invoiceManager.retrieveBySubscriptionBeforeTimestamp(
379+
'sub_123',
380+
timestampMs
381+
);
382+
383+
expect(result).toBe(mockInvoice.id);
384+
expect(stripeClient.invoicesList).toHaveBeenCalledWith({
385+
subscription: 'sub_123',
386+
created: { lte: Math.floor(timestampMs / 1000) },
387+
limit: 1,
388+
});
389+
});
390+
391+
it('returns undefined when no invoices exist', async () => {
392+
const mockResponse = StripeResponseFactory(StripeApiListFactory([]));
393+
394+
jest
395+
.spyOn(stripeClient, 'invoicesList')
396+
.mockResolvedValue(mockResponse);
397+
398+
const result =
399+
await invoiceManager.retrieveBySubscriptionBeforeTimestamp(
400+
'sub_123',
401+
1700000000000
402+
);
403+
404+
expect(result).toBeUndefined();
405+
});
406+
});
407+
365408
describe('retrieve', () => {
366409
it('retrieves an invoice', async () => {
367410
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());

libs/payments/customer/src/lib/invoice.manager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,23 @@ export class InvoiceManager {
205205
return stripeInvoiceToInvoicePreviewDTO(upcomingInvoice);
206206
}
207207

208+
/**
209+
* Retrieve the most recent invoice for a subscription created on or before
210+
* a given timestamp. Used to pin the success page to the original purchase
211+
* invoice even after the subscription is later upgraded.
212+
*/
213+
async retrieveBySubscriptionBeforeTimestamp(
214+
subscriptionId: string,
215+
timestampMs: number
216+
): Promise<string | undefined> {
217+
const result = await this.stripeClient.invoicesList({
218+
subscription: subscriptionId,
219+
created: { lte: Math.floor(timestampMs / 1000) },
220+
limit: 1,
221+
});
222+
return result.data[0]?.id;
223+
}
224+
208225
/**
209226
* Fetch the invoice preview for the latest invoice associated with a cart
210227
*/

libs/payments/stripe/src/lib/stripe.client.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const mockStripeRetrieveUpcomingInvoice =
3838
mockJestFnGenerator<typeof Stripe.prototype.invoices.retrieveUpcoming>();
3939
const mockStripeInvoicesFinalizeInvoice =
4040
mockJestFnGenerator<typeof Stripe.prototype.invoices.finalizeInvoice>();
41+
const mockStripeInvoicesList =
42+
mockJestFnGenerator<typeof Stripe.prototype.invoices.list>();
4143
const mockStripeInvoicesRetrieve =
4244
mockJestFnGenerator<typeof Stripe.prototype.invoices.retrieve>();
4345
const mockStripePaymentMethodsAttach =
@@ -80,6 +82,7 @@ jest.mock('stripe', () => ({
8082
},
8183
invoices: {
8284
finalizeInvoice: mockStripeInvoicesFinalizeInvoice,
85+
list: mockStripeInvoicesList,
8386
retrieve: mockStripeInvoicesRetrieve,
8487
retrieveUpcoming: mockStripeRetrieveUpcomingInvoice,
8588
},
@@ -267,6 +270,24 @@ describe('StripeClient', () => {
267270
});
268271
});
269272

273+
describe('invoicesList', () => {
274+
it('works successfully', async () => {
275+
const mockInvoice = StripeInvoiceFactory();
276+
const mockResponse = StripeResponseFactory(
277+
StripeApiListFactory([mockInvoice])
278+
);
279+
280+
mockStripeInvoicesList.mockResolvedValue(mockResponse);
281+
282+
const result = await stripeClient.invoicesList({
283+
subscription: 'sub_123',
284+
limit: 1,
285+
});
286+
287+
expect(result).toEqual(mockResponse);
288+
});
289+
});
290+
270291
describe('invoicesRetrieve', () => {
271292
it('works successfully', async () => {
272293
const mockInvoice = StripeInvoiceFactory();

libs/payments/stripe/src/lib/stripe.client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,15 @@ export class StripeClient {
230230
return result as StripeResponse<StripeSubscription>;
231231
}
232232

233+
@CaptureTimingWithStatsD()
234+
async invoicesList(params: Stripe.InvoiceListParams) {
235+
const result = await this.stripe.invoices.list({
236+
...params,
237+
expand: undefined,
238+
});
239+
return result as StripeResponse<StripeApiList<StripeInvoice>>;
240+
}
241+
233242
@CaptureTimingWithStatsD()
234243
async invoicesRetrieve(
235244
id: string,

0 commit comments

Comments
 (0)