зеркало из https://github.com/mozilla/fxa.git
feat(payments): Gate SubscriptionCreate screen based on new/returning PayPal customers.
Also simplify PaymentLegalBlurb component on Checkout screens.
This commit is contained in:
Родитель
a75f925682
Коммит
2c03f3342f
|
@ -24,6 +24,9 @@ type MockAppProps = {
|
|||
export const defaultAppContextValue: AppContextType = {
|
||||
config: {
|
||||
...config,
|
||||
featureFlags: {
|
||||
usePaypalUIByDefault: true,
|
||||
},
|
||||
productRedirectURLs: {
|
||||
product_8675309: 'https://example.com/product',
|
||||
},
|
||||
|
|
|
@ -6,10 +6,7 @@ import { Plan, Profile, Customer } from '../../store/types';
|
|||
import { PaymentProviderDetails } from '../PaymentProviderDetails';
|
||||
import SubscriptionTitle from '../SubscriptionTitle';
|
||||
import { TermsAndPrivacy } from '../TermsAndPrivacy';
|
||||
import {
|
||||
PaypalPaymentLegalBlurb,
|
||||
StripePaymentLegalBlurb,
|
||||
} from '../PaymentLegalBlurb';
|
||||
import PaymentLegalBlurb from '../PaymentLegalBlurb';
|
||||
|
||||
import circledCheckbox from './images/circled-confirm.svg';
|
||||
|
||||
|
@ -135,8 +132,7 @@ export const PaymentConfirmation = ({
|
|||
Continue to download
|
||||
</a>
|
||||
</Localized>
|
||||
{Provider.isPaypal(payment_provider) && <PaypalPaymentLegalBlurb />}
|
||||
{Provider.isStripe(payment_provider) && <StripePaymentLegalBlurb />}
|
||||
<PaymentLegalBlurb provider={payment_provider} />
|
||||
<TermsAndPrivacy plan={selectedPlan} />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -56,7 +56,7 @@ const PLAN = {
|
|||
'https://www.mozilla.org/fr/privacy/websites/',
|
||||
},
|
||||
};
|
||||
const CUSTOMER = {
|
||||
const CUSTOMER: Customer = {
|
||||
billing_name: 'Foo Barson',
|
||||
payment_provider: 'stripe',
|
||||
payment_type: 'credit',
|
||||
|
|
|
@ -39,6 +39,7 @@ import { Plan, Customer } from '../../store/types';
|
|||
import { productDetailsFromPlan } from 'fxa-shared/subscriptions/metadata';
|
||||
|
||||
import './index.scss';
|
||||
import * as PaymentProvider from '../../lib/PaymentProvider';
|
||||
|
||||
export type PaymentSubmitResult = {
|
||||
stripe: Stripe;
|
||||
|
@ -82,8 +83,10 @@ export const PaymentForm = ({
|
|||
onChange: onChangeProp,
|
||||
submitNonce,
|
||||
}: BasePaymentFormProps) => {
|
||||
const hasExistingCard =
|
||||
customer && customer.last4 && customer.subscriptions.length > 0;
|
||||
const isExistingStripeCustomer =
|
||||
customer &&
|
||||
PaymentProvider.isStripe(customer?.payment_provider) &&
|
||||
customer.subscriptions.length > 0;
|
||||
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
@ -121,7 +124,7 @@ export const PaymentForm = ({
|
|||
const { name } = validator.getValues();
|
||||
const card = elements.getElement(CardElement);
|
||||
/* istanbul ignore next - card should exist unless there was an external stripe loading error, handled above */
|
||||
if (hasExistingCard || card) {
|
||||
if (isExistingStripeCustomer || card) {
|
||||
onSubmitForParent({
|
||||
stripe,
|
||||
elements,
|
||||
|
@ -157,7 +160,7 @@ export const PaymentForm = ({
|
|||
navigatorLanguages
|
||||
));
|
||||
}
|
||||
const paymentSource = hasExistingCard ? (
|
||||
const paymentSource = isExistingStripeCustomer ? (
|
||||
<div className="card-details" data-testid="card-details">
|
||||
<Localized
|
||||
id="sub-update-card-ending"
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Localized } from '@fluent/react';
|
||||
|
||||
import * as PaymentProvider from '../../lib/PaymentProvider';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
function getPrivacyLinkText(): string {
|
||||
|
@ -15,7 +17,7 @@ function getStripePrivacyLinkText(): string {
|
|||
return 'View the <stripePrivacyLink>Stripe privacy policy</stripePrivacyLink>.';
|
||||
}
|
||||
|
||||
export const PaypalPaymentLegalBlurb = () => (
|
||||
const PaypalPaymentLegalBlurb = () => (
|
||||
<div className="payment-legal-blurb">
|
||||
<Localized id="payment-legal-copy-paypal">
|
||||
<p>Mozilla uses Paypal for secure payment processing.</p>
|
||||
|
@ -38,7 +40,7 @@ export const PaypalPaymentLegalBlurb = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
export const StripePaymentLegalBlurb = () => (
|
||||
const StripePaymentLegalBlurb = () => (
|
||||
<div className="payment-legal-blurb">
|
||||
<Localized id="payment-legal-copy-stripe">
|
||||
<p>Mozilla uses Stripe for secure payment processing.</p>
|
||||
|
@ -61,7 +63,7 @@ export const StripePaymentLegalBlurb = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
export const PaymentLegalBlurb = () => (
|
||||
const DefaultPaymentLegalBlurb = () => (
|
||||
<div className="payment-legal-blurb">
|
||||
<Localized id="payment-legal-copy-stripe-paypal">
|
||||
<p>Mozilla uses Stripe and Paypal for secure payment processing.</p>
|
||||
|
@ -91,4 +93,17 @@ export const PaymentLegalBlurb = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
export type PaymentLegalBlurbProps = {
|
||||
provider: PaymentProvider.ProviderType | undefined;
|
||||
};
|
||||
|
||||
export const PaymentLegalBlurb = ({ provider }: PaymentLegalBlurbProps) => {
|
||||
return (
|
||||
(PaymentProvider.isPaypal(provider) && <PaypalPaymentLegalBlurb />) ||
|
||||
(PaymentProvider.isStripe(provider) && <StripePaymentLegalBlurb />) || (
|
||||
<DefaultPaymentLegalBlurb />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentLegalBlurb;
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
import React from 'react';
|
||||
import { Localized } from '@fluent/react';
|
||||
|
||||
import * as Provider from '../../lib/PaymentProvider';
|
||||
import { LoadingSpinner } from '../LoadingSpinner';
|
||||
import SubscriptionTitle from '../SubscriptionTitle';
|
||||
import {
|
||||
PaypalPaymentLegalBlurb,
|
||||
StripePaymentLegalBlurb,
|
||||
} from '../PaymentLegalBlurb';
|
||||
import PaymentLegalBlurb from '../PaymentLegalBlurb';
|
||||
import { ProviderType } from 'fxa-payments-server/src/lib/PaymentProvider';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export type PaymentProcessingProps = {
|
||||
provider: 'stripe' | 'paypal';
|
||||
provider?: ProviderType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
@ -35,8 +32,7 @@ export const PaymentProcessing = ({
|
|||
</div>
|
||||
|
||||
<div className="footer" data-testid="footer">
|
||||
{Provider.isPaypal(provider) && <PaypalPaymentLegalBlurb />}
|
||||
{Provider.isStripe(provider) && <StripePaymentLegalBlurb />}
|
||||
<PaymentLegalBlurb provider={provider} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
|
|
@ -2,10 +2,16 @@
|
|||
* 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/. */
|
||||
|
||||
export function isStripe(provider: string | undefined) {
|
||||
export type ProviderType = 'paypal' | 'stripe' | 'not_chosen';
|
||||
|
||||
export function isStripe(provider: ProviderType | undefined): boolean {
|
||||
return provider === 'stripe';
|
||||
}
|
||||
|
||||
export function isPaypal(provider: string | undefined) {
|
||||
export function isPaypal(provider: ProviderType | undefined): boolean {
|
||||
return provider === 'paypal';
|
||||
}
|
||||
|
||||
export function isNotChosen(provider: ProviderType | undefined): boolean {
|
||||
return provider === 'not_chosen' || provider === undefined;
|
||||
}
|
||||
|
|
|
@ -513,6 +513,7 @@ export const MOCK_ACTIVE_SUBSCRIPTIONS_AFTER_SUBSCRIPTION = [
|
|||
export const MOCK_CUSTOMER = {
|
||||
billing_name: 'Jane Doe',
|
||||
payment_type: 'card',
|
||||
payment_provider: 'stripe',
|
||||
brand: 'Visa',
|
||||
last4: '8675',
|
||||
exp_month: '8',
|
||||
|
|
|
@ -11,7 +11,7 @@ import { linkTo } from '@storybook/addon-links';
|
|||
import MockApp, {
|
||||
defaultAppContextValue,
|
||||
} from '../../../../.storybook/components/MockApp';
|
||||
import { CUSTOMER, PROFILE, PLAN } from '../../../lib/mock-data';
|
||||
import { CUSTOMER, PROFILE, PLAN, NEW_CUSTOMER } from '../../../lib/mock-data';
|
||||
import { APIError } from '../../../lib/apiClient';
|
||||
import { PickPartial } from '../../../lib/types';
|
||||
import { SignInLayout } from '../../../components/AppLayout';
|
||||
|
@ -178,7 +178,7 @@ function init() {
|
|||
|
||||
const Subject = ({
|
||||
isMobile = false,
|
||||
customer = CUSTOMER,
|
||||
customer = NEW_CUSTOMER,
|
||||
profile = PROFILE,
|
||||
selectedPlan = PLAN,
|
||||
apiClientOverrides = defaultApiClientOverrides,
|
||||
|
|
|
@ -12,7 +12,13 @@ import {
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { PaymentMethod, PaymentIntent } from '@stripe/stripe-js';
|
||||
import { SignInLayout } from '../../../components/AppLayout';
|
||||
import { CUSTOMER, PROFILE, PLAN, NEW_CUSTOMER } from '../../../lib/mock-data';
|
||||
import {
|
||||
CUSTOMER,
|
||||
PROFILE,
|
||||
PLAN,
|
||||
NEW_CUSTOMER,
|
||||
PAYPAL_CUSTOMER,
|
||||
} from '../../../lib/mock-data';
|
||||
import { PickPartial } from '../../../lib/types';
|
||||
|
||||
import {
|
||||
|
@ -191,7 +197,7 @@ describe('routes/ProductV2/SubscriptionCreate', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('renders as expected with PayPal UI enabled and an existing customer', async () => {
|
||||
it('renders as expected with PayPal UI enabled and an existing Stripe customer', async () => {
|
||||
const { queryByTestId } = screen;
|
||||
updateConfig({
|
||||
featureFlags: {
|
||||
|
@ -209,6 +215,35 @@ describe('routes/ProductV2/SubscriptionCreate', () => {
|
|||
waitForExpect(() =>
|
||||
expect(queryByTestId('paypal-button')).not.toBeInTheDocument()
|
||||
);
|
||||
waitForExpect(() =>
|
||||
expect(queryByTestId('paymentForm')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders as expected with PayPal UI enabled and an existing PayPal customer', async () => {
|
||||
const { queryByTestId } = screen;
|
||||
updateConfig({
|
||||
featureFlags: {
|
||||
usePaypalUIByDefault: true,
|
||||
},
|
||||
});
|
||||
const MockedButtonBase = ({}: ButtonBaseProps) => {
|
||||
return <button data-testid="paypal-button" />;
|
||||
};
|
||||
await act(async () => {
|
||||
render(
|
||||
<Subject
|
||||
customer={PAYPAL_CUSTOMER}
|
||||
paypalButtonBase={MockedButtonBase}
|
||||
/>
|
||||
);
|
||||
});
|
||||
waitForExpect(() =>
|
||||
expect(queryByTestId('paypal-button')).not.toBeInTheDocument()
|
||||
);
|
||||
waitForExpect(() =>
|
||||
expect(queryByTestId('paymentForm')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders as expected for mobile', async () => {
|
||||
|
|
|
@ -22,6 +22,7 @@ import PaymentLegalBlurb from '../../../components/PaymentLegalBlurb';
|
|||
import { SubscriptionTitle } from '../../../components/SubscriptionTitle';
|
||||
import { TermsAndPrivacy } from '../../../components/TermsAndPrivacy';
|
||||
import { PaymentProcessing } from '../../../components/PaymentProcessing';
|
||||
import { ProviderType } from '../../../lib/PaymentProvider';
|
||||
|
||||
import * as Amplitude from '../../../lib/amplitude';
|
||||
import { Localized } from '@fluent/react';
|
||||
|
@ -92,6 +93,10 @@ export const SubscriptionCreate = ({
|
|||
|
||||
const [paypalScriptLoaded, setPaypalScriptLoaded] = useState(false);
|
||||
|
||||
// The Stripe customer isn't created until payment is submitted, so
|
||||
// customer can be null and customer.payment_provider can be undefined.
|
||||
const paymentProvider: ProviderType | undefined = customer?.payment_provider;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.featureFlags.usePaypalUIByDefault) {
|
||||
return;
|
||||
|
@ -203,7 +208,7 @@ export const SubscriptionCreate = ({
|
|||
})}
|
||||
data-testid="subscription-create"
|
||||
>
|
||||
{!hasExistingCard(customer) && paypalScriptLoaded && (
|
||||
{isNewCustomer(customer) && paypalScriptLoaded && (
|
||||
<div
|
||||
className="subscription-create-pay-with-other"
|
||||
data-testid="pay-with-other"
|
||||
|
@ -230,7 +235,7 @@ export const SubscriptionCreate = ({
|
|||
)}
|
||||
|
||||
<div className="subscription-create-pay-with-card">
|
||||
{!hasExistingCard(customer) && !paypalScriptLoaded && (
|
||||
{isNewCustomer(customer) && !paypalScriptLoaded && (
|
||||
<div>
|
||||
<Localized id="pay-with-heading-card-only">
|
||||
<p className="pay-with-heading">Pay with card</p>
|
||||
|
@ -239,7 +244,7 @@ export const SubscriptionCreate = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!hasExistingCard(customer) && paypalScriptLoaded && (
|
||||
{isNewCustomer(customer) && paypalScriptLoaded && (
|
||||
<div>
|
||||
<Localized id="pay-with-heading-card-or">
|
||||
<p className="pay-with-heading">Or pay with card</p>
|
||||
|
@ -248,39 +253,45 @@ export const SubscriptionCreate = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="billing-title">
|
||||
<Localized id="sub-update-payment-title">
|
||||
<span className="title">Payment information</span>
|
||||
</Localized>
|
||||
</h3>
|
||||
{!isExistingPaypalCustomer(customer) && (
|
||||
<>
|
||||
<h3 className="billing-title">
|
||||
<Localized id="sub-update-payment-title">
|
||||
<span className="title">Payment information</span>
|
||||
</Localized>
|
||||
</h3>
|
||||
|
||||
<ErrorMessage isVisible={!!paymentError}>
|
||||
{paymentError && (
|
||||
<Localized id={getErrorMessage(paymentError.code || 'UNKNOWN')}>
|
||||
<p data-testid="error-payment-submission">
|
||||
{getErrorMessage(paymentError.code || 'UNKNOWN')}
|
||||
</p>
|
||||
</Localized>
|
||||
)}
|
||||
</ErrorMessage>
|
||||
<ErrorMessage isVisible={!!paymentError}>
|
||||
{paymentError && (
|
||||
<Localized
|
||||
id={getErrorMessage(paymentError.code || 'UNKNOWN')}
|
||||
>
|
||||
<p data-testid="error-payment-submission">
|
||||
{getErrorMessage(paymentError.code || 'UNKNOWN')}
|
||||
</p>
|
||||
</Localized>
|
||||
)}
|
||||
</ErrorMessage>
|
||||
|
||||
<PaymentForm
|
||||
{...{
|
||||
customer,
|
||||
submitNonce,
|
||||
onSubmit,
|
||||
onChange,
|
||||
inProgress,
|
||||
validatorInitialState,
|
||||
confirm: true,
|
||||
plan: selectedPlan,
|
||||
onMounted: onFormMounted,
|
||||
onEngaged: onFormEngaged,
|
||||
}}
|
||||
/>
|
||||
<PaymentForm
|
||||
{...{
|
||||
customer,
|
||||
submitNonce,
|
||||
onSubmit,
|
||||
onChange,
|
||||
inProgress,
|
||||
validatorInitialState,
|
||||
confirm: true,
|
||||
plan: selectedPlan,
|
||||
onMounted: onFormMounted,
|
||||
onEngaged: onFormEngaged,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="subscription-create-footer">
|
||||
<PaymentLegalBlurb />
|
||||
<PaymentLegalBlurb provider={paymentProvider} />
|
||||
{selectedPlan && <TermsAndPrivacy plan={selectedPlan} />}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -330,7 +341,7 @@ async function handleSubscriptionPayment({
|
|||
} & SubscriptionCreateAuthServerAPIs) {
|
||||
// If there's an existing card on record, GOTO 3
|
||||
|
||||
if (hasExistingCard(customer)) {
|
||||
if (isExistingStripeCustomer(customer)) {
|
||||
const createSubscriptionResult = await apiCreateSubscriptionWithPaymentMethod(
|
||||
{
|
||||
priceId: selectedPlan.plan_id,
|
||||
|
@ -502,7 +513,18 @@ async function handlePaymentIntent({
|
|||
}
|
||||
}
|
||||
|
||||
const hasExistingCard = (customer: Customer | null) =>
|
||||
customer && customer.last4 && customer.subscriptions.length > 0;
|
||||
const isExistingStripeCustomer = (customer: Customer | null) =>
|
||||
customer &&
|
||||
customer.payment_provider === 'stripe' &&
|
||||
customer.subscriptions.length > 0;
|
||||
|
||||
const isExistingPaypalCustomer = (customer: Customer | null) =>
|
||||
customer &&
|
||||
customer.payment_provider === 'paypal' &&
|
||||
customer.subscriptions.length > 0;
|
||||
|
||||
const isNewCustomer = (customer: Customer | null) =>
|
||||
customer?.payment_provider === undefined ||
|
||||
customer?.payment_provider === 'not_chosen';
|
||||
|
||||
export default SubscriptionCreate;
|
||||
|
|
|
@ -26,6 +26,7 @@ import { useValidatorState } from '../../../lib/validator';
|
|||
import DialogMessage from '../../../components/DialogMessage';
|
||||
import PaymentLegalBlurb from '../../../components/PaymentLegalBlurb';
|
||||
import { TermsAndPrivacy } from '../../../components/TermsAndPrivacy';
|
||||
import { ProviderType } from 'fxa-payments-server/src/lib/PaymentProvider';
|
||||
|
||||
import PlanUpgradeDetails from './PlanUpgradeDetails';
|
||||
import Header from '../../../components/Header';
|
||||
|
@ -69,6 +70,8 @@ export const SubscriptionUpgrade = ({
|
|||
|
||||
const inProgress = updateSubscriptionPlanStatus.loading;
|
||||
|
||||
const paymentProvider: ProviderType | undefined = customer?.payment_provider;
|
||||
|
||||
useEffect(() => {
|
||||
Amplitude.updateSubscriptionPlanMounted(selectedPlan);
|
||||
}, [selectedPlan]);
|
||||
|
@ -236,7 +239,7 @@ export const SubscriptionUpgrade = ({
|
|||
</SubmitButton>
|
||||
</div>
|
||||
|
||||
<PaymentLegalBlurb />
|
||||
<PaymentLegalBlurb provider={paymentProvider} />
|
||||
<TermsAndPrivacy plan={selectedPlan} />
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -15,10 +15,33 @@ import { PAYPAL_CUSTOMER } from '../../lib/mock-data';
|
|||
|
||||
function init() {
|
||||
storiesOf('routes/Product', module)
|
||||
.add('subscribing with existing account', () => <ProductRoute />)
|
||||
.add('subscribing with new account', () => (
|
||||
<ProductRoute queryParams={{ activated: '1' }} />
|
||||
))
|
||||
.add('subscribing with existing Stripe account', () => (
|
||||
<ProductRoute
|
||||
routeProps={{
|
||||
...MOCK_PROPS,
|
||||
customer: {
|
||||
loading: false,
|
||||
error: null,
|
||||
result: CUSTOMER,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))
|
||||
.add('subscribing with existing PayPal account', () => (
|
||||
<ProductRoute
|
||||
routeProps={{
|
||||
...MOCK_PROPS,
|
||||
customer: {
|
||||
loading: false,
|
||||
error: null,
|
||||
result: PAYPAL_CUSTOMER,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))
|
||||
.add('success with Stripe', () => (
|
||||
<ProductRoute
|
||||
routeProps={{
|
||||
|
|
|
@ -503,6 +503,7 @@ describe('routes/Subscriptions', () => {
|
|||
product_metadata: {
|
||||
...MOCK_PLANS[1].product_metadata,
|
||||
webIconURL: null,
|
||||
webIconBackground: null,
|
||||
},
|
||||
},
|
||||
...MOCK_PLANS.slice(2),
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ProviderType } from '../lib/PaymentProvider';
|
||||
|
||||
export type {
|
||||
PlanInterval,
|
||||
RawMetadata,
|
||||
|
@ -73,7 +75,7 @@ export type Customer = {
|
|||
exp_month?: string;
|
||||
exp_year?: string;
|
||||
last4?: string;
|
||||
payment_provider?: string;
|
||||
payment_provider?: ProviderType;
|
||||
payment_type?: string;
|
||||
subscriptions: Array<CustomerSubscription>;
|
||||
};
|
||||
|
|
Загрузка…
Ссылка в новой задаче