Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2270,6 +2270,7 @@ describe('CartService', () => {
});

describe('getCart', () => {
const mockPaymentIntentInvoiceId = 'in_mock_from_intent';
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockCustomerSession = StripeResponseFactory(
StripeCustomerSessionFactory()
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
);
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 45 additions & 13 deletions libs/payments/cart/src/lib/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions libs/payments/customer/src/lib/invoice.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
17 changes: 17 additions & 0 deletions libs/payments/customer/src/lib/invoice.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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
*/
Expand Down
21 changes: 21 additions & 0 deletions libs/payments/stripe/src/lib/stripe.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const mockStripeRetrieveUpcomingInvoice =
mockJestFnGenerator<typeof Stripe.prototype.invoices.retrieveUpcoming>();
const mockStripeInvoicesFinalizeInvoice =
mockJestFnGenerator<typeof Stripe.prototype.invoices.finalizeInvoice>();
const mockStripeInvoicesList =
mockJestFnGenerator<typeof Stripe.prototype.invoices.list>();
const mockStripeInvoicesRetrieve =
mockJestFnGenerator<typeof Stripe.prototype.invoices.retrieve>();
const mockStripePaymentMethodsAttach =
Expand Down Expand Up @@ -80,6 +82,7 @@ jest.mock('stripe', () => ({
},
invoices: {
finalizeInvoice: mockStripeInvoicesFinalizeInvoice,
list: mockStripeInvoicesList,
retrieve: mockStripeInvoicesRetrieve,
retrieveUpcoming: mockStripeRetrieveUpcomingInvoice,
},
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions libs/payments/stripe/src/lib/stripe.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,15 @@ export class StripeClient {
return result as StripeResponse<StripeSubscription>;
}

@CaptureTimingWithStatsD()
async invoicesList(params: Stripe.InvoiceListParams) {
const result = await this.stripe.invoices.list({
...params,
expand: undefined,
});
return result as StripeResponse<StripeApiList<StripeInvoice>>;
}

@CaptureTimingWithStatsD()
async invoicesRetrieve(
id: string,
Expand Down
Loading