Skip to content

Commit 184f8f6

Browse files
authored
Merge pull request #19839 from mozilla/pay-3366-stay-subscribed-email
feat(auth): add churn stay subscribed email
2 parents 087ec80 + 708eb16 commit 184f8f6

29 files changed

Lines changed: 1662 additions & 46 deletions

File tree

libs/payments/customer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ export * from './lib/factories/tax-address.factory';
2020
export * from './lib/customer.error';
2121
export * from './lib/util/stripeInvoiceToFirstInvoicePreviewDTO';
2222
export * from './lib/util/getSubplatInterval';
23+
export * from './lib/util/getSubplatIntervalFromSubscription';
2324
export * from './lib/util/retrieveSubscriptionItem';

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

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import {
1212
StripeCustomerFactory,
1313
StripeSubscriptionFactory,
1414
MockStripeConfigProvider,
15+
StripeRangeQueryParamFactory,
1516
} from '@fxa/payments/stripe';
1617
import { STRIPE_SUBSCRIPTION_METADATA } from './types';
1718
import { SubscriptionManager } from './subscription.manager';
1819
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
19-
import { SubscriptionCustomerMismatchError} from './customer.error'
20+
import { SubscriptionCustomerMismatchError } from './customer.error';
21+
import { StripeSubscriptionAsyncGeneratorFactory } from 'libs/payments/stripe/src/lib/factories/subscription.factory';
2022

2123
describe('SubscriptionManager', () => {
2224
let subscriptionManager: SubscriptionManager;
@@ -93,6 +95,46 @@ describe('SubscriptionManager', () => {
9395
});
9496
});
9597

