Skip to content

Commit 5097de4

Browse files
feat(payments-next): Update page: Subscription Management
Because: * We want to reduce churn by giving customers offers when they either go to cancel their subscription, or encourage them to stay subscribed to a subscription that will soon expire. This commit: * Determines customer's eligibility, and either displays appropriate messages + takes them to corresponding links, or keeps existing behaviour. Closes #PAY-3365
1 parent 14a6d4f commit 5097de4

28 files changed

+468
-175
lines changed

apps/payments/next/app/[locale]/subscriptions/manage/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default async function Manage({
6767
subscriptions,
6868
appleIapSubscriptions,
6969
googleIapSubscriptions,
70-
} = await getSubManPageContentAction(session.user?.id);
70+
} = await getSubManPageContentAction(session.user?.id, acceptLanguage, locale);
7171
const {
7272
billingAgreementId,
7373
brand,

libs/payments/customer/src/lib/customer.error.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ export class PriceForCurrencyNotFoundError extends CustomerError {
3636
}
3737
}
3838

39+
export class SubscriptionCustomerMismatchError extends CustomerError {
40+
constructor(
41+
customerId: string,
42+
subscriptionCustomerId: string,
43+
subscriptionId: string,
44+
) {
45+
super('Subscription customer does not match provided customer', {
46+
customerId,
47+
subscriptionCustomerId,
48+
subscriptionId,
49+
});
50+
this.name = 'SubscriptionCustomerMismatchError';
51+
}
52+
}
53+
3954
export class PromotionCodeError extends CustomerError {
4055
constructor(message: string, info: Record<string, any>) {
4156
super(message, info);

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { STRIPE_SUBSCRIPTION_METADATA } from './types';
1717
import { SubscriptionManager } from './subscription.manager';
1818
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
19+
import { SubscriptionCustomerMismatchError} from './customer.error'
1920

2021
describe('SubscriptionManager', () => {
2122
let subscriptionManager: SubscriptionManager;
@@ -298,4 +299,144 @@ describe('SubscriptionManager', () => {
298299
expect(result).toEqual('paypal');
299300
});
300301
});
302+
303+
describe('getSubscriptionStatus', () => {
304+
it('correctly returns active and cancelAtPeriodEnd when customerId matches', async () => {
305+
const mockCustomer = StripeCustomerFactory();
306+
const mockSubscription = StripeSubscriptionFactory({
307+
status: 'active',
308+
cancel_at_period_end: true,
309+
customer: mockCustomer.id,
310+
});
311+
const mockResponse = StripeResponseFactory(mockSubscription);
312+
313+
jest
314+
.spyOn(subscriptionManager, 'retrieve')
315+
.mockResolvedValue(mockResponse);
316+
317+
const result = await subscriptionManager.getSubscriptionStatus(
318+
mockCustomer.id,
319+
mockSubscription.id
320+
);
321+
322+
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
323+
mockSubscription.id
324+
);
325+
expect(result).toEqual({
326+
active: true,
327+
cancelAtPeriodEnd: true,
328+
});
329+
});
330+
331+
it('throws an error when customerId does not match', async () => {
332+
const mockCustomer1 = StripeCustomerFactory();
333+
const mockCustomer2 = StripeCustomerFactory();
334+
const mockSubscription = StripeSubscriptionFactory({
335+
customer: mockCustomer2.id,
336+
});
337+
const mockResponse = StripeResponseFactory(mockSubscription);
338+
339+
jest
340+
.spyOn(subscriptionManager, 'retrieve')
341+
.mockResolvedValueOnce(mockResponse);
342+
343+
await expect(
344+
subscriptionManager.getSubscriptionStatus(mockCustomer1.id, mockSubscription.id)
345+
).rejects.toThrow(SubscriptionCustomerMismatchError);
346+
347+
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
348+
mockSubscription.id
349+
);
350+
});
351+
});
352+
353+
describe('applyStripeCouponToSubscription', () => {
354+
it('successfully applies coupon without setCancelAtPeriodEnd', async () => {
355+
const mockCustomer = StripeCustomerFactory();
356+
const mockSubscription = StripeSubscriptionFactory({
357+
customer: mockCustomer.id,
358+
});
359+
const mockResponse = StripeResponseFactory(mockSubscription);
360+
const mockCouponId = 'coupon_123';
361+
const mockUpdatedSubscription = StripeSubscriptionFactory({
362+
customer: mockCustomer.id,
363+
});
364+
const mockUpdatedResponse = StripeResponseFactory(mockUpdatedSubscription);
365+
366+
jest
367+
.spyOn(subscriptionManager, 'retrieve')
368+
.mockResolvedValue(mockResponse);
369+
jest
370+
.spyOn(subscriptionManager, 'update')
371+
.mockResolvedValue(mockUpdatedResponse);
372+
373+
const result = await subscriptionManager.applyStripeCouponToSubscription({
374+
customerId: mockCustomer.id,
375+
subscriptionId: mockSubscription.id,
376+
stripeCouponId: mockCouponId
377+
});
378+
379+
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(mockSubscription.id);
380+
expect(subscriptionManager.update).toHaveBeenCalledWith(mockSubscription.id, {
381+
discounts: [{ coupon: mockCouponId }],
382+
});
383+
expect(result).toEqual(mockUpdatedResponse);
384+
});
385+
386+
it('successfully applies coupon with setCancelAtPeriodEnd', async () => {
387+
const mockCustomer = StripeCustomerFactory();
388+
const mockSubscription = StripeSubscriptionFactory({
389+
customer: mockCustomer.id,
390+
});
391+
const mockResponse = StripeResponseFactory(mockSubscription);
392+
const mockCouponId = 'coupon_123';
393+
const mockUpdatedSubscription = StripeSubscriptionFactory({
394+
customer: mockCustomer.id,
395+
});
396+
const mockUpdatedResponse = StripeResponseFactory(mockUpdatedSubscription);
397+
398+
jest
399+
.spyOn(subscriptionManager, 'retrieve')
400+
.mockResolvedValue(mockResponse);
401+
jest
402+
.spyOn(subscriptionManager, 'update')
403+
.mockResolvedValue(mockUpdatedResponse);
404+
405+
const result = await subscriptionManager.applyStripeCouponToSubscription({
406+
customerId: mockCustomer.id,
407+
subscriptionId: mockSubscription.id,
408+
stripeCouponId: mockCouponId,
409+
setCancelAtPeriodEnd: true
410+
});
411+
412+
expect(subscriptionManager.update).toHaveBeenCalledWith(mockSubscription.id, {
413+
discounts: [{ coupon: mockCouponId }],
414+
cancel_at_period_end: true,
415+
});
416+
expect(result).toEqual(mockUpdatedResponse);
417+
});
418+
419+
it('throws an error when customerId does not match', async () => {
420+
const mockCustomer1 = StripeCustomerFactory();
421+
const mockCustomer2 = StripeCustomerFactory();
422+
const mockSubscription = StripeSubscriptionFactory({
423+
customer: mockCustomer2.id,
424+
});
425+
const mockResponse = StripeResponseFactory(mockSubscription);
426+
const mockCouponId = 'coupon_123';
427+
428+
jest
429+
.spyOn(subscriptionManager, 'retrieve')
430+
.mockResolvedValueOnce(mockResponse);
431+
432+
await expect(
433+
subscriptionManager.applyStripeCouponToSubscription({
434+
customerId: mockCustomer1.id,
435+
subscriptionId: mockSubscription.id,
436+
stripeCouponId: mockCouponId
437+
})
438+
).rejects.toThrow(SubscriptionCustomerMismatchError);
439+
expect(jest.spyOn(subscriptionManager, 'update')).not.toHaveBeenCalled();
440+
});
441+
});
301442
});

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Stripe } from 'stripe';
88
import { StripeClient, StripeSubscription } from '@fxa/payments/stripe';
99
import { ACTIVE_SUBSCRIPTION_STATUSES } from '@fxa/payments/stripe';
1010
import { type StripeSubscriptionMetadataInput } from './types';
11+
import { SubscriptionCustomerMismatchError} from './customer.error'
1112

