зеркало из https://github.com/mozilla/fxa.git
feat(payments): update PlanUpgradeDetails content
Because: * We need to show the customer what is owed and when (i.e. proration) on checkout. This commit: * Updates PlanUpgradeDetails component and invoice preview to include proration amount. * Adds a helper function to formats for only plan intervals. * Shows updated content when useStripeImmediatelyInvoice flag is set to true. * Updates tests and stories when aforementioned flag is set to true, where applicable. Closes FXA-6878
This commit is contained in:
Родитель
475e9fb5b3
Коммит
2598733ed9
|
@ -107,7 +107,7 @@ executors:
|
|||
parameters:
|
||||
resource_class:
|
||||
type: string
|
||||
default: large
|
||||
default: xlarge
|
||||
resource_class: << parameters.resource_class >>
|
||||
docker:
|
||||
- image: mozilla/fxa-circleci:ci-test-runner
|
||||
|
@ -890,7 +890,7 @@ workflows:
|
|||
production_smoke_tests:
|
||||
when: << pipeline.parameters.enable_production_smoke_tests >>
|
||||
jobs:
|
||||
# Note that we removed content server tests as it runs on Stage only
|
||||
# Note that we removed content server tests as it runs on Stage only
|
||||
- smoke-tests:
|
||||
name: Smoke Test Production - Playwright
|
||||
project: production
|
||||
|
|
|
@ -695,6 +695,7 @@ export class StripeHelper extends StripeHelperBase {
|
|||
this.config.subscriptions.stripeInvoiceImmediately
|
||||
) {
|
||||
try {
|
||||
requestObject.subscription_proration_behavior = 'always_invoice';
|
||||
requestObject.subscription_proration_date = Math.floor(
|
||||
Date.now() / 1000
|
||||
);
|
||||
|
|
|
@ -2123,6 +2123,7 @@ describe('#integration - StripeHelper', () => {
|
|||
tax_exempt: 'none',
|
||||
shipping: undefined,
|
||||
},
|
||||
subscription_proration_behavior: 'always_invoice',
|
||||
subscription: customer1.subscriptions?.data[0].id,
|
||||
subscription_proration_date: 1,
|
||||
subscription_items: [
|
||||
|
|
|
@ -267,6 +267,33 @@ export const PlanDetails = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!!priceAmounts.discountAmount && (
|
||||
<div className="plan-details-item">
|
||||
<Localized id="coupon-promo-code">
|
||||
<div>Promo Code</div>
|
||||
</Localized>
|
||||
|
||||
<Localized
|
||||
id="coupon-amount"
|
||||
attrs={{ title: true }}
|
||||
vars={{
|
||||
amount: getLocalizedCurrency(
|
||||
priceAmounts.discountAmount,
|
||||
currency
|
||||
),
|
||||
intervalCount: interval_count,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{`- ${getLocalizedCurrencyString(
|
||||
priceAmounts.discountAmount,
|
||||
currency
|
||||
)}`}
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exclusiveTaxRates.length === 1 && (
|
||||
<div className="plan-details-item">
|
||||
<Localized id="plan-details-tax">
|
||||
|
@ -317,33 +344,6 @@ export const PlanDetails = ({
|
|||
</Localized>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!!priceAmounts.discountAmount && (
|
||||
<div className="plan-details-item">
|
||||
<Localized id="coupon-promo-code">
|
||||
<div>Promo Code</div>
|
||||
</Localized>
|
||||
|
||||
<Localized
|
||||
id={`coupon-amount`}
|
||||
attrs={{ title: true }}
|
||||
vars={{
|
||||
amount: getLocalizedCurrency(
|
||||
priceAmounts.discountAmount,
|
||||
currency
|
||||
),
|
||||
intervalCount: interval_count,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{`- ${getLocalizedCurrencyString(
|
||||
priceAmounts.discountAmount,
|
||||
currency
|
||||
)}`}
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 pb-6">
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import {
|
||||
formatPlanInterval,
|
||||
formatPriceAmount,
|
||||
getLocalizedCurrency,
|
||||
getLocalizedCurrencyString,
|
||||
getLocalizedDate,
|
||||
getLocalizedDateString,
|
||||
} from './formats';
|
||||
import { Plan } from 'fxa-shared/subscriptions/types';
|
||||
import { MOCK_PLANS } from './test-utils';
|
||||
|
||||
describe('format.ts', () => {
|
||||
describe('Currency Formatting', () => {
|
||||
|
@ -113,4 +116,78 @@ describe('format.ts', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan Details Formatting', () => {
|
||||
// test plans
|
||||
const MOCK_PLAN_1: Plan = {
|
||||
...MOCK_PLANS[0],
|
||||
interval: 'day',
|
||||
interval_count: 1,
|
||||
};
|
||||
const MOCK_PLAN_2: Plan = {
|
||||
...MOCK_PLANS[0],
|
||||
interval: 'month',
|
||||
interval_count: 2,
|
||||
};
|
||||
const MOCK_PLAN_3: Plan = {
|
||||
...MOCK_PLANS[0],
|
||||
interval: 'year',
|
||||
};
|
||||
|
||||
describe('returns plural of plan interval', () => {
|
||||
it('returns correctly formatted interval when intervalCount is set to a number other than 1', () => {
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: MOCK_PLAN_2.interval,
|
||||
intervalCount: MOCK_PLAN_2.interval_count,
|
||||
});
|
||||
|
||||
expect(formattedInterval).toEqual('months');
|
||||
});
|
||||
|
||||
it('returns correctly formatted interval when intervalCount is undefined', () => {
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: MOCK_PLAN_3.interval,
|
||||
});
|
||||
|
||||
expect(formattedInterval).toEqual('years');
|
||||
});
|
||||
|
||||
it('does not return plural of interval when intervalCount is set to 1', () => {
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: MOCK_PLAN_1.interval,
|
||||
intervalCount: MOCK_PLAN_1.interval_count,
|
||||
});
|
||||
|
||||
expect(formattedInterval).not.toEqual('days');
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns adverb of plan interval', () => {
|
||||
it('returns a correctly formatted string when intervalCount is equal to 1', () => {
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: MOCK_PLAN_1.interval,
|
||||
intervalCount: MOCK_PLAN_1.interval_count,
|
||||
});
|
||||
|
||||
expect(formattedInterval).toEqual('daily');
|
||||
});
|
||||
|
||||
it('does not return correctly formatted string when intervalCount is undefined', () => {
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: MOCK_PLAN_3.interval,
|
||||
});
|
||||
|
||||
expect(formattedInterval).not.toEqual('yearly');
|
||||
});
|
||||
|
||||
it('does not return correctly formatted string when intervalCount is set to a number other than 1', () => {
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: MOCK_PLAN_2.interval,
|
||||
intervalCount: MOCK_PLAN_2.interval_count,
|
||||
});
|
||||
|
||||
expect(formattedInterval).not.toEqual('monthly');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -171,6 +171,35 @@ export function formatPlanPricing(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to format a Stripe plan's interval
|
||||
* Examples:
|
||||
* 'daily' or 'days'
|
||||
* 'weekly' or 'weeks'
|
||||
* 'monthly' or 'months'
|
||||
* 'yearly' or 'years'
|
||||
* @param interval
|
||||
* @param intervalCount
|
||||
*/
|
||||
export function formatPlanInterval({
|
||||
interval,
|
||||
intervalCount,
|
||||
}: {
|
||||
interval: PlanInterval;
|
||||
intervalCount?: number;
|
||||
}): string {
|
||||
switch (interval) {
|
||||
case 'day':
|
||||
return intervalCount === 1 ? 'daily' : 'days';
|
||||
case 'week':
|
||||
return intervalCount === 1 ? 'weekly' : 'weeks';
|
||||
case 'month':
|
||||
return intervalCount === 1 ? 'monthly' : 'months';
|
||||
case 'year':
|
||||
return intervalCount === 1 ? 'yearly' : 'years';
|
||||
}
|
||||
}
|
||||
|
||||
export const legalDocsRedirectUrl = (docUrl: string): string =>
|
||||
`/legal-docs?url=${encodeURI(docUrl)}`;
|
||||
|
||||
|
|
|
@ -907,6 +907,8 @@ export const MOCK_PREVIEW_INVOICE_NO_TAX: FirstInvoicePreview = {
|
|||
},
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
export const MOCK_PREVIEW_INVOICE_AFTER_SUBSCRIPTION: FirstInvoicePreview = {
|
||||
|
@ -926,6 +928,8 @@ export const MOCK_PREVIEW_INVOICE_AFTER_SUBSCRIPTION: FirstInvoicePreview = {
|
|||
},
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
export const MOCK_PREVIEW_INVOICE_WITH_TAX_EXCLUSIVE: FirstInvoicePreview = {
|
||||
|
@ -952,6 +956,8 @@ export const MOCK_PREVIEW_INVOICE_WITH_TAX_EXCLUSIVE: FirstInvoicePreview = {
|
|||
display_name: 'Sales Tax',
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
export const MOCK_PREVIEW_INVOICE_WITH_ZERO_TAX_EXCLUSIVE: FirstInvoicePreview =
|
||||
|
@ -990,6 +996,8 @@ export const MOCK_PREVIEW_INVOICE_WITH_TAX_INCLUSIVE: FirstInvoicePreview = {
|
|||
display_name: 'Sales Tax',
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
export const MOCK_PREVIEW_INVOICE_WITH_TAX_INCLUSIVE_DISCOUNT: FirstInvoicePreview =
|
||||
|
@ -1022,6 +1030,8 @@ export const MOCK_PREVIEW_INVOICE_WITH_TAX_INCLUSIVE_DISCOUNT: FirstInvoicePrevi
|
|||
amount_off: 50,
|
||||
percent_off: null,
|
||||
},
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
export const MOCK_PREVIEW_INVOICE_WITH_TAX_EXCLUSIVE_DISCOUNT: FirstInvoicePreview =
|
||||
|
@ -1054,6 +1064,8 @@ export const MOCK_PREVIEW_INVOICE_WITH_TAX_EXCLUSIVE_DISCOUNT: FirstInvoicePrevi
|
|||
amount_off: 50,
|
||||
percent_off: null,
|
||||
},
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
export const INVOICE_NO_TAX: LatestInvoiceItems = MOCK_PREVIEW_INVOICE_NO_TAX;
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { useContext } from 'react';
|
|||
import { Localized } from '@fluent/react';
|
||||
|
||||
import { AppContext } from '../../../lib/AppContext';
|
||||
|
||||
import { formatPlanInterval } from '../../../lib/formats';
|
||||
import ffLogo from '../../../images/firefox-logo.svg';
|
||||
import { Plan } from '../../../store/types';
|
||||
|
||||
|
@ -28,12 +28,23 @@ export const PlanUpgradeDetails = ({
|
|||
invoicePreview: FirstInvoicePreview;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { config } = useContext(AppContext);
|
||||
const { amount, currency, interval, interval_count } = selectedPlan;
|
||||
const { navigatorLanguages, config } = useContext(AppContext);
|
||||
const { product_name, amount, currency, interval, interval_count } =
|
||||
selectedPlan;
|
||||
const formattedInterval = formatPlanInterval({
|
||||
interval: interval,
|
||||
intervalCount: 1,
|
||||
});
|
||||
const productDetails = uiContentFromProductConfig(
|
||||
selectedPlan,
|
||||
navigatorLanguages,
|
||||
config.featureFlags.useFirestoreProductConfigs
|
||||
);
|
||||
|
||||
const role = isMobile ? undefined : 'complementary';
|
||||
|
||||
const showTax = config.featureFlags.useStripeAutomaticTax;
|
||||
const invoiceImmediately = config.featureFlags.useStripeInvoiceImmediately;
|
||||
|
||||
const exclusiveTaxRates =
|
||||
invoicePreview.tax?.filter(
|
||||
|
@ -42,6 +53,7 @@ export const PlanUpgradeDetails = ({
|
|||
|
||||
const totalAmount = showTax ? invoicePreview.total : amount;
|
||||
const subTotal = invoicePreview.subtotal;
|
||||
const oneTimeCharge = invoicePreview?.one_time_charge;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
@ -65,9 +77,26 @@ export const PlanUpgradeDetails = ({
|
|||
{showTax && !!subTotal && !!exclusiveTaxRates.length && (
|
||||
<>
|
||||
<div className="plan-details-item">
|
||||
<Localized id="plan-details-list-price">
|
||||
<div>List Price</div>
|
||||
</Localized>
|
||||
{invoiceImmediately ? (
|
||||
<Localized
|
||||
id={`sub-update-new-plan-${formattedInterval}`}
|
||||
vars={{
|
||||
productName: productDetails.name || product_name,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{productDetails.name || product_name} (
|
||||
{formattedInterval.replace(/\w/, (firstLetter) =>
|
||||
firstLetter.toUpperCase()
|
||||
)}
|
||||
)
|
||||
</div>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="plan-details-list-price">
|
||||
<div>List Price</div>
|
||||
</Localized>
|
||||
)}
|
||||
|
||||
<PriceDetails
|
||||
total={subTotal}
|
||||
|
@ -120,6 +149,25 @@ export const PlanUpgradeDetails = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoiceImmediately && oneTimeCharge && (
|
||||
<>
|
||||
<hr className="m-0 my-5 unit-row-hr" />
|
||||
|
||||
<div className="plan-details-item font-semibold mt-5">
|
||||
<Localized id="sub-update-prorated-upgrade">
|
||||
<div className="total-label">Prorated Upgrade</div>
|
||||
</Localized>
|
||||
|
||||
<PriceDetails
|
||||
total={oneTimeCharge}
|
||||
currency={currency}
|
||||
className="total-price"
|
||||
dataTestId="prorated-amount"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
@ -14,3 +14,16 @@ sub-change-submit = Confirm change
|
|||
sub-update-current-plan-label = Current plan
|
||||
sub-update-new-plan-label = New plan
|
||||
sub-update-total-label = New total
|
||||
sub-update-prorated-upgrade = Prorated Upgrade
|
||||
|
||||
## Checkout line item for subscription plan change listing the product name and frequency of payment
|
||||
## For example, a Mozilla VPN subscription charged monthly would appear as: Mozilla VPN (Monthly)
|
||||
## Variables:
|
||||
## $productName (String) - Name of the upgraded product (e.g. Mozilla VPN)
|
||||
|
||||
sub-update-new-plan-daily = { $productName } (Daily)
|
||||
sub-update-new-plan-weekly = { $productName } (Weekly)
|
||||
sub-update-new-plan-monthly = { $productName } (Monthly)
|
||||
sub-update-new-plan-yearly = { $productName } (Yearly)
|
||||
|
||||
##
|
||||
|
|
|
@ -50,6 +50,8 @@ const invoicePreviewNoTax: FirstInvoicePreview = {
|
|||
},
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
const invoicePreviewInclusiveTax: FirstInvoicePreview = {
|
||||
|
@ -65,6 +67,8 @@ const invoicePreviewInclusiveTax: FirstInvoicePreview = {
|
|||
display_name: 'Sales Tax',
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
const invoicePreviewExclusiveTax: FirstInvoicePreview = {
|
||||
|
@ -80,6 +84,8 @@ const invoicePreviewExclusiveTax: FirstInvoicePreview = {
|
|||
display_name: 'Sales Tax',
|
||||
},
|
||||
],
|
||||
prorated_amount: -833,
|
||||
one_time_charge: 1337,
|
||||
};
|
||||
|
||||
const invoicePreviewExclusiveTaxMulti: FirstInvoicePreview = {
|
||||
|
|
|
@ -101,6 +101,22 @@ async function rendersAsExpected(
|
|||
expect(queryByTestId('sub-update-acknowledgment')).toHaveTextContent(
|
||||
expectedInvoiceDate
|
||||
);
|
||||
expect(
|
||||
queryByTestId(`sub-update-new-plan-${selectedPlan.interval}`)
|
||||
).toBeInTheDocument();
|
||||
|
||||
if (
|
||||
!!invoicePreview.one_time_charge &&
|
||||
invoicePreview.one_time_charge > 0
|
||||
) {
|
||||
expect(queryByTestId('sub-update-prorated-upgrade')).toBeInTheDocument();
|
||||
expect(queryByTestId('prorated-amount')).toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
queryByTestId('sub-update-prorated-upgrade')
|
||||
).not.toBeInTheDocument();
|
||||
expect(queryByTestId('prorated-amount')).not.toBeInTheDocument();
|
||||
}
|
||||
} else {
|
||||
expect(queryByTestId('sub-update-copy')).toHaveTextContent(
|
||||
expectedInvoiceDate
|
||||
|
|
|
@ -38,6 +38,8 @@ export interface FirstInvoicePreview {
|
|||
total_excluding_tax: number | null;
|
||||
tax?: InvoiceTax[];
|
||||
discount?: InvoiceDiscount;
|
||||
one_time_charge?: number;
|
||||
prorated_amount?: number;
|
||||
}
|
||||
|
||||
export const firstInvoicePreviewSchema = joi.object({
|
||||
|
|
Загрузка…
Ссылка в новой задаче