subscriptions.usage-tiered@1.0.0
impl/typescript.ts
2,809 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { type TierCharge, type TierMode, type UsageCharge, type UsageTier } from "./subscriptions_usage_tiered_types.ts";
function charge(tier: number, quantity: number, t: UsageTier): TierCharge {
const amount = money(quantity * t.unitPrice.minor + t.flatFee.minor, t.unitPrice.currency);
return { tier, quantity, unitPrice: t.unitPrice, flatFee: t.flatFee, amount };
}
/**
* Price `quantity` units against ascending tiers. Graduated prices each unit
* in its own tier and charges the flat fee of every tier reached; volume
* prices every unit at the tier the quantity falls in, plus that tier's flat
* fee. The first tier is always reached, so its flat fee applies at zero.
*/
export function priceUsage(quantity: number, tiers: readonly UsageTier[], mode: TierMode): UsageCharge {
if (!Number.isInteger(quantity) || quantity < 0) {
throw new RangeError(`quantity must be a whole number of 0 or more, received ${quantity}`);
}
if (mode !== "graduated" && mode !== "volume") {
throw new RangeError(`unknown tier mode "${mode}": expected graduated or volume`);
}
if (tiers.length === 0) {
throw new RangeError("tiered pricing needs at least one tier");
}
const currency = tiers[0].unitPrice.currency;
let previous = 0;
tiers.forEach((t, i) => {
assertSameCurrency(tiers[0].unitPrice, t.unitPrice);
assertSameCurrency(tiers[0].unitPrice, t.flatFee);
if (t.upTo === null) {
if (i !== tiers.length - 1) throw new RangeError("only the last tier may be open-ended (upTo null)");
} else {
if (!Number.isInteger(t.upTo) || t.upTo <= previous) {
throw new RangeError(`tier upTo values must be positive and strictly increasing, received ${t.upTo} after ${previous}`);
}
previous = t.upTo;
}
});
const last = tiers[tiers.length - 1];
if (last.upTo !== null && quantity > last.upTo) {
throw new RangeError(`quantity ${quantity} exceeds the last tier, which ends at ${last.upTo}`);
}
const lines: TierCharge[] = [];
if (mode === "volume") {
const index = tiers.findIndex((t) => t.upTo === null || quantity <= t.upTo);
lines.push(charge(index + 1, quantity, tiers[index]));
} else {
let floor = 0;
for (let i = 0; i < tiers.length; i++) {
// Tier 1 is always reached; a later tier only once usage passes the
// previous tier's last unit.
if (i > 0 && quantity <= floor) break;
const t = tiers[i];
const top = t.upTo === null ? quantity : Math.min(quantity, t.upTo);
lines.push(charge(i + 1, top - floor, t));
if (t.upTo === null) break;
floor = t.upTo;
}
}
return { lines, total: sumMoney(lines.map((l) => l.amount), currency) };
}