@balena/balena-pricing
Version:
Balena pricing/savings calculators
348 lines • 14.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreditPricing = exports.InvalidParametersError = void 0;
const typed_error_1 = require("typed-error");
class InvalidParametersError extends typed_error_1.TypedError {
constructor(message) {
super(message);
}
}
exports.InvalidParametersError = InvalidParametersError;
// Credit pricing definitions.
const CREDITS = {
'device:microservices': [
{
version: 1,
validFrom: new Date('2023-02-01T00:00:00Z'),
firstDiscountPriceCents: 149,
discountRate: 0.33,
discountThreshold: 12000,
discountThresholdPriceCents: 125,
},
{
version: 2,
validFrom: new Date('2023-03-08T00:00:00Z'),
firstDiscountPriceCents: 199,
discountRate: 0.33,
discountThreshold: 12000,
discountThresholdPriceCents: 150,
},
],
};
/**
* Validate credit pricing definitions.
* @param credits - credit pricing definitions
* @throws {InvalidParametersError} if there are duplicate versions/dates
*
* @example
* validateCredits(CREDITS);
*/
function validateCredits(credits) {
// Assert that there are no duplicate versions/dates.
for (const [slug, definitions] of Object.entries(credits)) {
const versions = new Set();
const validFroms = new Set();
for (const definition of definitions) {
if (versions.has(definition.version)) {
throw new InvalidParametersError(`Duplicate version ${definition.version} for feature ${slug}`);
}
const validFrom = definition.validFrom.toISOString();
if (validFroms.has(validFrom)) {
throw new InvalidParametersError(`Duplicate validFrom ${validFrom} for feature ${slug}`);
}
versions.add(definition.version);
validFroms.add(validFrom);
}
}
}
/**
* Get the required credit amount for a given unit cost.
* @param pricing - credit pricing definition
* @param unitCost - unit cost
* @returns number of credits required
*
* @example
* getCreditAmount(CREDITS['device:microservices'][0], 100);
*/
function getCreditAmount(pricing, unitCost) {
if (unitCost >= pricing.discountThresholdPriceCents) {
return (((unitCost - pricing.firstDiscountPriceCents) *
(pricing.discountThreshold - 1)) /
(pricing.discountThresholdPriceCents -
pricing.firstDiscountPriceCents) +
1);
}
return (pricing.discountThreshold *
10 **
(Math.log10(unitCost / pricing.discountThresholdPriceCents) /
Math.log10(1 - pricing.discountRate)));
}
/**
* Sort credit pricing definitions by validFrom date.
* Sorts from latest to oldest.
* @param credits - credit pricing definitions
* @returns sorted credit pricing definitions
*
* @example
* sortCredits(CREDITS);
*/
function sortCredits(credits) {
const sortedCredits = {};
for (const [slug, definitions] of Object.entries(credits)) {
sortedCredits[slug] = definitions.sort((a, b) => {
return b.validFrom.getTime() - a.validFrom.getTime();
});
}
return sortedCredits;
}
class CreditPricing {
credits;
target;
constructor(options = {}) {
// Sort and then validate credit pricing definitions.
this.credits = sortCredits(options.credits ?? CREDITS);
validateCredits(this.credits);
// Allow consumers to target one of the following:
// 'current' - the most recent valid version (default)
// 'latest' - the latest version, regardless of validity
// number - a specific version
// Date - the most recent valid version up to a given date
this.target = options.target ?? 'current';
}
/**
* Gets and returns pricing for a given feature.
* @param featureSlug - feature slug
* @returns credit pricing definition
*
* @example
* getDefinition('device:microservices');
*/
getDefinition(featureSlug) {
if (this.credits[featureSlug] == null) {
throw new InvalidParametersError(`Feature ${featureSlug} not supported for credits`);
}
let definition;
// Return latest version of a feature's pricing definition set.
// This ignores the validFrom date, meaning it can/will return
// definitions that are scheduled to be valid in the future.
if (this.target === 'latest') {
return this.credits[featureSlug][0];
}
// Return exact version of a feature's pricing definition set.
if (typeof this.target === 'number') {
definition = this.credits[featureSlug].find((credit) => {
return credit.version === this.target;
});
return definition;
}
// Return version of a feature's pricing definition set that is
// valid up to a given date.
if (this.target instanceof Date) {
return this.credits[featureSlug].find((def) => {
return def.validFrom <= this.target;
});
}
// Default to returning the most recent valid version of a
// feature's pricing definition set.
return this.credits[featureSlug].find((def) => {
return def.validFrom <= new Date();
});
}
/**
* Adjust a given credit amount to be just over the line to be higher than
* the given lower unit cost. This is used to handle rounding edge cases in
* which the original range calculations were slightly off.
* @param featureSlug - credit feature slug
* @param lowerUnitCost - lower unit cost to adjust to
* @param amount - starting point credit amount
* @returns number of credits needed to be just above the lower unit cost
*
* @example
* fixRange('device:microservices', 198, 1000);
*/
fixRange(featureSlug, lowerUnitCost, amount) {
let fixed = amount;
let unitCost = this.getCreditPrice(featureSlug, 0, fixed);
// Normalize to lower of the two costs.
// Add credits until unitCost is down to the lowerUnitCost.
while (unitCost > lowerUnitCost) {
fixed = fixed + 1;
unitCost = this.getCreditPrice(featureSlug, 0, fixed);
}
// Go back right "over the line" to the higher cost.
// Reduce credits until unitCost is just higher than the lowerUnitCost.
while (unitCost === lowerUnitCost) {
fixed = fixed - 1;
unitCost = this.getCreditPrice(featureSlug, 0, fixed);
}
return fixed;
}
/**
* Get credit amount range for a given unit cost.
* @param featureSlug - feature slug
* @param unitCost - unit cost
* @param availableCredits - currently available credits
* @returns credit amount range
*
* @example
* getCreditRange('device:microservices', 190);
* getCreditRange('device:microservices', 190, 1000);
*/
getCreditRange(featureSlug, unitCost, availableCredits = 0) {
// Validate unit cost input
if (!Number.isInteger(unitCost)) {
throw new InvalidParametersError('Unit cost must be a whole number');
}
if (unitCost <= 0) {
throw new InvalidParametersError('Unit cost must be greater than 0');
}
if (!Number.isInteger(availableCredits)) {
throw new InvalidParametersError('Available credits must be a whole number');
}
if (availableCredits < 0) {
throw new InvalidParametersError('Available credits must be greater than or equal to 0');
}
const pricing = this.getDefinition(featureSlug);
if (pricing == null) {
throw new InvalidParametersError('Requested feature not allowed for credit usage');
}
// Requested unit cost cannot be higher than the first discount price
if (unitCost > pricing.firstDiscountPriceCents) {
throw new InvalidParametersError(`Unit cost cannot be greater than ${pricing.firstDiscountPriceCents}`);
}
// Calculate credit range. Can only calculate "from" if unit cost
// is less than the first discount price, otherwise set to 1.
// Cannot go lower than $0.01 unit cost, so only calculate "to"
// if unit cost is greater than 1.
const creditRange = {
from: unitCost === pricing.firstDiscountPriceCents
? 1
: Math.ceil(getCreditAmount(pricing, unitCost + 0.5)),
};
if (unitCost > 1) {
creditRange.to = Math.floor(getCreditAmount(pricing, unitCost - 0.5));
}
// Handle rounding edge cases where from/to calculation results aren't exactly right.
if (creditRange.from > 1 &&
!(this.getCreditPrice(featureSlug, 0, creditRange.from) === unitCost &&
this.getCreditPrice(featureSlug, 0, creditRange.from - 1) ===
unitCost + 1)) {
creditRange.from =
this.fixRange(featureSlug, unitCost, creditRange.from) + 1;
}
if (creditRange.to &&
!(this.getCreditPrice(featureSlug, 0, creditRange.to) === unitCost &&
this.getCreditPrice(featureSlug, 0, creditRange.to + 1) === unitCost - 1)) {
creditRange.to = this.fixRange(featureSlug, unitCost - 1, creditRange.to);
}
// Adjust for currently available credits.
if (availableCredits > 0) {
creditRange.from = Math.max(creditRange.from - availableCredits, 0);
}
if (creditRange.to && availableCredits > 0) {
creditRange.to = Math.max(creditRange.to - availableCredits, 0);
}
// Throw error if impossible to purchase credits at requested unit cost
// after accounting for currently available credits.
if (creditRange.from === 0 &&
(creditRange.to === 0 || creditRange.to == null)) {
throw new Error('Requested unit cost is too high');
}
return creditRange;
}
/**
* Calculates the price of a credit purchase
* @param featureSlug - feature slug
* @param availableCredits - total of available and currently accrued credits
* @param creditsToPurchase - number of credits to purchase
* @returns price of credits for purchase
*
* @example
* getCreditPrice('device:microservices', 0, 25000);
*/
getCreditPrice(featureSlug, availableCredits, creditsToPurchase) {
// Assert that credit amounts are valid
if (!Number.isInteger(availableCredits)) {
throw new InvalidParametersError('Available credits must be a whole number');
}
if (!Number.isInteger(creditsToPurchase)) {
throw new InvalidParametersError('Credit purchase amount must be a whole number');
}
if (availableCredits < 0) {
throw new InvalidParametersError('Available credits must be greater than or equal to 0');
}
if (creditsToPurchase <= 0) {
throw new InvalidParametersError('Credit purchase amount must be greater than 0');
}
const pricing = this.getDefinition(featureSlug);
if (pricing == null) {
throw new InvalidParametersError('Requested feature not allowed for credit usage');
}
const total = availableCredits + creditsToPurchase;
if (creditsToPurchase === 0 || total === 0) {
return 0;
}
if (total <= pricing.discountThreshold) {
return Math.round(pricing.firstDiscountPriceCents +
((pricing.discountThresholdPriceCents -
pricing.firstDiscountPriceCents) /
(pricing.discountThreshold - 1)) *
(total - 1));
}
const result = Math.round(pricing.discountThresholdPriceCents *
Math.pow(1 - pricing.discountRate, Math.log10(total / pricing.discountThreshold)));
if (result <= 0) {
throw new InvalidParametersError('The provided quantity surpasses the maximum supported amount of credits');
}
return result;
}
/**
* Calculate the total price of a credit purchase
* @param featureSlug - feature slug
* @param availableCredits - total of available and currently accrued credits
* @param creditsToPurchase - number of credits to purchase
* @returns total price of credits for purchase
*
* @example
* getCreditTotalPrice('device:microservices', 0, 25000);
*/
getCreditTotalPrice(featureSlug, availableCredits, creditsToPurchase) {
return Math.round(this.getCreditPrice(featureSlug, availableCredits, creditsToPurchase) *
creditsToPurchase);
}
/**
* Calculate discount percentage when compared to dynamic pricing
* @param featureSlug - feature slug
* @param availableCredits - total of available and currently accrued credits
* @param creditsToPurchase - number of credits to purchase
* @param dynamicPriceCents - dynamic price in cents
* @returns discount percentage
*
* @example
* getDiscountOverDynamic('device:microservices', 0, 25000);
*/
getDiscountOverDynamic(featureSlug, availableCredits, creditsToPurchase, dynamicPriceCents) {
return Math.round(((dynamicPriceCents -
this.getCreditPrice(featureSlug, availableCredits, creditsToPurchase)) /
dynamicPriceCents) *
100);
}
/**
* Calculate the total savings of a credit purchase
* @param featureSlug - feature slug
* @param availableCredits - total of available and currently accrued credits
* @param creditsToPurchase - number of credits to purchase
* @param dynamicPriceCents - dynamic price in cents
* @returns total savings of credits for purchase
*
* @example
* getTotalSavings('device:microservices', 0, 25000, 100);
*/
getTotalSavings(featureSlug, availableCredits, creditsToPurchase, dynamicPriceCents) {
return Math.round(creditsToPurchase *
(dynamicPriceCents -
this.getCreditPrice(featureSlug, availableCredits, creditsToPurchase)));
}
}
exports.CreditPricing = CreditPricing;
//# sourceMappingURL=index.js.map