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
73 changes: 73 additions & 0 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,79 @@ describe('CheckoutService', () => {
});
});

describe('confirms pending setup intent on subscription', () => {
const mockPendingSetupIntentId = 'seti_pending_12345';
const mockSubscriptionWithPendingSetup = StripeResponseFactory(
StripeSubscriptionFactory({
pending_setup_intent: mockPendingSetupIntentId,
})
);
const mockInvoice = StripeResponseFactory(
StripeInvoiceFactory({
payment_intent: null,
amount_due: 0,
})
);
const mockSetupIntent = StripeResponseFactory(
StripeSetupIntentFactory({
status: 'succeeded',
payment_method: StripePaymentMethodFactory().id,
})
);

beforeEach(async () => {
jest
.spyOn(subscriptionManager, 'create')
.mockResolvedValue(mockSubscriptionWithPendingSetup);
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
jest
.spyOn(setupIntentManager, 'confirm')
.mockResolvedValue(mockSetupIntent);
jest
.spyOn(setupIntentManager, 'createAndConfirm')
.mockResolvedValue(mockSetupIntent);
});

beforeEach(async () => {
await checkoutService.payWithStripe(
mockCart,
mockConfirmationToken.id,
mockAttributionData,
mockRequestArgs,
mockCart.uid
);
});

it('calls setupIntentManager.confirm with the pending setup intent', () => {
expect(setupIntentManager.confirm).toHaveBeenCalledWith(
mockPendingSetupIntentId,
mockConfirmationToken.id
);
});

it('does not call createAndConfirm', () => {
expect(setupIntentManager.createAndConfirm).not.toHaveBeenCalled();
});

it('increments setup intent status counter', () => {
expect(statsd.increment).toHaveBeenCalledWith(
'checkout_stripe_payment_setupintent_status',
{ status: mockSetupIntent.status }
);
});

it('calls updateProcessingCart with the confirmed setup intent id', () => {
expect(cartManager.updateProcessingCart).toHaveBeenCalledWith(
mockCart.id,
mockPrePayStepsResult.version,
{
stripeSubscriptionId: mockSubscriptionWithPendingSetup.id,
stripeIntentId: mockSetupIntent.id,
}
);
});
});

