Skip to content

Commit e3a19db

Browse files
Merge pull request #18316 from mozilla/FXA-10628
feat(next): Clean up artifacts from checkout process on checkout failure Because: * As the user processes through the checkout process for a subscription, we create business objects to represent their subscription and the stakeholders. If the subscription fails, the artifacts need to be cleaned up. This commit: * Adds additional logic to the cart service wrapWithCartCatch method to clean up defunct artifacts. Closes #FXA-10628
2 parents 818d7d7 + 16eff52 commit e3a19db

12 files changed

Lines changed: 427 additions & 19 deletions

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

Lines changed: 254 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
StripePaymentIntentFactory,
4545
StripeCustomerSessionFactory,
4646
StripeApiListFactory,
47+
StripeInvoiceFactory,
48+
StripeDeletedInvoiceFactory,
4749
} from '@fxa/payments/stripe';
4850
import {
4951
MockProfileClientConfigProvider,
@@ -199,6 +201,249 @@ describe('CartService', () => {
199201
paymentMethodManager = moduleRef.get(PaymentMethodManager);
200202
});
201203

204+
describe('wrapCartWithCatch', () => {
205+
it('calls cartManager.finishErrorCart', async () => {
206+
const mockCart = ResultCartFactory({
207+
state: CartState.PROCESSING,
208+
stripeSubscriptionId: null,
209+
stripeCustomerId: null,
210+
});
211+
jest
212+
.spyOn(cartManager, 'fetchCartById')
213+
.mockRejectedValueOnce(new Error('test'))
214+
.mockResolvedValue(mockCart);
215+
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
216+
217+
await expect(
218+
cartService.finalizeProcessingCart(mockCart.id)
219+
).rejects.toThrow(Error);
220+
221+
expect(cartManager.finishErrorCart).toHaveBeenCalled();
222+
});
223+
224+
it('cancels a created subscription', async () => {
225+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
226+
const mockSubscription = StripeResponseFactory(
227+
StripeSubscriptionFactory({
228+
customer: mockCustomer.id,
229+
latest_invoice: null,
230+
})
231+
);
232+
const mockCart = ResultCartFactory({
233+
state: CartState.PROCESSING,
234+
stripeSubscriptionId: mockSubscription.id,
235+
stripeCustomerId: mockCustomer.id,
236+
});
237+
238+
jest
239+
.spyOn(cartManager, 'fetchCartById')
240+
.mockRejectedValueOnce(new Error('test'))
241+
.mockResolvedValue(mockCart);
242+
243+
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
244+
jest
245+
.spyOn(subscriptionManager, 'retrieve')
246+
.mockResolvedValue(mockSubscription);
247+
jest
248+
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
249+
.mockResolvedValue(undefined);
250+
jest
251+
.spyOn(subscriptionManager, 'cancel')
252+
.mockResolvedValue(mockSubscription);
253+
254+
await expect(
255+
cartService.finalizeProcessingCart(mockCart.id)
256+
).rejects.toThrow(Error);
257+
258+
expect(subscriptionManager.cancel).toHaveBeenCalledWith(
259+
mockSubscription.id,
260+
{
261+
cancellation_details: {
262+
comment: 'Automatic Cancellation: Cart checkout failed.',
263+
},
264+
}
265+
);
266+
});
267+
268+
it('deletes a created draft invoice', async () => {
269+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
270+
const mockInvoice = StripeResponseFactory(
271+
StripeInvoiceFactory({ status: 'draft' })
272+
);
273+
const mockDeletedInvoice = StripeResponseFactory(
274+
StripeDeletedInvoiceFactory({ id: mockInvoice.id })
275+
);
276+
const mockSubscription = StripeResponseFactory(
277+
StripeSubscriptionFactory({
278+
customer: mockCustomer.id,
279+
latest_invoice: mockInvoice.id,
280+
})
281+
);
282+
const mockCart = ResultCartFactory({
283+
state: CartState.PROCESSING,
284+
stripeSubscriptionId: mockSubscription.id,
285+
stripeCustomerId: mockCustomer.id,
286+
});
287+
288+
jest
289+
.spyOn(cartManager, 'fetchCartById')
290+
.mockRejectedValueOnce(new Error('test'))
291+
.mockResolvedValue(mockCart);
292+
293+
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
294+
jest
295+
.spyOn(subscriptionManager, 'retrieve')
296+
.mockResolvedValue(mockSubscription);
297+
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
298+
jest
299+
.spyOn(invoiceManager, 'delete')
300+
.mockResolvedValue(mockDeletedInvoice);
301+
jest
302+
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
303+
.mockResolvedValue(undefined);
304+
jest
305+
.spyOn(subscriptionManager, 'cancel')
306+
.mockResolvedValue(mockSubscription);
307+
308+
await expect(
309+
cartService.finalizeProcessingCart(mockCart.id)
310+
).rejects.toThrow(Error);
311+
312+
expect(invoiceManager.delete).toHaveBeenCalledWith(mockInvoice.id);
313+
});
314+
315+
it('voids a created finalized invoice', async () => {
316+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
317+
const mockInvoice = StripeResponseFactory(
318+
StripeInvoiceFactory({ status: 'open' })
319+
);
320+
const mockSubscription = StripeResponseFactory(
321+
StripeSubscriptionFactory({
322+
customer: mockCustomer.id,
323+
latest_invoice: mockInvoice.id,
324+
})
325+
);
326+
const mockCart = ResultCartFactory({
327+
state: CartState.PROCESSING,
328+
stripeSubscriptionId: mockSubscription.id,
329+
stripeCustomerId: mockCustomer.id,
330+
});
331+
332+
jest
333+
.spyOn(cartManager, 'fetchCartById')
334+
.mockRejectedValueOnce(new Error('test'))
335+
.mockResolvedValue(mockCart);
336+
337+
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
338+
jest
339+
.spyOn(subscriptionManager, 'retrieve')
340+
.mockResolvedValue(mockSubscription);
341+
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
342+
jest.spyOn(invoiceManager, 'void').mockResolvedValue(mockInvoice);
343+
jest
344+
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
345+
.mockResolvedValue(undefined);
346+
jest
347+
.spyOn(subscriptionManager, 'cancel')
348+
.mockResolvedValue(mockSubscription);
349+
350+
await expect(
351+
cartService.finalizeProcessingCart(mockCart.id)
352+
).rejects.toThrow(Error);
353+
354+
expect(invoiceManager.void).toHaveBeenCalledWith(mockInvoice.id);
355+
});
356+
357+
it('cancels a created payment intent', async () => {
358+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
359+
const mockPaymentIntent = StripeResponseFactory(
360+
StripePaymentIntentFactory({ status: 'processing' })
361+
);
362+
const mockSubscription = StripeResponseFactory(
363+
StripeSubscriptionFactory({
364+
customer: mockCustomer.id,
365+
latest_invoice: null,
366+
})
367+
);
368+
const mockCart = ResultCartFactory({
369+
state: CartState.PROCESSING,
370+
stripeSubscriptionId: mockSubscription.id,
371+
stripeCustomerId: mockCustomer.id,
372+
});
373+
374+
jest
375+
.spyOn(cartManager, 'fetchCartById')
376+
.mockRejectedValueOnce(new Error('test'))
377+
.mockResolvedValue(mockCart);
378+
379+
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
380+
jest
381+
.spyOn(subscriptionManager, 'retrieve')
382+
.mockResolvedValue(mockSubscription);
383+
jest
384+
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
385+
.mockResolvedValue(mockPaymentIntent);
386+
jest
387+
.spyOn(paymentIntentManager, 'cancel')
388+
.mockResolvedValue(mockPaymentIntent);
389+
jest
390+
.spyOn(subscriptionManager, 'cancel')
391+
.mockResolvedValue(mockSubscription);
392+
393+
await expect(
394+
cartService.finalizeProcessingCart(mockCart.id)
395+
).rejects.toThrow(Error);
396+
397+
expect(paymentIntentManager.cancel).toHaveBeenCalledWith(
398+
mockPaymentIntent.id
399+
);
400+
});
401+
402+
it('does not delete a customer with preexisting subscriptions', async () => {
403+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
404+
const mockSubscription = StripeResponseFactory(
405+
StripeSubscriptionFactory({
406+
customer: mockCustomer.id,
407+
latest_invoice: null,
408+
})
409+
);
410+
const mockPreviousSubscription = StripeResponseFactory(
411+
StripeSubscriptionFactory()
412+
);
413+
const mockCart = ResultCartFactory({
414+
state: CartState.PROCESSING,
415+
stripeSubscriptionId: mockSubscription.id,
416+
stripeCustomerId: mockCustomer.id,
417+
});
418+
419+
jest
420+
.spyOn(cartManager, 'fetchCartById')
421+
.mockRejectedValueOnce(new Error('test'))
422+
.mockResolvedValue(mockCart);
423+
424+
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
425+
jest
426+
.spyOn(subscriptionManager, 'retrieve')
427+
.mockResolvedValue(mockSubscription);
428+
jest
429+
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
430+
.mockResolvedValue(undefined);
431+
jest
432+
.spyOn(subscriptionManager, 'cancel')
433+
.mockResolvedValue(mockSubscription);
434+
jest
435+
.spyOn(subscriptionManager, 'listForCustomer')
436+
.mockResolvedValue([mockPreviousSubscription]);
437+
jest.spyOn(customerManager, 'delete');
438+
439+
await expect(
440+
cartService.finalizeProcessingCart(mockCart.id)
441+
).rejects.toThrow(Error);
442+
443+
expect(customerManager.delete).not.toHaveBeenCalledWith(mockCustomer.id);
444+
});
445+
});
446+
202447
describe('setupCart', () => {
203448
const args = {
204449
interval: SubplatInterval.Monthly,
@@ -382,7 +627,12 @@ describe('CartService', () => {
382627
});
383628

384629
describe('restartCart', () => {
630+
const mockStripeCustomerId = faker.string.uuid();
631+
const mockAccountCustomer = ResultAccountCustomerFactory({
632+
stripeCustomerId: mockStripeCustomerId,
633+
});
385634
const mockOldCart = ResultCartFactory({
635+
uid: mockAccountCustomer.uid,
386636
couponCode: faker.word.noun(),
387637
});
388638
const mockNewCart = ResultCartFactory();
@@ -401,6 +651,9 @@ describe('CartService', () => {
401651
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
402652
.mockResolvedValue(undefined);
403653
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockNewCart);
654+
jest
655+
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
656+
.mockResolvedValue(mockAccountCustomer);
404657

405658
const result = await cartService.restartCart(mockOldCart.id);
406659

@@ -412,7 +665,7 @@ describe('CartService', () => {
412665
couponCode: mockOldCart.couponCode,
413666
taxAddress: mockOldCart.taxAddress,
414667
currency: mockOldCart.currency,
415-
stripeCustomerId: mockOldCart.stripeCustomerId,
668+
stripeCustomerId: mockAccountCustomer.stripeCustomerId,
416669
email: mockOldCart.email,
417670
amount: mockOldCart.amount,
418671
eligibilityStatus: mockOldCart.eligibilityStatus,

0 commit comments

Comments
 (0)