1213
@Injectable()
1314
export class SubscriptionManager {
@@ -88,4 +89,58 @@ export class SubscriptionManager {
8889
this.getPaymentProvider(sub) === 'paypal'
8990
);
9091
}
92+
93+
async getSubscriptionStatus(
94+
customerId: string,
95+
subscriptionId: string
96+
): Promise<{
97+
active: boolean;
98+
cancelAtPeriodEnd: boolean;
99+
}> {
100+
const subscription = await this.retrieve(subscriptionId);
101+
102+
if (subscription.customer !== customerId) {
103+
throw new SubscriptionCustomerMismatchError(
104+
customerId,
105+
subscription.customer,
106+
subscriptionId
107+
);
108+
}
109+
110+
return {
111+
active: subscription.status === 'active',
112+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
113+
};
114+
}
115+
116+
async applyStripeCouponToSubscription(args: {
117+
customerId: string;
118+
subscriptionId: string;
119+
stripeCouponId: string;
120+
setCancelAtPeriodEnd?: boolean;
121+
}) {
122+
const { customerId, subscriptionId, stripeCouponId, setCancelAtPeriodEnd } = args;
123+
124+
const subscription = await this.retrieve(subscriptionId);
125+
126+
if (subscription.customer !== customerId) {
127+
throw new SubscriptionCustomerMismatchError(
128+
customerId,
129+
subscription.customer,
130+
subscriptionId
131+
);
132+
}
133+
try {
134+
const updatedSubscription = await this.update(
135+
subscriptionId,
136+
{
137+
discounts: [{ coupon: stripeCouponId }],
138+
...(setCancelAtPeriodEnd ? { cancel_at_period_end: true } : {}),
139+
}
140+
);
141+
return updatedSubscription;
142+
} catch (error) {
143+
return null;
144+
}
145+
}
91146
}

0 commit comments

Comments
 (0)