describe('payment intent error', () => {
beforeEach(async () => {
jest
Expand Down
58 changes: 31 additions & 27 deletions libs/payments/cart/src/lib/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class CheckoutService {
await this.accountCustomerManager.getAccountCustomerByUid(uid);

throw new AccountCustomerAlreadyExistsError(uid);
} catch(error) {
} catch (error) {
if (!(error instanceof AccountCustomerNotFoundError)) {
throw error;
}
Expand Down Expand Up @@ -428,8 +428,7 @@ export class CheckoutService {
trial_period_days: freeTrial.trialLengthDays,
trial_settings: {
end_behavior: {
missing_payment_method:
'cancel' as const,
missing_payment_method: 'cancel' as const,
},
},
}
Expand Down Expand Up @@ -488,10 +487,7 @@ export class CheckoutService {
subscription.status
);
}
await this.freeTrialManager.recordFreeTrial(
uid,
freeTrial.internalName
);
await this.freeTrialManager.recordFreeTrial(uid, freeTrial.internalName);
}

// Get payment/setup intent for subscription
Expand All @@ -516,6 +512,15 @@ export class CheckoutService {
off_session: false,
}
);
} else if (subscription.pending_setup_intent) {
intent = await this.setupIntentManager.confirm(
subscription.pending_setup_intent,
confirmationTokenId
);

this.statsd.increment('checkout_stripe_payment_setupintent_status', {
status: intent.status,
});
} else {
Comment thread
StaberindeZA marked this conversation as resolved.
intent = await this.setupIntentManager.createAndConfirm(
customer.id,
Expand Down Expand Up @@ -743,10 +748,7 @@ export class CheckoutService {
subscription.status
);
}
await this.freeTrialManager.recordFreeTrial(
uid,
freeTrial.internalName
);
await this.freeTrialManager.recordFreeTrial(uid, freeTrial.internalName);
}

const updatedVersion = version + 1;
Expand Down Expand Up @@ -973,15 +975,14 @@ export class CheckoutService {
taxAddress,
});
} else {
upcomingInvoice =
await this.invoiceManager.previewUpcomingForUpgrade({
priceId,
customer,
fromSubscriptionItem,
...(isTrialing && {
trialEnd: Math.floor(Date.now() / 1000),
}),
});
upcomingInvoice = await this.invoiceManager.previewUpcomingForUpgrade({
priceId,
customer,
fromSubscriptionItem,
...(isTrialing && {
trialEnd: Math.floor(Date.now() / 1000),
}),
});
}
return upcomingInvoice.subtotal;
} else {
Expand Down Expand Up @@ -1014,7 +1015,10 @@ export class CheckoutService {

const fetchResult = await Promise.all([
this.nimbusManager.fetchExperiments({
nimbusUserId: this.nimbusManager.generateNimbusId(uid, experimentationId),
nimbusUserId: this.nimbusManager.generateNimbusId(
uid,
experimentationId
),
preview: searchParams?.experimentationPreview === 'true',
}),
this.productConfigurationManager.getFreeTrial(offeringConfigId),
Expand All @@ -1035,9 +1039,7 @@ export class CheckoutService {
}

const [nimbusResult, freeTrialUtil] = fetchResult;
if (
!nimbusResult?.Features?.['free-trial-feature']?.enabled
) {
if (!nimbusResult?.Features?.['free-trial-feature']?.enabled) {
return null;
}

Expand All @@ -1049,7 +1051,9 @@ export class CheckoutService {
const matchingTrial = freeTrials.find(
(trial) =>
trial.trialLengthDays > 0 &&
trial.countries.some((country) => country.slice(0, 2) === countryCode) &&
trial.countries.some(
(country) => country.slice(0, 2) === countryCode
) &&
trial.intervals.includes(interval)
);
return matchingTrial ?? null;
Expand All @@ -1074,7 +1078,7 @@ export class CheckoutService {
interval: args.interval,
uid: args.uid,
experimentationId: args.experimentationId,
searchParams: args.searchParams
searchParams: args.searchParams,
});
if (!offer) {
return { offer: null, userEligible: false };
Expand All @@ -1088,7 +1092,7 @@ export class CheckoutService {

return {
offer,
userEligible: !isBlockedByCooldown
userEligible: !isBlockedByCooldown,
};
}
}
25 changes: 25 additions & 0 deletions libs/payments/customer/src/lib/setupIntent.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ describe('SetupIntentManager', () => {
});
});

describe('confirm', () => {
it('should confirm an existing setup intent', async () => {
const mockSetupIntentId = 'seti_12345';
const mockConfirmationToken = 'confirmToken';
const mockResponse = StripeResponseFactory(StripeSetupIntentFactory());

jest
.spyOn(stripeClient, 'setupIntentConfirm')
.mockResolvedValue(mockResponse);

const result = await setupIntentManager.confirm(
mockSetupIntentId,
mockConfirmationToken
);

expect(stripeClient.setupIntentConfirm).toHaveBeenCalledWith(
mockSetupIntentId,
{
confirmation_token: mockConfirmationToken,
}
);
expect(result).toEqual(mockResponse);
});
});

describe('retrieve', () => {
it('should retrieve a payment intent', async () => {
const mockResponse = StripeResponseFactory(StripeSetupIntentFactory());
Expand Down
6 changes: 6 additions & 0 deletions libs/payments/customer/src/lib/setupIntent.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export class SetupIntentManager {
});
}

async confirm(setupIntentId: string, confirmationTokenId: string) {
return this.stripeClient.setupIntentConfirm(setupIntentId, {
confirmation_token: confirmationTokenId,
});
}
Comment thread
StaberindeZA marked this conversation as resolved.

async retrieve(setupIntentId: string) {
return this.stripeClient.setupIntentRetrieve(setupIntentId);
}
Expand Down
74 changes: 74 additions & 0 deletions libs/payments/stripe/src/lib/stripe.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { StripePaymentMethodFactory } from './factories/payment-method.factory';
import { StripePriceFactory } from './factories/price.factory';
import { StripeProductFactory } from './factories/product.factory';
import { StripePromotionCodeFactory } from './factories/promotion-code.factory';
import { StripeSetupIntentFactory } from './factories/setup-intent.factory';
import { StripeSubscriptionFactory } from './factories/subscription.factory';
import { StripeUpcomingInvoiceFactory } from './factories/upcoming-invoice.factory';
import { StripeClient } from './stripe.client';
Expand Down Expand Up @@ -59,6 +60,14 @@ const mockStripeSubscriptionsRetrieve =
mockJestFnGenerator<typeof Stripe.prototype.subscriptions.retrieve>();
const mockStripeSubscriptionsUpdate =
mockJestFnGenerator<typeof Stripe.prototype.subscriptions.update>();
const mockStripeSetupIntentsCancel =
mockJestFnGenerator<typeof Stripe.prototype.setupIntents.cancel>();
const mockStripeSetupIntentsConfirm =
mockJestFnGenerator<typeof Stripe.prototype.setupIntents.confirm>();
const mockStripeSetupIntentsCreate =
mockJestFnGenerator<typeof Stripe.prototype.setupIntents.create>();
const mockStripeSetupIntentsRetrieve =
mockJestFnGenerator<typeof Stripe.prototype.setupIntents.retrieve>();

jest.mock('stripe', () => ({
Stripe: function () {
Expand Down Expand Up @@ -94,6 +103,12 @@ jest.mock('stripe', () => ({
retrieve: mockStripeSubscriptionsRetrieve,
update: mockStripeSubscriptionsUpdate,
},
setupIntents: {
cancel: mockStripeSetupIntentsCancel,
confirm: mockStripeSetupIntentsConfirm,
create: mockStripeSetupIntentsCreate,
retrieve: mockStripeSetupIntentsRetrieve,
},
};
},
}));
Expand Down Expand Up @@ -372,4 +387,63 @@ describe('StripeClient', () => {
expect(result).toEqual(mockResponse);
});
});

describe('setupIntentCancel', () => {
it('cancels a setup intent within Stripe', async () => {
const mockSetupIntent = StripeSetupIntentFactory();
const mockResponse = StripeResponseFactory(mockSetupIntent);

mockStripeSetupIntentsCancel.mockResolvedValue(mockResponse);

const result = await stripeClient.setupIntentCancel(mockSetupIntent.id);

expect(result).toEqual(mockResponse);
});
});

describe('setupIntentCreate', () => {
it('creates a setup intent within Stripe', async () => {
const mockCustomer = StripeCustomerFactory();
const mockSetupIntent = StripeSetupIntentFactory();
const mockResponse = StripeResponseFactory(mockSetupIntent);

mockStripeSetupIntentsCreate.mockResolvedValue(mockResponse);

const result = await stripeClient.setupIntentCreate({
customer: mockCustomer.id,
confirm: true,
});

expect(result).toEqual(mockResponse);
});
});

describe('setupIntentRetrieve', () => {
it('retrieves a setup intent within Stripe', async () => {
const mockSetupIntent = StripeSetupIntentFactory();
const mockResponse = StripeResponseFactory(mockSetupIntent);

mockStripeSetupIntentsRetrieve.mockResolvedValue(mockResponse);

const result = await stripeClient.setupIntentRetrieve(mockSetupIntent.id);

expect(result).toEqual(mockResponse);
});
});

describe('setupIntentConfirm', () => {
it('confirms a setup intent within Stripe', async () => {
const mockSetupIntent = StripeSetupIntentFactory();
const mockResponse = StripeResponseFactory(mockSetupIntent);
const mockConfirmationToken = 'ctoken_12345';

mockStripeSetupIntentsConfirm.mockResolvedValue(mockResponse);

const result = await stripeClient.setupIntentConfirm(mockSetupIntent.id, {
confirmation_token: mockConfirmationToken,
});

expect(result).toEqual(mockResponse);
});
});
});
Loading
Loading