Skip to content

Commit

Permalink
MWPW-165302: Calculate promo prices with duration (#3492)
Browse files Browse the repository at this point in the history
* MWPW-165302: Calculate promo prices with duration

* update jsdoc

* fix test

* fix recurrence
  • Loading branch information
yesil authored Jan 23, 2025
1 parent cf48942 commit 6cb26a3
Show file tree
Hide file tree
Showing 17 changed files with 4,390 additions and 479 deletions.
4 changes: 2 additions & 2 deletions libs/deps/mas/commerce.js

Large diffs are not rendered by default.

138 changes: 69 additions & 69 deletions libs/deps/mas/mas.js

Large diffs are not rendered by default.

138 changes: 69 additions & 69 deletions libs/deps/mas/merch-card.js

Large diffs are not rendered by default.

138 changes: 69 additions & 69 deletions libs/features/mas/dist/mas.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions libs/features/mas/src/price/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
createPromoPriceWithAnnualTemplate,
} from './template.js';

import { isPromotionActive } from './utilities.js';

const price = createPriceTemplate();
const pricePromo = createPromoPriceTemplate();
const priceOptical = createPriceTemplate({
Expand All @@ -27,4 +29,5 @@ export {
priceAnnual,
priceWithAnnual,
pricePromoWithAnnual,
isPromotionActive,
};
37 changes: 31 additions & 6 deletions libs/features/mas/src/price/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
formatRegularPrice,
formatAnnualPrice,
makeSpacesAroundNonBreaking,
} from './utilities';
} from './utilities.js';

// JSON imports require new syntax to run in Milo/wtr tests,
// but the new syntax is not yet supported by ESLint:
Expand Down Expand Up @@ -153,6 +153,7 @@ const createPriceTemplate =
displayOptical = false,
displayStrikethrough = false,
displayAnnual = false,
instant = undefined,
} = {}) =>
(
{
Expand All @@ -163,6 +164,7 @@ const createPriceTemplate =
displayTax = false,
language,
literals: priceLiterals = {},
quantity = 1,
} = {},
{
commitment,
Expand All @@ -174,6 +176,7 @@ const createPriceTemplate =
taxTerm,
term,
usePrecision,
promotion,
} = {},
attributes = {},
) => {
Expand All @@ -184,8 +187,10 @@ const createPriceTemplate =
price,
}).forEach(([key, value]) => {
if (value == null) {
/* c8 ignore next 2 */
throw new Error(`Argument "${key}" is missing for osi ${offerSelectorIds?.toString()}, country ${country}, language ${language}`);
/* c8 ignore next 2 */
throw new Error(
`Argument "${key}" is missing for osi ${offerSelectorIds?.toString()}, country ${country}, language ${language}`,
);

Check warning on line 193 in libs/features/mas/src/price/template.js

View check run for this annotation

Codecov / codecov/patch

libs/features/mas/src/price/template.js#L193

Added line #L193 was not covered by tests
}
});

Expand All @@ -208,7 +213,7 @@ const createPriceTemplate =
locale,
).format(parameters);
} catch {
/* c8 ignore next 2 */
/* c8 ignore next 2 */
log.error('Failed to format literal:', literal);
return '';
}
Expand All @@ -226,10 +231,15 @@ const createPriceTemplate =
const { accessiblePrice, recurrenceTerm, ...formattedPrice } = method({
commitment,
formatString,
term,
instant,
isIndianPrice: country === 'IN',
originalPrice: price,
priceWithoutDiscount,
price: displayOptical ? price : displayPrice,
promotion,
quantity,
term,
usePrecision,
isIndianPrice: country === 'IN',
});

