Skip to content

Commit

Permalink
Merge pull request #18511 from mozilla/FXA-11251
Browse files Browse the repository at this point in the history
fix(payments-next): Create subscription with valid coupon
  • Loading branch information
xlisachan authored Mar 6, 2025
2 parents 5b5fcda + 85ff38d commit 6516e47
Show file tree
Hide file tree
Showing 11 changed files with 114 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,16 @@ import {
SubscriptionTitle,
TermsAndPrivacy,
} from '@fxa/payments/ui/server';
import { CartState } from '@fxa/shared/db/mysql/account';
import { auth } from 'apps/payments/next/auth';
import { config } from 'apps/payments/next/config';

// TODO - Replace these placeholders as part of FXA-8227
export const metadata = {
title: 'Mozilla accounts',
description: 'Mozilla accounts',
};

export interface CheckoutSearchParams {
experiment?: string;
promotion_code?: string;
}

export default async function RootLayout({
export default async function CheckoutLayout({
children,
params,
}: {
Expand Down Expand Up @@ -104,7 +99,7 @@ export default async function RootLayout({
cartId={cart.id}
cartVersion={cart.version}
promoCode={cart.couponCode}
readOnly={false}
readOnly={cart.state === CartState.START ? false : true}
/>
</section>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { Metadata } from 'next';
import { headers } from 'next/headers';
import Image from 'next/image';

import { formatPlanPricing, getCardIcon } from '@fxa/payments/ui';

import { SupportedPages, getApp } from '@fxa/payments/ui/server';
import {
fetchCMSData,
getCartOrRedirectAction,
recordEmitterEventAction,
} from '@fxa/payments/ui/actions';
import { CheckoutParams } from '@fxa/payments/ui/server';
import Image from 'next/image';
import type { Metadata } from 'next';
import {
getApp,
CheckoutParams,
SupportedPages,
} from '@fxa/payments/ui/server';

export const dynamic = 'force-dynamic';

Expand Down
8 changes: 4 additions & 4 deletions apps/payments/next/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import './styles/global.css';
import type { Metadata } from 'next';

// TODO - Replace these placeholders as part of FXA-8227
export const metadata = {
title: 'Mozilla accounts',
description: 'Mozilla accounts',
export const metadata: Metadata = {
title: 'Mozilla',
description: 'Subscription management',
};

export default function RootLayout({
Expand Down
6 changes: 3 additions & 3 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
PriceManager,
ProductManager,
PromotionCodeManager,
STRIPE_CUSTOMER_METADATA,
STRIPE_SUBSCRIPTION_METADATA,
SubscriptionManager,
TaxAddressFactory,
} from '@fxa/payments/customer';
Expand Down Expand Up @@ -447,7 +447,7 @@ describe('CheckoutService', () => {
const mockUpdatedSubscription = StripeResponseFactory(
StripeSubscriptionFactory({
metadata: {
[STRIPE_CUSTOMER_METADATA.SubscriptionPromotionCode]:
[STRIPE_SUBSCRIPTION_METADATA.SubscriptionPromotionCode]:
mockCart.couponCode as string,
},
})
Expand Down Expand Up @@ -476,7 +476,7 @@ describe('CheckoutService', () => {
{
metadata: {
...mockSubscription.metadata,
[STRIPE_CUSTOMER_METADATA.SubscriptionPromotionCode]:
[STRIPE_SUBSCRIPTION_METADATA.SubscriptionPromotionCode]:
mockCart.couponCode,
},
}
Expand Down
10 changes: 5 additions & 5 deletions libs/payments/cart/src/lib/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,12 @@ export class CheckoutService {
await this.customerChanged(uid);

if (cart.couponCode) {
const subscriptionMetadata = {
...subscription.metadata,
[STRIPE_CUSTOMER_METADATA.SubscriptionPromotionCode]: cart.couponCode,
};
await this.subscriptionManager.update(subscription.id, {
metadata: subscriptionMetadata,
metadata: {
...subscription.metadata,
[STRIPE_SUBSCRIPTION_METADATA.SubscriptionPromotionCode]:
cart.couponCode,
},
});
}
await this.cartManager.finishCart(cart.id, version, {});
Expand Down
60 changes: 58 additions & 2 deletions libs/payments/customer/src/lib/promotionCode.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ describe('PromotionCodeManager', () => {
const mockCartCurrency = mockPrice.currency;

jest
.spyOn(stripeClient, 'promotionCodesList')
.mockResolvedValue(StripeResponseFactory(StripeApiListFactory([])));
.spyOn(promotionCodeManager, 'retrieveByName')
.mockResolvedValue(undefined);

await expect(() =>
promotionCodeManager.assertValidPromotionCodeNameForPrice(
Expand Down Expand Up @@ -228,6 +228,62 @@ describe('PromotionCodeManager', () => {
).rejects.toBeInstanceOf(PromotionCodeCouldNotBeAttachedError);
});

it('resolves when cart and coupon currencies match', async () => {
const mockPrice = StripePriceFactory({
currency: 'cad',
});
const mockPromotionCode = StripePromotionCodeFactory({
coupon: StripeCouponFactory({
currency: 'cad',
}),
});
const mockCartCurrency = 'CAD';

jest
.spyOn(promotionCodeManager, 'retrieveByName')
.mockResolvedValue(mockPromotionCode);

jest
.spyOn(promotionCodeManager, 'assertValidPromotionCodeForPrice')
.mockResolvedValue();

await expect(
promotionCodeManager.assertValidPromotionCodeNameForPrice(
mockPromotionCode.code,
mockPrice,
mockCartCurrency
)
).resolves.toEqual(undefined);
});

it('resolves when coupon currency is null', async () => {
const mockPrice = StripePriceFactory({
currency: 'cad',
});
const mockPromotionCode = StripePromotionCodeFactory({
coupon: StripeCouponFactory({
currency: null,
}),
});
const mockCartCurrency = 'CAD';

jest
.spyOn(promotionCodeManager, 'retrieveByName')
.mockResolvedValue(mockPromotionCode);

jest
.spyOn(promotionCodeManager, 'assertValidPromotionCodeForPrice')
.mockResolvedValue();

await expect(
promotionCodeManager.assertValidPromotionCodeNameForPrice(
mockPromotionCode.code,
mockPrice,
mockCartCurrency
)
).resolves.toEqual(undefined);
});

it('throws an error if currencies do no match', async () => {
const mockPrice = StripePriceFactory({
currency: 'cad',
Expand Down
6 changes: 5 additions & 1 deletion libs/payments/customer/src/lib/promotionCode.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export class PromotionCodeManager {
if (!promoCode)
throw new PromotionCodeCouldNotBeAttachedError('PromoCode not found');

if (promoCode.coupon.currency !== cartCurrency)
// promotion code currency may be null, in which case it is applicable to all currencies
if (
promoCode.coupon.currency &&
promoCode.coupon.currency.toLowerCase() !== cartCurrency.toLowerCase()
)
throw new CouponErrorInvalid();

await this.assertValidPromotionCodeForPrice(promoCode, price);
Expand Down
9 changes: 6 additions & 3 deletions libs/payments/customer/src/lib/subscription.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
StripeSubscriptionFactory,
MockStripeConfigProvider,
} from '@fxa/payments/stripe';
import { STRIPE_CUSTOMER_METADATA } from './types';
import { STRIPE_SUBSCRIPTION_METADATA } from './types';
import { SubscriptionManager } from './subscription.manager';
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';

Expand Down Expand Up @@ -179,7 +179,8 @@ describe('SubscriptionManager', () => {
it('updates metadata', async () => {
const mockParams = {
metadata: {
[STRIPE_CUSTOMER_METADATA.SubscriptionPromotionCode]: 'test-coupon',
[STRIPE_SUBSCRIPTION_METADATA.SubscriptionPromotionCode]:
'test-coupon',
},
};
const mockSubscription = StripeSubscriptionFactory(mockParams);
Expand All @@ -204,7 +205,9 @@ describe('SubscriptionManager', () => {
it('throws if metadata key does not match', async () => {
const mockParams = {
metadata: {
[STRIPE_CUSTOMER_METADATA.SubscriptionPromotionCode]: 'test-coupon',
amount: '1200',
[STRIPE_SUBSCRIPTION_METADATA.SubscriptionPromotionCode]:
'test-coupon',
promotionCode: 'test-coupon',
},
};
Expand Down
7 changes: 3 additions & 4 deletions libs/payments/customer/src/lib/subscription.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
StripeSubscription,
} from '@fxa/payments/stripe';
import { ACTIVE_SUBSCRIPTION_STATUSES } from '@fxa/payments/stripe';
import { STRIPE_CUSTOMER_METADATA } from './types';
import { STRIPE_SUBSCRIPTION_METADATA } from './types';
import { InvalidPaymentIntentError, PaymentIntentNotFoundError } from './error';

@Injectable()
Expand Down Expand Up @@ -44,15 +44,14 @@ export class SubscriptionManager {
const newMetadata = params.metadata;
Object.keys(newMetadata).forEach((key) => {
if (
!Object.values(STRIPE_CUSTOMER_METADATA).includes(
key as STRIPE_CUSTOMER_METADATA
!Object.values(STRIPE_SUBSCRIPTION_METADATA).includes(
key as STRIPE_SUBSCRIPTION_METADATA
)
) {
throw new Error(`Invalid metadata key: ${key}`);
}
});
}

return this.stripeClient.subscriptionsUpdate(subscriptionId, params);
}

Expand Down
2 changes: 1 addition & 1 deletion libs/payments/customer/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export interface TaxAmount {

export enum STRIPE_CUSTOMER_METADATA {
PaypalAgreement = 'paypalAgreementId',
SubscriptionPromotionCode = 'appliedPromotionCode',
}

export enum STRIPE_PRICE_METADATA {
Expand All @@ -48,6 +47,7 @@ export enum STRIPE_PRODUCT_METADATA {
export enum STRIPE_SUBSCRIPTION_METADATA {
Currency = 'currency',
Amount = 'amount',
SubscriptionPromotionCode = 'appliedPromotionCode',
}

export enum STRIPE_INVOICE_METADATA {
Expand Down
42 changes: 19 additions & 23 deletions libs/payments/ui/src/lib/client/components/CouponForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
'use client';

import { Localized } from '@fluent/react';
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useFormState } from 'react-dom';
import { ButtonVariant } from '../BaseButton';
import { SubmitButton } from '../SubmitButton';
import { updateCartAction } from '../../../actions/updateCart';
import { getFallbackTextByFluentId } from '../../../utils/error-ftl-messages';
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';

interface WithCouponProps {
cartId: string;
Expand All @@ -30,7 +30,7 @@ const WithCoupon = ({
}

return (
<>
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 mt-6 p-4 rounded-t-lg text-base tablet:my-8">
<h2 className="m-0 mb-4 font-semibold text-grey-600">
<Localized id="next-coupon-promo-code-applied">
Promo Code Applied
Expand All @@ -54,7 +54,7 @@ const WithCoupon = ({
</span>
)}
</form>
</>
</div>
);
};

Expand Down Expand Up @@ -88,7 +88,7 @@ const WithoutCoupon = ({
const [error, formAction] = useFormState(applyCoupon, null);

return (
<>
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 mt-6 p-4 rounded-t-lg text-base tablet:my-8">
<h2 className="m-0 mb-4 font-semibold text-grey-600">
<Localized id="next-coupon-promo-code">Promo Code</Localized>
</h2>
Expand Down Expand Up @@ -124,7 +124,7 @@ const WithoutCoupon = ({
</div>
)}
</form>
</>
</div>
);
};

Expand All @@ -142,22 +142,18 @@ export function CouponForm({
readOnly,
}: CouponFormProps) {
const hasCouponCode = !!promoCode;
return (
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 mt-6 p-4 rounded-t-lg text-base tablet:my-8">
{hasCouponCode ? (
<WithCoupon
cartId={cartId}
cartVersion={cartVersion}
couponCode={promoCode}
readOnly={readOnly}
/>
) : (
<WithoutCoupon
cartId={cartId}
cartVersion={cartVersion}
readOnly={readOnly}
/>
)}
</div>
return hasCouponCode ? (
<WithCoupon
cartId={cartId}
cartVersion={cartVersion}
couponCode={promoCode}
readOnly={readOnly}
/>
) : readOnly ? null : (
<WithoutCoupon
cartId={cartId}
cartVersion={cartVersion}
readOnly={readOnly}
/>
);
}

0 comments on commit 6516e47

Please sign in to comment.