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:
Meghan Sardesai 2023-07-04 18:24:01 -04:00
Родитель 475e9fb5b3
Коммит 2598733ed9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9A46BEBC2E8A3934
12 изменённых файлов: 240 добавлений и 35 удалений

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

@ -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({