Skip to content

Commit 362fb5d

Browse files
authored
Merge pull request #20070 from mozilla/fix_3509
fix(payments-next): Prevent from redirecting to error page after successfully redeeming coupon
2 parents fc866f1 + f809d0f commit 362fb5d

3 files changed

Lines changed: 142 additions & 100 deletions

File tree

libs/payments/management/src/lib/churn-intervention.service.spec.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,46 @@ describe('ChurnInterventionService', () => {
785785
cmsChurnInterventionEntry: mockCmsChurnEntry,
786786
});
787787
});
788+
789+
it('returns redeemed true and does not increment redemption when coupon already applied', async () => {
790+
jest
791+
.spyOn(churnInterventionService, 'determineStaySubscribedEligibility')
792+
.mockResolvedValue({
793+
isEligible: true,
794+
reason: 'eligible',
795+
cmsChurnInterventionEntry: mockCmsChurnEntry,
796+
cmsOfferingContent: null,
797+
});
798+
jest
799+
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
800+
.mockResolvedValue(mockAccountCustomer);
801+
jest
802+
.spyOn(subscriptionManager, 'retrieve')
803+
.mockResolvedValue(mockSubscription);
804+
805+
jest.spyOn(subscriptionManager, 'hasCouponId').mockResolvedValue(true);
806+
807+
const result = await churnInterventionService.redeemChurnCoupon(
808+
mockUid,
809+
mockSubscription.id,
810+
'stay_subscribed'
811+
);
812+
813+
expect(result).toEqual({
814+
redeemed: true,
815+
reason: 'discount_already_applied',
816+
updatedChurnInterventionEntryData: null,
817+
cmsChurnInterventionEntry: mockCmsChurnEntry,
818+
});
819+
820+
expect(
821+
jest.spyOn(subscriptionManager, 'resubscribeWithCoupon')
822+
).not.toHaveBeenCalled();
823+
824+
expect(
825+
jest.spyOn(churnInterventionManager, 'updateEntry')
826+
).not.toHaveBeenCalled();
827+
});
788828
});
789829

