Skip to content
Open
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
7 changes: 0 additions & 7 deletions libs/payments/cart/src/lib/cart.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,13 +486,6 @@ export class SubmitNeedsInputCustomerIdMissingError extends CartError {
}
}

export class SubmitNeedsInputSubscriptionIdMissingError extends CartError {
constructor(cartId: string) {
super('Cart must have a stripeSubscriptionId', { cartId });
this.name = 'SubmitNeedsInputSubscriptionIdMissingError';
}
}

export class SubmitNeedsInputUidMissingError extends CartError {
constructor(cartId: string) {
super('Cart must have a uid', { cartId });
Expand Down
67 changes: 63 additions & 4 deletions libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,44 @@ describe('CartService', () => {
);
});

it('cancels a setup intent when no subscription was created', async () => {
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockCart = ResultCartFactory({
state: CartState.PROCESSING,
stripeSubscriptionId: null,
stripeCustomerId: mockCustomer.id,
stripeIntentId: 'seti_setup_intent_id',
});

jest
.spyOn(cartManager, 'fetchCartById')
.mockRejectedValueOnce(new Error('test'))
.mockResolvedValue(mockCart);
jest
.spyOn(asyncLocalStorage, 'getStore')
.mockReturnValue(CartStoreFactory());
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
jest
.spyOn(setupIntentManager, 'retrieve')
.mockResolvedValue(mockSetupIntent);
jest
.spyOn(setupIntentManager, 'cancel')
.mockResolvedValue(mockSetupIntent);
jest.spyOn(subscriptionManager, 'cancel');
jest.spyOn(subscriptionManager, 'retrieve');

await expect(
cartService.finalizeProcessingCart(mockCart.id)
).rejects.toThrow(Error);

expect(setupIntentManager.cancel).toHaveBeenCalledWith(
mockSetupIntent.id,
mockSetupIntent.status
);
expect(subscriptionManager.cancel).not.toHaveBeenCalled();
expect(subscriptionManager.retrieve).not.toHaveBeenCalled();
});

it('does not delete a customer with preexisting subscriptions', async () => {
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockSubscription = StripeResponseFactory(
Expand Down Expand Up @@ -2852,16 +2890,37 @@ describe('CartService', () => {
/SubmitNeedsInputCustomerIdMissingError/
);
});
it('throws assertion error for missing stripeSubscriptionId', async () => {
it('finalizes setup intent and creates subscription when no stripeSubscriptionId is set yet', async () => {
const localMockCart = {
...mockCart,
stripeSubscriptionId: null,
stripeIntentId: mockSetupIntent.id,
};
const mockAttribution = SubscriptionAttributionFactory();
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(localMockCart);
jest
.spyOn(checkoutService, 'finalizeSetupIntentAndCreateSubscription')
.mockResolvedValue();

await expect(cartService.submitNeedsInput(mockCart.id)).rejects.toThrow(
/SubmitNeedsInputSubscriptionIdMissingError/
);
await cartService.submitNeedsInput(localMockCart.id, mockAttribution);

expect(
checkoutService.finalizeSetupIntentAndCreateSubscription
).toHaveBeenCalledWith(localMockCart, mockAttribution, undefined);
expect(checkoutService.postPaySteps).not.toHaveBeenCalled();
});

it('throws SubmitNeedsInputFailedError when no subscription exists and attribution is missing', async () => {
const localMockCart = {
...mockCart,
stripeSubscriptionId: null,
stripeIntentId: mockSetupIntent.id,
};
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(localMockCart);

await expect(
cartService.submitNeedsInput(localMockCart.id)
).rejects.toThrow(/SubmitNeedsInputFailedError/);
});
it('throws assertion error for missing uid', async () => {
const localMockCart = {
Expand Down
86 changes: 50 additions & 36 deletions libs/payments/cart/src/lib/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ import {
GetCartUnitAmountForCurrencyMissingError,
GetCartPriceForCurrencyRecurringMissingError,
SubmitNeedsInputCustomerIdMissingError,
SubmitNeedsInputSubscriptionIdMissingError,
SubmitNeedsInputUidMissingError,
CartSubscriptionDeletionFailedError,
} from './cart.error';
Expand Down Expand Up @@ -252,34 +251,36 @@ export class CartService {
}
}
}
}

if (cart.stripeIntentId) {
const intent = isPaymentIntentId(cart.stripeIntentId)
? await this.paymentIntentManager.retrieve(cart.stripeIntentId)
: await this.setupIntentManager.retrieve(cart.stripeIntentId);
if (intent?.status === 'succeeded') {
throw new PaidPaymentIntendOnFailedCartError(
cartId,
intent.id,
cart.stripeCustomerId ?? undefined
);
}
if (cart.stripeIntentId) {
const intent = isPaymentIntentId(cart.stripeIntentId)
? await this.paymentIntentManager.retrieve(cart.stripeIntentId)
: await this.setupIntentManager.retrieve(cart.stripeIntentId);
if (intent?.status === 'succeeded') {
throw new PaidPaymentIntendOnFailedCartError(
cartId,
intent.id,
cart.stripeCustomerId ?? undefined
);
}

if (!isPaymentIntentId(cart.stripeIntentId)) {
try {
await this.setupIntentManager.cancel(intent.id, intent.status);
} catch (e) {
// swallow the error to allow cancellation of the subscription
this.log.error(e);
Sentry.captureException(e, {
extra: {
cartId,
},
});
}
if (!isPaymentIntentId(cart.stripeIntentId)) {
try {
await this.setupIntentManager.cancel(intent.id, intent.status);
} catch (e) {
// swallow the error to allow cancellation of the subscription
this.log.error(e);
Sentry.captureException(e, {
extra: {
cartId,
},
});
}
}
}

if (subscriptionId) {
if (cart.eligibilityStatus === CartEligibilityStatus.CREATE) {
try {
await this.subscriptionManager.cancel(subscriptionId, {
Expand Down Expand Up @@ -1185,17 +1186,17 @@ export class CartService {
}

@SanitizeExceptions()
async submitNeedsInput(cartId: string) {
async submitNeedsInput(
cartId: string,
attribution?: SubscriptionAttributionParams,
requestArgs?: CommonMetrics
) {
return this.wrapWithCartCatch(cartId, async () => {
const cart = await this.cartManager.fetchCartById(cartId);
assert(
cart.stripeCustomerId,
new SubmitNeedsInputCustomerIdMissingError(cartId)
);
assert(
cart.stripeSubscriptionId,
new SubmitNeedsInputSubscriptionIdMissingError(cartId)
);
assert(cart.uid, new SubmitNeedsInputUidMissingError(cartId));

if (!cart.stripeIntentId) {
Expand All @@ -1207,18 +1208,31 @@ export class CartService {
: await this.setupIntentManager.retrieve(cart.stripeIntentId);

if (intent.status === 'succeeded') {
if (intent.payment_method) {
await this.customerManager.update(cart.stripeCustomerId, {
invoice_settings: {
default_payment_method: intent.payment_method,
},
});
} else {
if (!intent.payment_method) {
throw new SuccessfulIntentMissingPaymentMethodCartError(
cartId,
intent.id
);
}

if (!cart.stripeSubscriptionId) {
assert(
attribution,
new SubmitNeedsInputFailedError(cartId)
);
await this.checkoutService.finalizeSetupIntentAndCreateSubscription(
cart,
attribution,
requestArgs
);
return;
}

await this.customerManager.update(cart.stripeCustomerId, {
invoice_settings: {
default_payment_method: intent.payment_method,
},
});
const subscription = await this.subscriptionManager.retrieve(
cart.stripeSubscriptionId
);
Expand Down
10 changes: 10 additions & 0 deletions libs/payments/cart/src/lib/checkout.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ export class InvalidIntentStateError extends CheckoutError {
}
}

export class SetupIntentNotReturnedError extends CheckoutError {
constructor(cartId: string) {
super(
'Stripe did not return a SetupIntent or attach one to the error',
{ cartId }
);
this.name = 'SetupIntentNotReturnedError';
}
}

export class AccountCustomerAlreadyExistsError extends CheckoutError {
constructor(uid: string) {
super('account customer already exists for uid', { uid });
Expand Down
Loading
Loading