Skip to content

Commit cc8c112

Browse files
committed
feat(auth): add churn stay subscribed email
Because: - Want to remind customers that do not have auto-renew set that their subscription is expiring soon and potentially offer them a coupon. This commit: - Adds subscriptionEndingReminder email - Update existing subscription-reminders script to also send subscriptionEndingReminder emails if enabled. Closes #PAY-3366
1 parent e5d59c3 commit cc8c112

File tree

33 files changed

+1625
-36
lines changed

33 files changed

+1625
-36
lines changed

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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ 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';
20+
import { StripeSubscriptionAsyncGeneratorFactory } from 'libs/payments/stripe/src/lib/factories/subscription.factory';
1921

2022
describe('SubscriptionManager', () => {
2123
let subscriptionManager: SubscriptionManager;
@@ -92,6 +94,46 @@ describe('SubscriptionManager', () => {
9294
});
9395
});
9496

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

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

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

2323
async create(
2424
params: Omit<Stripe.SubscriptionCreateParams, 'metadata'> & {
25-
metadata?: StripeSubscriptionMetadataInput
25+
metadata?: StripeSubscriptionMetadataInput;
2626
},
2727
options?: Stripe.RequestOptions
2828
) {
@@ -36,8 +36,8 @@ export class SubscriptionManager {
3636
async update(
3737
subscriptionId: string,
3838
params: Omit<Stripe.SubscriptionUpdateParams, 'metadata'> & {
39-
metadata?: StripeSubscriptionMetadataInput
40-
},
39+
metadata?: StripeSubscriptionMetadataInput;
40+
}
4141
) {
4242
return this.stripeClient.subscriptionsUpdate(subscriptionId, params);
4343
}
@@ -50,6 +50,16 @@ export class SubscriptionManager {
5050
return result.data;
5151
}
5252

53+
async *listCancelOnDateGenerator(currentPeriodEnd: Stripe.RangeQueryParam) {
54+
for await (const subscription of this.stripeClient.subscriptionsListGenerator(
55+
{ current_period_end: currentPeriodEnd }
56+
)) {
57+
if (subscription.cancel_at_period_end) {
58+
yield subscription;
59+
}
60+
}
61+
}
62+
5363
async cancelIncompleteSubscriptionsToPrice(
5464
customerId: string,
5565
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)