feat(payments): Gate SubscriptionCreate screen based on new/returning PayPal customers.

Also simplify PaymentLegalBlurb component on Checkout screens.
This commit is contained in:
Bianca Danforth 2021-03-03 13:09:15 -06:00
Родитель a75f925682
Коммит 2c03f3342f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 2C96DD7DB2A2D72D
15 изменённых файлов: 172 добавлений и 66 удалений

Просмотреть файл

@ -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>;
};