790830
describe('determineCancellationIntervention', () => {
@@ -1102,10 +1142,12 @@ describe('ChurnInterventionService', () => {
11021142
.mockResolvedValue(StripeResponseFactory(mockSubscription));
11031143

11041144
const result =
1105-
await churnInterventionService.determineCancelInterstitialOfferEligibility({
1106-
uid: mockUid,
1107-
subscriptionId: mockSubscription.id,
1108-
});
1145+
await churnInterventionService.determineCancelInterstitialOfferEligibility(
1146+
{
1147+
uid: mockUid,
1148+
subscriptionId: mockSubscription.id,
1149+
}
1150+
);
11091151

11101152
expect(result).toEqual({
11111153
isEligible: false,

libs/payments/management/src/lib/churn-intervention.service.ts

Lines changed: 61 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ import { SubplatInterval, SubscriptionManager } from '@fxa/payments/customer';
2121
import { AccountCustomerManager } from '@fxa/payments/stripe';
2222
import { ProfileClient } from '@fxa/profile/client';
2323
import { NotifierService } from '@fxa/shared/notifier';
24-
import {
25-
ChurnInterventionProductIdentifierMissingError,
26-
} from './churn-intervention.error';
24+
import { ChurnInterventionProductIdentifierMissingError } from './churn-intervention.error';
2725

2826
@Injectable()
2927
export class ChurnInterventionService {
@@ -221,6 +219,25 @@ export class ChurnInterventionService {
221219
};
222220
}
223221

222+
const churnCouponId = cmsChurnInterventionEntry.stripeCouponId;
223+
const couponAlreadyApplied = await this.subscriptionManager.hasCouponId(
224+
subscriptionId,
225+
churnCouponId
226+
);
227+
228+
if (couponAlreadyApplied) {
229+
this.statsd.increment('stay_subscribed_eligibility', {
230+
eligibility: 'ineligible',
231+
reason: 'discount_already_applied',
232+
});
233+
return {
234+
isEligible: false,
235+
reason: 'discount_already_applied',
236+
cmsChurnInterventionEntry: null,
237+
cmsOfferingContent: cmsContent,
238+
};
239+
}
240+
224241
const redemptionCount =
225242
await this.churnInterventionManager.getRedemptionCountForUid(
226243
uid,
@@ -246,25 +263,6 @@ export class ChurnInterventionService {
246263
};
247264
}
248265

249-
const churnCouponId = cmsChurnInterventionEntry.stripeCouponId;
250-
const couponAlreadyApplied = await this.subscriptionManager.hasCouponId(
251-
subscriptionId,
252-
churnCouponId
253-
);
254-
255-
if (couponAlreadyApplied) {
256-
this.statsd.increment('stay_subscribed_eligibility', {
257-
eligibility: 'ineligible',
258-
reason: 'discount_already_applied',
259-
});
260-
return {
261-
isEligible: false,
262-
reason: 'discount_already_applied',
263-
cmsChurnInterventionEntry: null,
264-
cmsOfferingContent: cmsContent,
265-
};
266-
}
267-
268266
this.statsd.increment('stay_subscribed_eligibility', {
269267
eligibility: 'eligible',
270268
});
@@ -354,12 +352,29 @@ export class ChurnInterventionService {
354352
};
355353
}
356354

355+
const couponId =
356+
eligibilityResult.cmsChurnInterventionEntry.stripeCouponId;
357+
358+
const couponAlreadyApplied = await this.subscriptionManager.hasCouponId(
359+
subscriptionId,
360+
couponId
361+
);
362+
363+
if (couponAlreadyApplied) {
364+
return {
365+
redeemed: true,
366+
reason: 'discount_already_applied',
367+
updatedChurnInterventionEntryData: null,
368+
cmsChurnInterventionEntry:
369+
eligibilityResult.cmsChurnInterventionEntry,
370+
};
371+
}
372+
357373
const updatedSubscription =
358374
await this.subscriptionManager.resubscribeWithCoupon({
359375
customerId: subscription.customer,
360376
subscriptionId,
361-
stripeCouponId:
362-
eligibilityResult.cmsChurnInterventionEntry.stripeCouponId,
377+
stripeCouponId: couponId,
363378
});
364379
await this.customerChanged(uid);
365380

@@ -537,7 +552,8 @@ export class ChurnInterventionService {
537552
await this.productConfigurationManager.getPageContentByPriceIds([
538553
stripePriceId,
539554
]);
540-
const { offering, purchaseDetails } = result.purchaseForPriceId(stripePriceId);
555+
const { offering, purchaseDetails } =
556+
result.purchaseForPriceId(stripePriceId);
541557
const offeringId = offering?.apiIdentifier;
542558
const { webIcon, productName } = purchaseDetails;
543559

@@ -747,7 +763,7 @@ export class ChurnInterventionService {
747763
reason: 'customer_mismatch',
748764
cmsChurnInterventionEntry: null,
749765
cmsOfferingContent: null,
750-
}
766+
};
751767
}
752768

753769
const subscriptionStatus =
@@ -809,6 +825,25 @@ export class ChurnInterventionService {
809825
};
810826
}
811827

828+
const churnCouponId = cmsChurnInterventionEntry.stripeCouponId;
829+
const couponAlreadyApplied = await this.subscriptionManager.hasCouponId(
830+
args.subscriptionId,
831+
churnCouponId
832+
);
833+
834+
if (couponAlreadyApplied) {
835+
this.statsd.increment('cancel_intervention_decision', {
836+
type: 'none',
837+
reason: 'discount_already_applied',
838+
});
839+
return {
840+
isEligible: false,
841+
reason: 'discount_already_applied',
842+
cmsChurnInterventionEntry,
843+
cmsOfferingContent: cmsContent,
844+
};
845+
}
846+
812847
const redemptionCount =
813848
await this.churnInterventionManager.getRedemptionCountForUid(
814849
args.uid,
@@ -834,25 +869,6 @@ export class ChurnInterventionService {
834869
};
835870
}
836871

837-
const churnCouponId = cmsChurnInterventionEntry.stripeCouponId;
838-
const couponAlreadyApplied = await this.subscriptionManager.hasCouponId(
839-
args.subscriptionId,
840-
churnCouponId
841-
);
842-
843-
if (couponAlreadyApplied) {
844-
this.statsd.increment('cancel_intervention_decision', {
845-
type: 'none',
846-
reason: 'discount_already_applied',
847-
});
848-
return {
849-
isEligible: false,
850-
reason: 'discount_already_applied',
851-
cmsChurnInterventionEntry,
852-
cmsOfferingContent: cmsContent,
853-
};
854-
}
855-
856872
this.statsd.increment('cancel_intervention_decision', {
857873
type: 'cancel_churn_intervention',
858874
});

libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx

Lines changed: 35 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import { Localized } from '@fluent/react';
88
import * as Form from '@radix-ui/react-form';
99
import Image from 'next/image';
1010
import Link from 'next/link';
11-
import { useParams, useRouter, useSearchParams } from 'next/navigation';
11+
import { useParams, useSearchParams } from 'next/navigation';
1212
import { useState } from 'react';
1313

1414
import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
1515
import {
1616
getNextChargeChurnContent,
17+
BaseButton,
1718
ButtonVariant,
18-
SubmitButton,
1919
} from '@fxa/payments/ui';
2020
import { redeemChurnCouponAction } from '@fxa/payments/ui/actions';
2121
import spinner from '@fxa/shared/assets/images/spinner.svg';
@@ -72,7 +72,6 @@ export function ChurnCancel({
7272
cmsChurnInterventionEntry,
7373
cmsOfferingContent,
7474
}: ChurnCancelProps) {
75-
const router = useRouter();
7675
const [loading, setLoading] = useState(false);
7776
const [showResubscribeActionError, setResubscribeActionError] =
7877
useState(false);
@@ -104,7 +103,8 @@ export function ChurnCancel({
104103
nextInvoiceTotal: cancelContent.nextInvoiceTotal,
105104
});
106105

107-
async function churnCancelFlow() {
106+
async function handleRedeemSubmit(e: React.FormEvent<HTMLFormElement>) {
107+
e.preventDefault();
108108
if (loading) return;
109109

110110
setLoading(true);
@@ -177,30 +177,17 @@ export function ChurnCancel({
177177
<Link
178178
className="border box-border flex font-bold font-header h-14 items-center justify-center rounded text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50"
179179
href={`/${locale}/subscriptions/landing`}
180-
onClick={(e) => {
181-
e.preventDefault();
182-
setLoading(true);
183-
router.push(`/${locale}/subscriptions/landing`);
184-
}}
185180
>
186-
{loading ? (
187-
<Image
188-
src={spinner}
189-
alt=""
190-
className="absolute animate-spin h-8 w-8"
191-
/>
192-
) : (
193-
<Localized id="churn-cancel-flow-button-back-to-subscriptions">
194-
<span>Back to subscriptions</span>
195-
</Localized>
196-
)}
181+
<Localized id="churn-cancel-flow-button-back-to-subscriptions">
182+
<span>Back to subscriptions</span>
183+
</Localized>
197184
</Link>
198185
</div>
199186
</div>
200187
</div>
201188
) : isOffer ? (
202189
<Form.Root
203-
action={churnCancelFlow}
190+
onSubmit={handleRedeemSubmit}
204191
className="max-w-[480px] p-10 text-grey-600 tablet:bg-white tablet:rounded-xl tablet:border tablet:border-grey-200 tablet:shadow-[0_0_16px_0_rgba(0,0,0,0.08)]"
205192
>
206193
<div className="flex flex-col items-center justify-center gap-4 text-center">
@@ -246,47 +233,44 @@ export function ChurnCancel({
246233
)}
247234

248235
<Form.Submit asChild>
249-
<SubmitButton
236+
<BaseButton
250237
className="flex font-bold h-14 items-center justify-center tablet:w-full"
251238
variant={ButtonVariant.SubscriptionManagementSecondary}
239+
type="submit"
252240
disabled={loading}
253241
>
254-
{typeof discountAmount === 'number' && discountAmount > 0 ? (
255-
<Localized
256-
id="churn-cancel-flow-button-stay-subscribed-and-save-discount"
257-
vars={{ discountPercent: discountAmount }}
258-
>
259-
<span>Stay subscribed and save {discountAmount}%</span>
260-
</Localized>
242+
{loading ? (
243+
<Image
244+
src={spinner}
245+
alt=""
246+
className="absolute animate-spin h-8 w-8"
247+
/>
261248
) : (
262-
<Localized id="churn-cancel-flow-button-stay-subscribed-and-save">
263-
<span>Stay subscribed and save</span>
264-
</Localized>
249+
<>
250+
{typeof discountAmount === 'number' &&
251+
discountAmount > 0 ? (
252+
<Localized
253+
id="churn-cancel-flow-button-stay-subscribed-and-save-discount"
254+
vars={{ discountPercent: discountAmount }}
255+
>
256+
<span>Stay subscribed and save {discountAmount}%</span>
257+
</Localized>
258+
) : (
259+
<Localized id="churn-cancel-flow-button-stay-subscribed-and-save">
260+
<span>Stay subscribed and save</span>
261+
</Localized>
262+
)}
263+
</>
265264
)}
266-
</SubmitButton>
265+
</BaseButton>
267266
</Form.Submit>
268267
<Link
269268
className="border box-border font-bold font-header h-14 items-center justify-center rounded text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
270269
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
271-
onClick={(e) => {
272-
e.preventDefault();
273-
setLoading(true);
274-
router.push(
275-
`/${locale}/subscriptions/${subscriptionId}/cancel`
276-
);
277-
}}
278270
>
279-
{loading ? (
280-
<Image
281-
src={spinner}
282-
alt=""
283-
className="absolute animate-spin h-8 w-8"
284-
/>
285-
) : (
286-
<Localized id="churn-cancel-flow-button-continue-to-cancel">
287-
<span>Continue to cancel</span>
288-
</Localized>
289-
)}
271+
<Localized id="churn-cancel-flow-button-continue-to-cancel">
272+
<span>Continue to cancel</span>
273+
</Localized>
290274
</Link>
291275
<LinkExternal
292276
href={`/${locale}/${apiIdentifier}/${interval}/cancel/loyalty-discount/terms`}

0 commit comments

Comments
 (0)