98+
describe('listCancelOnDateGenerator', () => {
99+
const mockCurrentPeriodEnd = StripeRangeQueryParamFactory();
100+
it('returns generator that yields subscriptions', async () => {
101+
const mockSubscription = StripeSubscriptionFactory({
102+
cancel_at_period_end: true,
103+
});
104+
const mockGenerator = StripeSubscriptionAsyncGeneratorFactory([
105+
mockSubscription,
106+
]);
107+
const expected = mockSubscription;
108+
109+
jest
110+
.spyOn(stripeClient, 'subscriptionsListGenerator')
111+
.mockReturnValue(mockGenerator);
112+
113+
const generator =
114+
subscriptionManager.listCancelOnDateGenerator(mockCurrentPeriodEnd);
115+
const result = (await generator.next()).value;
116+
117+
expect(result).toEqual(expected);
118+
});
119+
120+
it('returns generator that yields no subscriptions', async () => {
121+
const mockSubscription = StripeSubscriptionFactory();
122+
const mockGenerator = StripeSubscriptionAsyncGeneratorFactory([
123+
mockSubscription,
124+
]);
125+
126+
jest
127+
.spyOn(stripeClient, 'subscriptionsListGenerator')
128+
.mockReturnValue(mockGenerator);
129+
130+
const generator =
131+
subscriptionManager.listCancelOnDateGenerator(mockCurrentPeriodEnd);
132+
const result = (await generator.next()).value;
133+
134+
expect(result).toEqual(undefined);
135+
});
136+
});
137+
96138
describe('cancel', () => {
97139
it('calls stripeclient', async () => {
98140
const mockSubscription = StripeSubscriptionFactory();
@@ -341,7 +383,10 @@ describe('SubscriptionManager', () => {
341383
.mockResolvedValueOnce(mockResponse);
342384

343385
await expect(
344-
subscriptionManager.getSubscriptionStatus(mockCustomer1.id, mockSubscription.id)
386+
subscriptionManager.getSubscriptionStatus(
387+
mockCustomer1.id,
388+
mockSubscription.id
389+
)
345390
).rejects.toThrow(SubscriptionCustomerMismatchError);
346391

347392
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
@@ -361,7 +406,9 @@ describe('SubscriptionManager', () => {
361406
const mockUpdatedSubscription = StripeSubscriptionFactory({
362407
customer: mockCustomer.id,
363408
});
364-
const mockUpdatedResponse = StripeResponseFactory(mockUpdatedSubscription);
409+
const mockUpdatedResponse = StripeResponseFactory(
410+
mockUpdatedSubscription
411+
);
365412

366413
jest
367414
.spyOn(subscriptionManager, 'retrieve')
@@ -373,13 +420,18 @@ describe('SubscriptionManager', () => {
373420
const result = await subscriptionManager.applyStripeCouponToSubscription({
374421
customerId: mockCustomer.id,
375422
subscriptionId: mockSubscription.id,
376-
stripeCouponId: mockCouponId
423+
stripeCouponId: mockCouponId,
377424
});
378425

379-
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(mockSubscription.id);
380-
expect(subscriptionManager.update).toHaveBeenCalledWith(mockSubscription.id, {
381-
discounts: [{ coupon: mockCouponId }],
382-
});
426+
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
427+
mockSubscription.id
428+
);
429+
expect(subscriptionManager.update).toHaveBeenCalledWith(
430+
mockSubscription.id,
431+
{
432+
discounts: [{ coupon: mockCouponId }],
433+
}
434+
);
383435
expect(result).toEqual(mockUpdatedResponse);
384436
});
385437

@@ -393,7 +445,9 @@ describe('SubscriptionManager', () => {
393445
const mockUpdatedSubscription = StripeSubscriptionFactory({
394446
customer: mockCustomer.id,
395447
});
396-
const mockUpdatedResponse = StripeResponseFactory(mockUpdatedSubscription);
448+
const mockUpdatedResponse = StripeResponseFactory(
449+
mockUpdatedSubscription
450+
);
397451

398452
jest
399453
.spyOn(subscriptionManager, 'retrieve')
@@ -406,13 +460,16 @@ describe('SubscriptionManager', () => {
406460
customerId: mockCustomer.id,
407461
subscriptionId: mockSubscription.id,
408462
stripeCouponId: mockCouponId,
409-
setCancelAtPeriodEnd: true
463+
setCancelAtPeriodEnd: true,
410464
});
411465

412-
expect(subscriptionManager.update).toHaveBeenCalledWith(mockSubscription.id, {
413-
discounts: [{ coupon: mockCouponId }],
414-
cancel_at_period_end: true,
415-
});
466+
expect(subscriptionManager.update).toHaveBeenCalledWith(
467+
mockSubscription.id,
468+
{
469+
discounts: [{ coupon: mockCouponId }],
470+
cancel_at_period_end: true,
471+
}
472+
);
416473
expect(result).toEqual(mockUpdatedResponse);
417474
});
418475

@@ -433,7 +490,7 @@ describe('SubscriptionManager', () => {
433490
subscriptionManager.applyStripeCouponToSubscription({
434491
customerId: mockCustomer1.id,
435492
subscriptionId: mockSubscription.id,
436-
stripeCouponId: mockCouponId
493+
stripeCouponId: mockCouponId,
437494
})
438495
).rejects.toThrow(SubscriptionCustomerMismatchError);
439496
expect(jest.spyOn(subscriptionManager, 'update')).not.toHaveBeenCalled();

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class SubscriptionManager {
2323

2424
async create(
2525
params: Omit<Stripe.SubscriptionCreateParams, 'metadata'> & {
26-
metadata?: StripeSubscriptionMetadataInput
26+
metadata?: StripeSubscriptionMetadataInput;
2727
},
2828
options?: Stripe.RequestOptions
2929
) {
@@ -37,8 +37,8 @@ export class SubscriptionManager {
3737
async update(
3838
subscriptionId: string,
3939
params: Omit<Stripe.SubscriptionUpdateParams, 'metadata'> & {
40-
metadata?: StripeSubscriptionMetadataInput
41-
},
40+
metadata?: StripeSubscriptionMetadataInput;
41+
}
4242
) {
4343
return this.stripeClient.subscriptionsUpdate(subscriptionId, params);
4444
}
@@ -51,6 +51,16 @@ export class SubscriptionManager {
5151
return result.data;
5252
}
5353

54+
async *listCancelOnDateGenerator(currentPeriodEnd: Stripe.RangeQueryParam) {
55+
for await (const subscription of this.stripeClient.subscriptionsListGenerator(
56+
{ current_period_end: currentPeriodEnd }
57+
)) {
58+
if (subscription.cancel_at_period_end) {
59+
yield subscription;
60+
}
61+
}
62+
}
63+
5464
async cancelIncompleteSubscriptionsToPrice(
5565
customerId: string,
5666
priceId: string
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import {
6+
StripePriceFactory,
7+
StripePriceRecurringFactory,
8+
StripeSubscriptionFactory,
9+
StripeSubscriptionItemFactory,
10+
} from '@fxa/payments/stripe';
11+
import { SubplatInterval } from '../types';
12+
import {
13+
getSubplatIntervalFromSubscription,
14+
PriceNotRecurringError,
15+
} from './getSubplatIntervalFromSubscription';
16+
17+
describe('getSubplatIntervalFromSubscription', () => {
18+
const priceRecurring = StripePriceRecurringFactory({
19+
interval: 'month',
20+
interval_count: 1,
21+
});
22+
const price = StripePriceFactory({ recurring: priceRecurring });
23+
const subscription = StripeSubscriptionFactory({
24+
items: {
25+
object: 'list',
26+
data: [StripeSubscriptionItemFactory({ price })],
27+
has_more: false,
28+
url: `/v1/subscription_items?subscription=`,
29+
},
30+
});
31+
32+
it('returns the correct offering and interval', () => {
33+
expect(getSubplatIntervalFromSubscription(subscription)).toBe(
34+
SubplatInterval.Monthly
35+
);
36+
});
37+
38+
it('does not find offering and interval', () => {
39+
price.recurring = StripePriceRecurringFactory({
40+
interval: 'day',
41+
interval_count: 5,
42+
});
43+
expect(getSubplatIntervalFromSubscription(subscription)).toBe(undefined);
44+
});
45+
46+
it('does not find offering and interval', async () => {
47+
price.recurring = null;
48+
expect(() => getSubplatIntervalFromSubscription(subscription)).toThrow(
49+
PriceNotRecurringError
50+
);
51+
});
52+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import type { StripeSubscription } from '@fxa/payments/stripe';
6+
import { getPriceFromSubscription } from './getPriceFromSubscription';
7+
import { getSubplatInterval } from './getSubplatInterval';
8+
9+
export class PriceNotRecurringError extends Error {
10+
private info: { priceId: string };
11+
constructor(priceId: string) {
12+
super('Plan is not recurring');
13+
this.name = 'SubscriptionRemindersPlanRecurringError';
14+
this.info = { priceId };
15+
}
16+
}
17+
18+
export const getSubplatIntervalFromSubscription = (
19+
subscription: StripeSubscription
20+
) => {
21+
const price = getPriceFromSubscription(subscription);
22+
if (!price.recurring) {
23+
throw new PriceNotRecurringError(price.id);
24+
}
25+
return getSubplatInterval(
26+
price.recurring.interval,
27+
price.recurring.interval_count
28+
);
29+
};

libs/payments/stripe/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export { StripeTaxRateFactory } from './lib/factories/tax-rate.factory';
4040
export { StripeTotalDiscountAmountsFactory } from './lib/factories/total-discount-amounts.factory';
4141
export { StripeTotalTaxAmountsFactory } from './lib/factories/total-tax-amounts.factory';
4242
export { StripeUpcomingInvoiceFactory } from './lib/factories/upcoming-invoice.factory';
43+
export { StripeRangeQueryParamFactory } from './lib/factories/utils.factory';
4344
export * from './lib/stripe.client';
4445
export * from './lib/stripe.client.types';
4546
export * from './lib/stripe.config';

libs/payments/stripe/src/lib/factories/api-list.factory.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
15
import { faker } from '@faker-js/faker';
2-
import { StripeApiList, StripeResponse } from '../stripe.client.types';
6+
import {
7+
StripeApiList,
8+
StripeResponse,
9+
type StripeApiListPromise,
10+
} from '../stripe.client.types';
311

412
export const StripeApiListFactory = <T extends Array<any>>(
513
data: T,
@@ -24,3 +32,33 @@ export const StripeResponseFactory = <T>(
2432
...data,
2533
...override,
2634
});
35+
36+
export const StripeApiListPromiseFactory = <T>(
37+
data: T[]
38+
): StripeApiListPromise<T> => {
39+
let index = 0;
40+
41+
const promise = Promise.resolve(
42+
StripeApiListFactory(data)
43+
) as StripeApiListPromise<T>;
44+
45+
promise.next = async (): Promise<IteratorResult<T>> => {
46+
if (index < data.length) {
47+
return { value: data[index++], done: false };
48+
}
49+
return { value: undefined as any, done: true };
50+
};
51+
promise[Symbol.asyncIterator] = function () {
52+
return promise;
53+
};
54+
55+
promise.autoPagingEach = async (handler: (item: T) => any) => {
56+
for (const item of data) {
57+
await handler(item);
58+
}
59+
};
60+
61+
promise.autoPagingToArray = async () => [...data];
62+
63+
return promise;
64+
};

libs/payments/stripe/src/lib/factories/subscription.factory.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,11 @@ export const StripeSubscriptionItemFactory = (
114114
tax_rates: [],
115115
...override,
116116
});
117+
118+
export async function* StripeSubscriptionAsyncGeneratorFactory(
119+
subscriptions: StripeSubscription[]
120+
): AsyncGenerator<StripeSubscription, void, unknown> {
121+
for (const sub of subscriptions) {
122+
yield sub;
123+
}
124+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Stripe } from 'stripe';
6+
7+
export const StripeRangeQueryParamFactory = (
8+
override?: Partial<Stripe.RangeQueryParam>
9+
): Stripe.RangeQueryParam => ({
10+
...override,
11+
});

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Stripe } from 'stripe';
88

99
import {
1010
StripeApiListFactory,
11+
StripeApiListPromiseFactory,
1112
StripeResponseFactory,
1213
} from './factories/api-list.factory';
1314
import { StripeCustomerFactory } from './factories/customer.factory';
@@ -167,6 +168,20 @@ describe('StripeClient', () => {
167168
});
168169
});
169170

171+
describe('subscriptionsListGenerator', () => {
172+
it('returns generator that yields subscriptions from Stripe', async () => {
173+
const mockSubscription = StripeSubscriptionFactory();
174+
175+
mockStripeSubscriptionsList.mockResolvedValue(
176+
StripeApiListPromiseFactory([mockSubscription])
177+
);
178+
179+
const generator = stripeClient.subscriptionsListGenerator();
180+
const result = (await generator.next()).value;
181+
expect(result).toEqual(mockSubscription);
182+
});
183+
});
184+
170185
describe('subscriptionsCreate', () => {
171186
it('creates subscription within Stripe', async () => {
172187
const mockCustomer = StripeCustomerFactory();

0 commit comments

Comments
 (0)