From 404edd7760ab7313ec528c5290203c5f10bd8902 Mon Sep 17 00:00:00 2001 From: Christos Date: Wed, 10 Jan 2024 13:12:54 +0200 Subject: [PATCH] Plans 2023: Add pricing to Plans data-store and use in plans-grid (take 4) (#86227) --- .../use-pricing-meta-for-grid-plans.ts | 221 +++++++++--------- .../test/use-pricing-meta-for-grid-plans.ts | 140 +++++------ .../src/plans/hooks/test/use-intro-offers.ts | 7 +- .../src/plans/hooks/use-intro-offers.ts | 2 +- packages/data-stores/src/plans/index.ts | 1 + .../src/plans/mock/next/store/plans.ts | 78 +++++-- .../src/plans/queries/use-plans.ts | 48 ++-- .../src/plans/queries/use-site-plans.ts | 26 ++- packages/data-stores/src/plans/types.ts | 84 ++++--- 9 files changed, 369 insertions(+), 238 deletions(-) diff --git a/client/my-sites/plans-features-main/hooks/data-store/use-pricing-meta-for-grid-plans.ts b/client/my-sites/plans-features-main/hooks/data-store/use-pricing-meta-for-grid-plans.ts index a5fe1cde26c571..672bbbc474fc80 100644 --- a/client/my-sites/plans-features-main/hooks/data-store/use-pricing-meta-for-grid-plans.ts +++ b/client/my-sites/plans-features-main/hooks/data-store/use-pricing-meta-for-grid-plans.ts @@ -7,9 +7,6 @@ import { import { Plans, WpcomPlansUI, Purchases } from '@automattic/data-stores'; import { useSelect } from '@wordpress/data'; import { useSelector } from 'react-redux'; -import { getPlanPrices } from 'calypso/state/plans/selectors'; -import { PlanPrices } from 'calypso/state/plans/types'; -import { getSitePlanRawPrice } from 'calypso/state/sites/plans/selectors'; import getSelectedSiteId from 'calypso/state/ui/selectors/get-selected-site-id'; import useCheckPlanAvailabilityForPurchase from '../use-check-plan-availability-for-purchase'; import type { AddOnMeta } from '@automattic/data-stores'; @@ -17,7 +14,6 @@ import type { UsePricingMetaForGridPlans, PricingMetaForGridPlan, } from 'calypso/my-sites/plans-grid/hooks/npm-ready/data-store/use-grid-plans'; -import type { IAppState } from 'calypso/state/types'; interface Props { planSlugs: PlanSlug[]; @@ -25,19 +21,8 @@ interface Props { storageAddOns?: ( AddOnMeta | null )[] | null; } -function getTotalPrices( planPrices: PlanPrices, addOnPrice = 0 ): PlanPrices { - const totalPrices = { ...planPrices }; - let key: keyof PlanPrices; - - for ( key in totalPrices ) { - const price = totalPrices[ key ]; - - if ( price !== null ) { - totalPrices[ key ] = price + addOnPrice; - } - } - - return totalPrices; +function getTotalPrice( planPrice: number | null | undefined, addOnPrice = 0 ): number | null { + return null !== planPrice && undefined !== planPrice ? planPrice + addOnPrice : null; } /* @@ -45,7 +30,6 @@ function getTotalPrices( planPrices: PlanPrices, addOnPrice = 0 ): PlanPrices { * - see PricingMetaForGridPlan type for details * - will migrate to data-store once dependencies are resolved (when site & plans data-stores more complete) */ - const usePricingMetaForGridPlans: UsePricingMetaForGridPlans = ( { planSlugs, withoutProRatedCredits = false, @@ -56,10 +40,10 @@ const usePricingMetaForGridPlans: UsePricingMetaForGridPlans = ( { // TODO: pass this in as a prop to uncouple the dependency const planAvailabilityForPurchase = useCheckPlanAvailabilityForPurchase( { planSlugs } ); - // pricedAPIPlans - should have a definition for all plans, being the main source of API data - const pricedAPIPlans = Plans.usePlans(); - // pricedAPISitePlans - unclear if all plans are included - const pricedAPISitePlans = Plans.useSitePlans( { siteId: selectedSiteId } ); + // plans - should have a definition for all plans, being the main source of API data + const plans = Plans.usePlans(); + // sitePlans - unclear if all plans are included + const sitePlans = Plans.useSitePlans( { siteId: selectedSiteId } ); const currentPlan = Plans.useCurrentPlan( { siteId: selectedSiteId } ); const introOffers = Plans.useIntroOffers( { siteId: selectedSiteId } ); const purchasedPlan = Purchases.useSitePurchaseById( { @@ -67,13 +51,33 @@ const usePricingMetaForGridPlans: UsePricingMetaForGridPlans = ( { purchaseId: currentPlan?.purchaseId, } ); - const selectedStorageOptions = useSelect( ( select ) => { - return select( WpcomPlansUI.store ).getSelectedStorageOptions(); - }, [] ); + const selectedStorageOptions = useSelect( + ( select ) => select( WpcomPlansUI.store ).getSelectedStorageOptions(), + [] + ); - const planPrices = useSelector( ( state: IAppState ) => { - return planSlugs.reduce( - ( acc, planSlug ) => { + let planPrices: + | { + [ planSlug in PlanSlug ]?: { + originalPrice: Plans.PlanPricing[ 'originalPrice' ]; + discountedPrice: Plans.PlanPricing[ 'discountedPrice' ]; + }; + } + | null = null; + + if ( ( selectedSiteId && sitePlans.isLoading ) || plans.isLoading ) { + /** + * Null until all data is ready, at least in initial state. + * - For now a simple loader is shown until these are resolved + * - `sitePlans` being a dependent query will only get fetching status when enabled (when `siteId` exists) + */ + planPrices = null; + } else { + /** + * Projected prices as needed for the plan-grid UI. + */ + planPrices = Object.fromEntries( + planSlugs.map( ( planSlug ) => { const availableForPurchase = planAvailabilityForPurchase[ planSlug ]; const selectedStorageOption = selectedStorageOptions?.[ planSlug ]; const selectedStorageAddOn = storageAddOns?.find( ( addOn ) => { @@ -86,33 +90,35 @@ const usePricingMetaForGridPlans: UsePricingMetaForGridPlans = ( { const storageAddOnPriceMonthly = storageAddOnPrices?.monthlyPrice || 0; const storageAddOnPriceYearly = storageAddOnPrices?.yearlyPrice || 0; - const planPricesMonthly = getPlanPrices( state, { - planSlug, - siteId: selectedSiteId || null, - returnMonthly: true, - returnSmallestUnit: true, - } ); - const planPricesFull = getPlanPrices( state, { - planSlug, - siteId: selectedSiteId || null, - returnMonthly: false, - returnSmallestUnit: true, - } ); - const totalPricesMonthly = getTotalPrices( planPricesMonthly, storageAddOnPriceMonthly ); - const totalPricesFull = getTotalPrices( planPricesFull, storageAddOnPriceYearly ); + const plan = plans.data?.[ planSlug ]; + const sitePlan = sitePlans.data?.[ planSlug ]; + + /** + * 0. No plan or sitePlan (when selected site exists): planSlug is for a priceless plan. + * TODO clk: the condition on `.pricing` here needs investigation. There should be a pricing object for all returned API plans. + */ + if ( ! plan?.pricing || ( selectedSiteId && ! sitePlan?.pricing ) ) { + return [ + planSlug, + { + originalPrice: { + monthly: null, + full: null, + }, + discountedPrice: { + monthly: null, + full: null, + }, + }, + ]; + } /** * 1. Original prices only for current site's plan. */ if ( selectedSiteId && currentPlan?.planSlug === planSlug ) { - let monthlyPrice = getSitePlanRawPrice( state, selectedSiteId, planSlug, { - returnMonthly: true, - returnSmallestUnit: true, - } ); - let fullPrice = getSitePlanRawPrice( state, selectedSiteId, planSlug, { - returnMonthly: false, - returnSmallestUnit: true, - } ); + let monthlyPrice = sitePlan?.pricing.originalPrice.monthly; + let fullPrice = sitePlan?.pricing.originalPrice.full; /** * Ensure the spotlight plan shows the price with which the plans was purchased. @@ -130,92 +136,93 @@ const usePricingMetaForGridPlans: UsePricingMetaForGridPlans = ( { } } - return { - ...acc, - [ planSlug ]: { + return [ + planSlug, + { originalPrice: { - monthly: monthlyPrice ? monthlyPrice + storageAddOnPriceMonthly : null, - full: fullPrice ? fullPrice + storageAddOnPriceYearly : null, + monthly: getTotalPrice( monthlyPrice ?? null, storageAddOnPriceMonthly ), + full: getTotalPrice( fullPrice ?? null, storageAddOnPriceYearly ), }, discountedPrice: { monthly: null, full: null, }, }, - }; + ]; } /** * 2. Original and Discounted prices for plan available for purchase. + * - If prorated credits are needed, then pick the discounted price from sitePlan (site context) if one exists. */ if ( availableForPurchase ) { - return { - ...acc, - [ planSlug ]: { - originalPrice: { - monthly: totalPricesMonthly.rawPrice, - full: totalPricesFull.rawPrice, - }, - discountedPrice: { - monthly: withoutProRatedCredits - ? totalPricesMonthly.discountedRawPrice - : totalPricesMonthly.planDiscountedRawPrice || - totalPricesMonthly.discountedRawPrice, - full: withoutProRatedCredits - ? totalPricesFull.discountedRawPrice - : totalPricesFull.planDiscountedRawPrice || totalPricesFull.discountedRawPrice, - }, - }, + const originalPrice = { + monthly: getTotalPrice( plan?.pricing.originalPrice.monthly, storageAddOnPriceMonthly ), + full: getTotalPrice( plan?.pricing.originalPrice.full, storageAddOnPriceYearly ), + }; + const discountedPrice = { + monthly: + sitePlan && ! withoutProRatedCredits + ? getTotalPrice( + sitePlan.pricing.discountedPrice.monthly, + storageAddOnPriceMonthly + ) + : getTotalPrice( plan?.pricing.discountedPrice.monthly, storageAddOnPriceMonthly ), + full: + sitePlan && ! withoutProRatedCredits + ? getTotalPrice( sitePlan.pricing.discountedPrice.full, storageAddOnPriceYearly ) + : getTotalPrice( plan?.pricing.discountedPrice.full, storageAddOnPriceYearly ), }; + + return [ + planSlug, + { + originalPrice, + discountedPrice, + }, + ]; } /** * 3. Original prices only for plan not available for purchase. */ - return { - ...acc, - [ planSlug ]: { + return [ + planSlug, + { originalPrice: { - monthly: totalPricesMonthly.rawPrice, - full: totalPricesFull.rawPrice, + monthly: getTotalPrice( + plan?.pricing.originalPrice.monthly, + storageAddOnPriceMonthly + ), + full: getTotalPrice( plan?.pricing.originalPrice.full, storageAddOnPriceYearly ), }, discountedPrice: { monthly: null, full: null, }, }, - }; - }, - {} as { - [ planSlug: string ]: Pick< PricingMetaForGridPlan, 'originalPrice' | 'discountedPrice' >; - } + ]; + } ) ); - } ); - - /* - * Return null until all data is ready, at least in initial state. - * - For now a simple loader is shown until these are resolved - * - We can optimise Error states in the UI / when everything gets ported into data-stores - * - `pricedAPISitePlans` being a dependent query will only get fetching status when enabled (when `siteId` exists) - */ - - if ( ( selectedSiteId && pricedAPISitePlans.isLoading ) || pricedAPIPlans.isLoading ) { - return null; } - return planSlugs.reduce( - ( acc, planSlug ) => ( { - ...acc, - [ planSlug ]: { - originalPrice: planPrices[ planSlug ]?.originalPrice, - discountedPrice: planPrices[ planSlug ]?.discountedPrice, - billingPeriod: pricedAPIPlans.data?.[ planSlug ]?.billPeriod, - currencyCode: pricedAPIPlans.data?.[ planSlug ]?.currencyCode, - expiry: pricedAPISitePlans.data?.[ planSlug ]?.expiry, - introOffer: introOffers?.[ planSlug ], - }, - } ), - {} as { [ planSlug: string ]: PricingMetaForGridPlan } + return ( + // TODO: consider removing the null return + planPrices && + planSlugs.reduce( + ( acc, planSlug ) => ( { + ...acc, + [ planSlug ]: { + originalPrice: planPrices?.[ planSlug ]?.originalPrice, + discountedPrice: planPrices?.[ planSlug ]?.discountedPrice, + billingPeriod: plans.data?.[ planSlug ]?.pricing.billPeriod, + currencyCode: plans.data?.[ planSlug ]?.pricing.currencyCode, + expiry: sitePlans.data?.[ planSlug ]?.expiry, + introOffer: introOffers?.[ planSlug ], + }, + } ), + {} as { [ planSlug in PlanSlug ]?: PricingMetaForGridPlan } + ) ); }; diff --git a/client/my-sites/plans-features-main/hooks/test/use-pricing-meta-for-grid-plans.ts b/client/my-sites/plans-features-main/hooks/test/use-pricing-meta-for-grid-plans.ts index 8a35404db29262..47cd8b6b4079f8 100644 --- a/client/my-sites/plans-features-main/hooks/test/use-pricing-meta-for-grid-plans.ts +++ b/client/my-sites/plans-features-main/hooks/test/use-pricing-meta-for-grid-plans.ts @@ -10,12 +10,6 @@ jest.mock( 'react-redux', () => ( { useSelector: ( selector ) => selector(), } ) ); jest.mock( '@wordpress/data' ); -jest.mock( 'calypso/state/plans/selectors', () => ( { - getPlanPrices: jest.fn(), -} ) ); -jest.mock( 'calypso/state/sites/plans/selectors', () => ( { - getSitePlanRawPrice: jest.fn(), -} ) ); jest.mock( 'calypso/state/ui/selectors/get-selected-site-id', () => jest.fn() ); jest.mock( '@automattic/data-stores', () => ( { Plans: { @@ -32,8 +26,6 @@ jest.mock( '../use-check-plan-availability-for-purchase', () => jest.fn() ); import { PLAN_PERSONAL, PLAN_PREMIUM } from '@automattic/calypso-products'; import { Plans, Purchases } from '@automattic/data-stores'; -import { getPlanPrices } from 'calypso/state/plans/selectors'; -import { getSitePlanRawPrice } from 'calypso/state/sites/plans/selectors'; import getSelectedSiteId from 'calypso/state/ui/selectors/get-selected-site-id'; import usePricingMetaForGridPlans from '../data-store/use-pricing-meta-for-grid-plans'; import useCheckPlanAvailabilityForPurchase from '../use-check-plan-availability-for-purchase'; @@ -41,21 +33,47 @@ import useCheckPlanAvailabilityForPurchase from '../use-check-plan-availability- describe( 'usePricingMetaForGridPlans', () => { beforeEach( () => { jest.clearAllMocks(); - Plans.useSitePlans.mockImplementation( () => ( { - isLoading: false, - data: null, - } ) ); + getSelectedSiteId.mockImplementation( () => 100 ); Purchases.useSitePurchaseById.mockImplementation( () => undefined ); + Plans.useIntroOffers.mockImplementation( () => ( { + [ PLAN_PREMIUM ]: null, + } ) ); Plans.usePlans.mockImplementation( () => ( { isLoading: false, data: { [ PLAN_PREMIUM ]: { - billPeriod: 365, - currencyCode: 'USD', + pricing: { + billPeriod: 365, + currencyCode: 'USD', + originalPrice: { + full: 500, + monthly: 500, + }, + discountedPrice: { + full: 400, + monthly: 400, + }, + }, }, - [ PLAN_PERSONAL ]: { - billPeriod: 365, - currencyCode: 'USD', + }, + } ) ); + Plans.useSitePlans.mockImplementation( () => ( { + isLoading: false, + data: { + [ PLAN_PREMIUM ]: { + expiry: null, + pricing: { + billPeriod: 365, + currencyCode: 'USD', + originalPrice: { + full: 500, + monthly: 500, + }, + discountedPrice: { + full: 250, + monthly: 250, + }, + }, }, }, } ) ); @@ -67,9 +85,6 @@ describe( 'usePricingMetaForGridPlans', () => { productSlug: PLAN_PREMIUM, planSlug: PLAN_PREMIUM, } ) ); - getSelectedSiteId.mockImplementation( () => 100 ); - getPlanPrices.mockImplementation( () => null ); - getSitePlanRawPrice.mockImplementation( () => 300 ); useCheckPlanAvailabilityForPurchase.mockImplementation( () => { return { [ PLAN_PREMIUM ]: true, @@ -85,8 +100,8 @@ describe( 'usePricingMetaForGridPlans', () => { const expectedPricingMeta = { [ PLAN_PREMIUM ]: { originalPrice: { - full: 300, - monthly: 300, + full: 500, + monthly: 500, }, discountedPrice: { full: null, @@ -94,6 +109,8 @@ describe( 'usePricingMetaForGridPlans', () => { }, billingPeriod: 365, currencyCode: 'USD', + expiry: null, + introOffer: null, }, }; @@ -105,30 +122,23 @@ describe( 'usePricingMetaForGridPlans', () => { productSlug: PLAN_PREMIUM, planSlug: PLAN_PREMIUM, } ) ); - getSelectedSiteId.mockImplementation( () => 100 ); - getPlanPrices.mockImplementation( () => ( { - rawPrice: 300, - discountedRawPrice: 200, - planDiscountedRawPrice: 100, - } ) ); - getSitePlanRawPrice.mockImplementation( () => null ); useCheckPlanAvailabilityForPurchase.mockImplementation( () => { return { [ PLAN_PREMIUM ]: false, - [ PLAN_PERSONAL ]: false, }; } ); const pricingMeta = usePricingMetaForGridPlans( { - planSlugs: [ PLAN_PERSONAL ], + planSlugs: [ PLAN_PREMIUM ], withoutProRatedCredits: false, + storageAddOns: null, } ); const expectedPricingMeta = { - [ PLAN_PERSONAL ]: { + [ PLAN_PREMIUM ]: { originalPrice: { - full: 300, - monthly: 300, + full: 500, + monthly: 500, }, discountedPrice: { full: null, @@ -136,67 +146,57 @@ describe( 'usePricingMetaForGridPlans', () => { }, billingPeriod: 365, currencyCode: 'USD', + expiry: null, + introOffer: null, }, }; expect( pricingMeta ).toEqual( expectedPricingMeta ); } ); - it( 'should return the original price and discounted price without pro-rated credits when withoutProRatedCredits is true', () => { + it( 'should return the original price and discounted price (prorated) when withoutProRatedCredits is false', () => { Plans.useCurrentPlan.mockImplementation( () => ( { - productSlug: PLAN_PREMIUM, - planSlug: PLAN_PREMIUM, + productSlug: PLAN_PERSONAL, + planSlug: PLAN_PERSONAL, } ) ); - getSelectedSiteId.mockImplementation( () => 100 ); - getPlanPrices.mockImplementation( () => ( { - rawPrice: 300, - discountedRawPrice: 200, - planDiscountedRawPrice: 100, - } ) ); - getSitePlanRawPrice.mockImplementation( () => null ); useCheckPlanAvailabilityForPurchase.mockImplementation( () => { return { - [ PLAN_PREMIUM ]: true, [ PLAN_PERSONAL ]: true, + [ PLAN_PREMIUM ]: true, }; } ); const pricingMeta = usePricingMetaForGridPlans( { - planSlugs: [ PLAN_PERSONAL ], - withoutProRatedCredits: true, + planSlugs: [ PLAN_PREMIUM ], + withoutProRatedCredits: false, storageAddOns: null, } ); const expectedPricingMeta = { - [ PLAN_PERSONAL ]: { + [ PLAN_PREMIUM ]: { originalPrice: { - full: 300, - monthly: 300, + full: 500, + monthly: 500, }, discountedPrice: { - full: 200, - monthly: 200, + full: 250, + monthly: 250, }, billingPeriod: 365, currencyCode: 'USD', + expiry: null, + introOffer: null, }, }; expect( pricingMeta ).toEqual( expectedPricingMeta ); } ); - it( 'should return the original price and discounted price with pro-rated credits when withoutProRatedCredits is false', () => { + it( 'should return the original price and discounted price (not prorated) when withoutProRatedCredits is true', () => { Plans.useCurrentPlan.mockImplementation( () => ( { - productSlug: PLAN_PREMIUM, - planSlug: PLAN_PREMIUM, - } ) ); - getSelectedSiteId.mockImplementation( () => 100 ); - getPlanPrices.mockImplementation( () => ( { - rawPrice: 300, - discountedRawPrice: 200, - planDiscountedRawPrice: 100, + productSlug: PLAN_PERSONAL, + planSlug: PLAN_PERSONAL, } ) ); - getSitePlanRawPrice.mockImplementation( () => null ); useCheckPlanAvailabilityForPurchase.mockImplementation( () => { return { [ PLAN_PREMIUM ]: true, @@ -205,23 +205,25 @@ describe( 'usePricingMetaForGridPlans', () => { } ); const pricingMeta = usePricingMetaForGridPlans( { - planSlugs: [ PLAN_PERSONAL ], - withoutProRatedCredits: false, + planSlugs: [ PLAN_PREMIUM ], + withoutProRatedCredits: true, storageAddOns: null, } ); const expectedPricingMeta = { - [ PLAN_PERSONAL ]: { + [ PLAN_PREMIUM ]: { originalPrice: { - full: 300, - monthly: 300, + full: 500, + monthly: 500, }, discountedPrice: { - full: 100, - monthly: 100, + full: 400, + monthly: 400, }, billingPeriod: 365, currencyCode: 'USD', + expiry: null, + introOffer: null, }, }; diff --git a/packages/data-stores/src/plans/hooks/test/use-intro-offers.ts b/packages/data-stores/src/plans/hooks/test/use-intro-offers.ts index 2188842d8b0034..d4792f32f91027 100644 --- a/packages/data-stores/src/plans/hooks/test/use-intro-offers.ts +++ b/packages/data-stores/src/plans/hooks/test/use-intro-offers.ts @@ -32,7 +32,7 @@ describe( 'useIntroOffers selector', () => { expect( introOffers ).toEqual( { [ MockData.NEXT_STORE_SITE_PLAN_BUSINESS.planSlug ]: - MockData.NEXT_STORE_SITE_PLAN_BUSINESS.introOffer, + MockData.NEXT_STORE_SITE_PLAN_BUSINESS.pricing.introOffer, } ); } ); @@ -68,7 +68,8 @@ describe( 'useIntroOffers selector', () => { const introOffers = useIntroOffers( { siteId: 1 } ); expect( introOffers ).toEqual( { - [ MockData.NEXT_STORE_PLAN_BUSINESS.planSlug ]: MockData.NEXT_STORE_PLAN_BUSINESS.introOffer, + [ MockData.NEXT_STORE_PLAN_BUSINESS.planSlug ]: + MockData.NEXT_STORE_PLAN_BUSINESS.pricing.introOffer, } ); } ); @@ -90,7 +91,7 @@ describe( 'useIntroOffers selector', () => { expect( introOffers ).toEqual( { [ MockData.NEXT_STORE_PLAN_PERSONAL.planSlug ]: null, [ MockData.NEXT_STORE_SITE_PLAN_BUSINESS.planSlug ]: - MockData.NEXT_STORE_SITE_PLAN_BUSINESS.introOffer, + MockData.NEXT_STORE_SITE_PLAN_BUSINESS.pricing.introOffer, } ); } ); } ); diff --git a/packages/data-stores/src/plans/hooks/use-intro-offers.ts b/packages/data-stores/src/plans/hooks/use-intro-offers.ts index 2bad229949d753..7fc1aefd2c1aae 100644 --- a/packages/data-stores/src/plans/hooks/use-intro-offers.ts +++ b/packages/data-stores/src/plans/hooks/use-intro-offers.ts @@ -33,7 +33,7 @@ const useIntroOffers = ( { siteId }: Props ): IntroOffersIndex | undefined => { return { ...acc, - [ planSlug ]: plan?.introOffer ?? null, + [ planSlug ]: plan?.pricing?.introOffer ?? null, }; }, {} diff --git a/packages/data-stores/src/plans/index.ts b/packages/data-stores/src/plans/index.ts index 14f9e7819d8a6a..2db3c01620ffd0 100644 --- a/packages/data-stores/src/plans/index.ts +++ b/packages/data-stores/src/plans/index.ts @@ -17,6 +17,7 @@ export type { PlanPath, PlanBillingPeriod, PlanSimplifiedFeature, + PlanPricing, } from './types'; /** Queries */ diff --git a/packages/data-stores/src/plans/mock/next/store/plans.ts b/packages/data-stores/src/plans/mock/next/store/plans.ts index d96ce409d1eba4..bfa83d75b79431 100644 --- a/packages/data-stores/src/plans/mock/next/store/plans.ts +++ b/packages/data-stores/src/plans/mock/next/store/plans.ts @@ -8,18 +8,41 @@ export const NEXT_STORE_SITE_PLAN_PERSONAL: SitePlan = { planSlug: 'personal-bundle', productSlug: 'personal-bundle', productId: 1, + pricing: { + currencyCode: 'USD', + introOffer: null, + originalPrice: { + monthly: 400, + full: 4800, + }, + discountedPrice: { + monthly: null, + full: null, + }, + }, }; export const NEXT_STORE_SITE_PLAN_BUSINESS: SitePlan = { planSlug: 'business-bundle', productSlug: 'business-bundle', productId: 2, - introOffer: { - formattedPrice: '$15.00', - rawPrice: 15, - intervalUnit: 'month', - intervalCount: 1, - isOfferComplete: false, + pricing: { + introOffer: { + formattedPrice: '$15.00', + rawPrice: 15, + intervalUnit: 'month', + intervalCount: 1, + isOfferComplete: false, + }, + originalPrice: { + monthly: 2500, + full: 30000, + }, + discountedPrice: { + monthly: null, + full: null, + }, + currencyCode: 'USD', }, }; @@ -37,22 +60,43 @@ export const NEXT_STORE_PLAN_PERSONAL: PlanNext = { productSlug: 'personal-bundle', productId: 1, productNameShort: 'Personal', - billPeriod: -1, - currencyCode: 'USD', + pricing: { + billPeriod: 365, + currencyCode: 'USD', + introOffer: null, + originalPrice: { + monthly: 400, + full: 4800, + }, + discountedPrice: { + monthly: null, + full: null, + }, + }, }; export const NEXT_STORE_PLAN_BUSINESS: PlanNext = { planSlug: 'business-bundle', productSlug: 'business-bundle', productId: 2, - introOffer: { - formattedPrice: '$25.00', - rawPrice: 25, - intervalUnit: 'month', - intervalCount: 1, - isOfferComplete: false, - }, productNameShort: 'Business', - billPeriod: 365, - currencyCode: 'USD', + pricing: { + billPeriod: 365, + currencyCode: 'USD', + introOffer: { + formattedPrice: '$25.00', + rawPrice: 25, + intervalUnit: 'month', + intervalCount: 1, + isOfferComplete: false, + }, + originalPrice: { + monthly: 2500, + full: 30000, + }, + discountedPrice: { + monthly: null, + full: null, + }, + }, }; diff --git a/packages/data-stores/src/plans/queries/use-plans.ts b/packages/data-stores/src/plans/queries/use-plans.ts index 4627a31b2b8a72..36dac327f8a7c7 100644 --- a/packages/data-stores/src/plans/queries/use-plans.ts +++ b/packages/data-stores/src/plans/queries/use-plans.ts @@ -1,3 +1,4 @@ +import { calculateMonthlyPriceForPlan } from '@automattic/calypso-products'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import wpcomRequest from 'wpcom-proxy-request'; import unpackIntroOffer from './lib/unpack-intro-offer'; @@ -16,25 +17,46 @@ function usePlans(): UseQueryResult< PlansIndex > { return useQuery( { queryKey: queryKeys.plans(), - queryFn: async () => { + queryFn: async (): Promise< PlansIndex > => { const data: PricedAPIPlan[] = await wpcomRequest( { path: `/plans`, apiVersion: '1.5', } ); return Object.fromEntries( - data.map( ( plan ) => [ - plan.product_slug, - { - planSlug: plan.product_slug, - productSlug: plan.product_slug, - productId: plan.product_id, - introOffer: unpackIntroOffer( plan ), - productNameShort: plan.product_name_short, - billPeriod: plan.bill_period, - currencyCode: plan.currency_code, - }, - ] ) + data.map( ( plan ) => { + const discountedPriceFull = + plan.orig_cost_integer !== plan.raw_price_integer ? plan.raw_price_integer : null; + + return [ + plan.product_slug, + { + planSlug: plan.product_slug, + productSlug: plan.product_slug, + productId: plan.product_id, + productNameShort: plan.product_name_short, + pricing: { + billPeriod: plan.bill_period, + currencyCode: plan.currency_code, + introOffer: unpackIntroOffer( plan ), + originalPrice: { + monthly: + typeof plan.orig_cost_integer === 'number' + ? calculateMonthlyPriceForPlan( plan.product_slug, plan.orig_cost_integer ) + : null, + full: plan.orig_cost_integer, + }, + discountedPrice: { + monthly: + typeof discountedPriceFull === 'number' + ? calculateMonthlyPriceForPlan( plan.product_slug, discountedPriceFull ) + : null, + full: discountedPriceFull, + }, + }, + }, + ]; + } ) ); }, } ); diff --git a/packages/data-stores/src/plans/queries/use-site-plans.ts b/packages/data-stores/src/plans/queries/use-site-plans.ts index 688e217a5ce934..5271f22ff8bf9b 100644 --- a/packages/data-stores/src/plans/queries/use-site-plans.ts +++ b/packages/data-stores/src/plans/queries/use-site-plans.ts @@ -1,3 +1,4 @@ +import { calculateMonthlyPriceForPlan } from '@automattic/calypso-products'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import wpcomRequest from 'wpcom-proxy-request'; import unpackIntroOffer from './lib/unpack-intro-offer'; @@ -25,7 +26,7 @@ function useSitePlans( { siteId }: Props ): UseQueryResult< SitePlansIndex > { return useQuery( { queryKey: queryKeys.sitePlans( siteId ), - queryFn: async () => { + queryFn: async (): Promise< SitePlansIndex > => { const data: PricedAPISitePlansIndex = await wpcomRequest( { path: `/sites/${ encodeURIComponent( siteId as string ) }/plans`, apiVersion: '1.3', @@ -34,6 +35,10 @@ function useSitePlans( { siteId }: Props ): UseQueryResult< SitePlansIndex > { return Object.fromEntries( Object.keys( data ).map( ( productId ) => { const plan = data[ Number( productId ) ]; + const originalPriceFull = plan.raw_discount_integer + ? plan.raw_price_integer + plan.raw_discount_integer + : plan.raw_price_integer; + const discountedPriceFull = plan.raw_discount_integer ? plan.raw_price_integer : null; return [ plan.product_slug, @@ -41,10 +46,27 @@ function useSitePlans( { siteId }: Props ): UseQueryResult< SitePlansIndex > { planSlug: plan.product_slug, productSlug: plan.product_slug, productId: Number( productId ), - introOffer: unpackIntroOffer( plan ), expiry: plan.expiry, currentPlan: plan.current_plan, purchaseId: plan.id ? Number( plan.id ) : undefined, + pricing: { + currencyCode: plan.currency_code, + introOffer: unpackIntroOffer( plan ), + originalPrice: { + monthly: + typeof originalPriceFull === 'number' + ? calculateMonthlyPriceForPlan( plan.product_slug, originalPriceFull ) + : null, + full: originalPriceFull, + }, + discountedPrice: { + monthly: + typeof discountedPriceFull === 'number' + ? calculateMonthlyPriceForPlan( plan.product_slug, discountedPriceFull ) + : null, + full: discountedPriceFull, + }, + }, }, ]; } ) diff --git a/packages/data-stores/src/plans/types.ts b/packages/data-stores/src/plans/types.ts index 4f342bad00ddbe..25b8d953a320f4 100644 --- a/packages/data-stores/src/plans/types.ts +++ b/packages/data-stores/src/plans/types.ts @@ -68,21 +68,45 @@ export interface PlanIntroductoryOffer { isOfferComplete: boolean; } +export interface PlanPricing { + billPeriod: -1 | ( typeof PERIOD_LIST )[ number ]; + currencyCode: string; + introOffer?: PlanIntroductoryOffer | null; + /** + * This is the original cost as defined for the associated billing plan. + */ + originalPrice: { + monthly: number | null; + full: number | null; + }; + /** + * This is the original cost as defined for the associated billing plan, minus any discounts + * stemming from either currency conversion and/or prorated credits (in the case of Site Plans). + * 1. If a concrete value exists for `Plan` (derived from `usePlans`), + * then it refers to a technical discount due to currency conversion. + * 2. If a concrete value exists for `SitePlan` (derived from `useSitePlans`), + * then it refers to a credit-based discount on the plan price (e.g. from proration). + */ + discountedPrice: { + monthly: number | null; + full: number | null; + }; +} + +export interface SitePlanPricing extends Omit< PlanPricing, 'billPeriod' > {} + export interface SitePlan { /* START: Same SitePlan/PlanNext props */ planSlug: PlanSlugFromProducts; productSlug: PlanSlugFromProducts; productId: number; - introOffer?: PlanIntroductoryOffer | null; + pricing: SitePlanPricing; /* END: Same SitePlan/PlanNext props */ - currentPlan?: boolean; - /** * This value is only returned for the current plan on the site. */ expiry?: string; - /** * This is only set when `currentPlan` is true (so for the current plan on the site). * It is sent through as `id` from the endpoint and remapped here to avoid confusion e.g. with `productId`. @@ -99,12 +123,9 @@ export interface PlanNext { planSlug: PlanSlugFromProducts; productSlug: PlanSlugFromProducts; productId: number; - introOffer?: PlanIntroductoryOffer | null; + pricing: PlanPricing; /* END: Same SitePlan/PlanNext props */ - productNameShort: string; - billPeriod: -1 | ( typeof PERIOD_LIST )[ number ]; - currencyCode: string; } export interface PricedAPIPlanIntroductoryOffer { @@ -115,25 +136,42 @@ export interface PricedAPIPlanIntroductoryOffer { introductory_offer_end_date?: string; } +export interface PricedAPIPlanPricing { + bill_period: -1 | ( typeof PERIOD_LIST )[ number ]; + product_display_price: string; + formatted_price: string; + + /** + * The product price in the currency's smallest unit. + */ + raw_price_integer: number; + + /** + * The orig cost in the currency's smallest unit. Note that orig_cost_integer is never null. + * If the cost of a store product is overridden by a promotion or a coupon, orig_cost_integer + * will return a price identical to raw_price_integer instead. + */ + orig_cost_integer: number; + + currency_code: string; +} + +export interface PricedAPISitePlanPricing + extends Omit< PricedAPIPlanPricing, 'orig_cost_integer' | 'bill_period' > { + raw_discount_integer: number; +} + /** * Item returned from https://public-api.wordpress.com/rest/v1.5/plans response * Only the properties that are actually used in the store are typed */ -export interface PricedAPIPlan extends PricedAPIPlanIntroductoryOffer { +export interface PricedAPIPlan extends PricedAPIPlanPricing, PricedAPIPlanIntroductoryOffer { product_id: number; product_name: string; path_slug?: PlanPath; product_slug: StorePlanSlug; product_name_short: string; product_type?: string; - bill_period: -1 | ( typeof PERIOD_LIST )[ number ]; - product_display_price: string; - formatted_price: string; - - /** - * The product price in the currency's smallest unit. - */ - raw_price_integer: number; /** * The product price as a float. @@ -141,19 +179,11 @@ export interface PricedAPIPlan extends PricedAPIPlanIntroductoryOffer { */ raw_price: number; - /** - * The orig cost in the currency's smallest unit. Note that origCostInteger is never null. Although orig_cost - * is undefined if the cost of a store product is overridden by a promotion or a coupon, orig_cost_integer - * is not. origCostInteger will return a price identical to raw_price_integer instead. - */ - orig_cost_integer: number; - /** * The orig cost as a float. * @deprecated use orig_cost_integer as using floats for currency is not safe. */ orig_cost?: number | null; - currency_code: string; } /** @@ -161,7 +191,9 @@ export interface PricedAPIPlan extends PricedAPIPlanIntroductoryOffer { * Only the properties that are actually used in the store are typed * Note: These, unlike the PricedAPIPlan, are returned indexed by product_id (and do not inlcude that in the plan's payload) */ -export interface PricedAPISitePlan extends PricedAPIPlanIntroductoryOffer { +export interface PricedAPISitePlan + extends PricedAPISitePlanPricing, + PricedAPIPlanIntroductoryOffer { /* product_id: number; // not included in the plan's payload */ product_slug: StorePlanSlug; current_plan?: boolean;