let accessibleLabel = accessiblePrice;
Expand Down Expand Up @@ -365,6 +375,20 @@ const createPromoPriceTemplate = () => (context, value, attributes) => {

const createPromoPriceWithAnnualTemplate =
() => (context, value, attributes) => {
let { instant } = context;
try {
if (!instant) {
instant = new URLSearchParams(document.location.search).get(
'instant',
);
}
if (instant) {
instant = new Date(instant);
}
} catch (e) {
instant = undefined;
/* ignore the error */
}

Check warning on line 391 in libs/features/mas/src/price/template.js

View check run for this annotation

Codecov / codecov/patch

libs/features/mas/src/price/template.js#L389-L391

Added lines #L389 - L391 were not covered by tests
const ctxStAnnual = {
...context,
displayTax: false,
Expand All @@ -386,6 +410,7 @@ const createPromoPriceWithAnnualTemplate =
}${createPriceTemplate()(context, value, attributes)}${renderSpan(cssClassNames.containerAnnualPrefix, ' (')}${createPriceTemplate(
{
displayAnnual: true,
instant,
},
)(
ctxStAnnual,
Expand Down
143 changes: 126 additions & 17 deletions libs/features/mas/src/price/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@ const SPACE_START_PATTERN = /^\s+/;
const SPACE_END_PATTERN = /\s+$/;
const NBSP = ' ';

const getAnnualPrice = (price) => price * 12;

/**
* Checks if a promotion is currently active.
*
* @param {Object} promotion - The promotion object.
* @param {string} promotion.start - The start date of the promotion in ISO format.
* @param {string} promotion.end - The end date of the promotion in ISO format.
* @param {Object} promotion.displaySummary - The display summary of the promotion.
* @param {number} promotion.displaySummary.amount - The amount of the promotion, (e.g: in percentage).
* @param {number} promotion.displaySummary.duration - The duration of the promotion.
* @param {number} promotion.displaySummary.minProductQuantity - The minimum product quantity for the promotion.
* @param {string} promotion.displaySummary.outcomeType - The outcome type of the promotion.
* @param {string} [instant] - An optional date string to use as the current date. If not provided, the current date is used.
* @returns {boolean} - Returns true if the promotion is active, false otherwise.
*/
const isPromotionActive = (promotion, instant) => {
const {
start,
end,
displaySummary: {
amount,
duration,
minProductQuantity,
outcomeType,
} = {},
} = promotion;
if (!(amount && duration && outcomeType && minProductQuantity)) {
return false;
}
const now = instant ? new Date(instant) : new Date();
if (!start || !end) {
return false;
}

Check warning on line 48 in libs/features/mas/src/price/utilities.js

View check run for this annotation

Codecov / codecov/patch

libs/features/mas/src/price/utilities.js#L47-L48

Added lines #L47 - L48 were not covered by tests

const startDate = new Date(start);
const endDate = new Date(end);

return now >= startDate && now <= endDate;
};

// TODO: @pandora/react-price does not have "module" field in package.json and is bundled entirely by Webpack
const RecurrenceTerm = {
MONTH: 'MONTH',
Expand Down Expand Up @@ -46,18 +87,18 @@ const opticalPriceRoundingRules = [
opticalPriceRoundingRule(
// optical price for the term is a multiple of the initial price
({ divisor, price }) => price % divisor == 0,
({ divisor, price }) => price / divisor
({ divisor, price }) => price / divisor,
),
opticalPriceRoundingRule(
// round optical price up to 2 decimals
({ usePrecision }) => usePrecision,
({ divisor, price }) => Math.round((price / divisor) * 100.0) / 100.0
({ divisor, price }) => Math.round((price / divisor) * 100.0) / 100.0,
),
opticalPriceRoundingRule(
// round optical price up to integer
() => true,
({ divisor, price }) =>
Math.ceil(Math.floor((price * 100) / divisor) / 100)
Math.ceil(Math.floor((price * 100) / divisor) / 100),
),
];

Expand Down Expand Up @@ -95,7 +136,7 @@ const extractNumberMask = (formatString, usePrecision = true) => {
// As the formatString could be container non-symbol like `A #,##0.00 B` so using regex here.
numberMask = numberMask.replace(
/\s?(#.*0)(?!\s)?/,
'$&' + getPossibleDecimalsDelimiter(formatString)
'$&' + getPossibleDecimalsDelimiter(formatString),
);
} else if (!usePrecision) {
// Trim the 0s after the decimalsDelimiter. `#,##0.00` will become `#,##0.`
Expand Down Expand Up @@ -169,15 +210,31 @@ const findDecimalsDelimiter = (formatString) =>
// Utilities, specific to tacocat needs.

/**
* @param { import('./types').PriceData } data
* @param { RecurrenceTerm } recurrenceTerm
* @param { (price: number, format: { currencySymbol: string }) => number } transformPrice
* Formats a price according to the specified format string and currency rules.
*
* @param {object} options - The formatting options
* @param {string} options.formatString - The currency format string (e.g., "'US$ '#,##0.00")
* @param {number} options.price - The price value to format
* @param {boolean} options.usePrecision - Whether to include decimal precision in the formatted price
* @param {boolean} [options.isIndianPrice=false] - Whether to use Indian locale-specific formatting
* @param {string} recurrenceTerm - The recurrence term (MONTH or YEAR) for the price
* @param {function} [transformPrice=(price) => price] - Optional function to transform the price before formatting
* @returns {{
* accessiblePrice: string,
* currencySymbol: string,
* decimals: string,
* decimalsDelimiter: string,
* hasCurrencySpace: boolean,
* integer: string,
* isCurrencyFirst: boolean,
* recurrenceTerm: string
* }} Formatted price object containing the accessible price string and formatting details
*
*/
// TODO: Move this function to pandora library
function formatPrice(
{ formatString, price, usePrecision, isIndianPrice = false },
recurrenceTerm,
transformPrice = (formattedPrice) => formattedPrice
transformPrice = (formattedPrice) => formattedPrice,
) {
const { currencySymbol, isCurrencyFirst, hasCurrencySpace } =
getCurrencySymbolDetails(formatString);
Expand Down Expand Up @@ -233,14 +290,14 @@ const formatOpticalPrice = (data) => {
usePrecision,
};
const { round } = opticalPriceRoundingRules.find(({ accept }) =>
accept(priceData)
accept(priceData),
);
if (!round)
throw new Error(
`Missing rounding rule for: ${JSON.stringify(priceData)}`
`Missing rounding rule for: ${JSON.stringify(priceData)}`,

Check warning on line 297 in libs/features/mas/src/price/utilities.js

View check run for this annotation

Codecov / codecov/patch

libs/features/mas/src/price/utilities.js#L297

Added line #L297 was not covered by tests
);
return round(priceData);
}
},
);
};

Expand All @@ -252,15 +309,67 @@ const formatRegularPrice = ({ commitment, term, ...data }) =>
formatPrice(data, recurrenceTerms[commitment]?.[term]);

/**
* Formats annual price.
* @param { import('./types').PriceData } data
* Creates a function that calculates the annual price with promotion applied.
*
* @param {object} data - The data object containing price and priceWithoutDiscount
* @returns {function(number): number} A function that takes a monthly price and returns the calculated annual price with promotion
*
*/
const formatAnnualPrice = (data) => {
const { commitment, term } = data;
const {
commitment,
instant,
price,
originalPrice,
priceWithoutDiscount,
promotion,
quantity = 1,
term,
} = data;
if (commitment === Commitment.YEAR && term === Term.MONTHLY) {
return formatPrice(data, RecurrenceTerm.YEAR, (price) => price * 12);
if (!promotion) {
return formatPrice(data, RecurrenceTerm.YEAR, getAnnualPrice);
}
const {
displaySummary: { outcomeType, duration, minProductQuantity = 1 } = {},
} = promotion;
switch (outcomeType) {
case 'PERCENTAGE_DISCOUNT': {
if (
quantity >= minProductQuantity &&
isPromotionActive(promotion, instant)
) {
const durationInMonths = parseInt(
duration.replace('P', '').replace('M', ''),
);
if (isNaN(durationInMonths)) return getAnnualPrice(price);
const discountPrice =
quantity * originalPrice * durationInMonths;
const regularPrice =
quantity *
priceWithoutDiscount *
(12 - durationInMonths);
const totalPrice =
Math.floor((discountPrice + regularPrice) * 100) / 100;
return formatPrice(
{ ...data, price: totalPrice },
RecurrenceTerm.YEAR,
);
}
}
default:
return formatPrice(data, RecurrenceTerm.YEAR, () =>
getAnnualPrice(priceWithoutDiscount ?? price),
);
}
}
return formatPrice(data, recurrenceTerms[commitment]?.[term]);
};

export { formatOpticalPrice, formatRegularPrice, formatAnnualPrice, makeSpacesAroundNonBreaking };
export {
formatOpticalPrice,
formatRegularPrice,
formatAnnualPrice,
makeSpacesAroundNonBreaking,
isPromotionActive,
};
Loading

0 comments on commit 6cb26a3

Please sign in to comment.