impl/typescript.ts
4,242 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 { type Invoice, type InvoiceLine, calculateInvoice } from "./finance_invoice_calculate.ts"; ← from finance.invoice.calculate ^1.0.0 · built alongside by fune
import { lineTotal } from "./finance_invoice_line_total.ts"; ← from finance.invoice.line-total ^1.0.0 · built alongside by fune
import { allocate } from "./money_allocate.ts"; ← from money.allocate ^1.0.0 · built alongside by fune
import { assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts"; ← from money.apply-rate ^1.0.0 · built alongside by fune
import { type Discount } from "./subscriptions_invoice_types.ts";
function checkDiscount(d: Discount, currency: string): void {
if ((d.basisPoints === null) === (d.amountOff === null)) {
throw new RangeError(`a discount needs exactly one of basisPoints and amountOff: "${d.description}"`);
}
if (d.basisPoints !== null && (!Number.isInteger(d.basisPoints) || d.basisPoints < 0 || d.basisPoints > 10000)) {
throw new RangeError(`discount basis points must be between 0 and 10000, received ${d.basisPoints}`);
}
if (d.amountOff !== null) {
assertSameCurrency(money(0, currency), d.amountOff);
if (d.amountOff.minor < 0) throw new RangeError(`discount amount must not be negative, received ${d.amountOff.minor}`);
}
if (!Number.isInteger(d.startPeriod) || d.startPeriod < 1) {
throw new RangeError(`discount start period must be 1 or more, received ${d.startPeriod}`);
}
if (d.duration === "repeating") {
if (d.durationPeriods === null || !Number.isInteger(d.durationPeriods) || d.durationPeriods < 1) {
throw new RangeError(`a repeating discount needs durationPeriods of 1 or more, received ${d.durationPeriods}`);
}
} else if (d.duration === "once" || d.duration === "forever") {
if (d.durationPeriods !== null) throw new RangeError(`durationPeriods applies only to a repeating discount: "${d.description}"`);
} else {
throw new RangeError(`unknown discount duration "${d.duration}": expected once, repeating or forever`);
}
}
function applies(d: Discount, period: number): boolean {
if (d.duration === "once") return period === d.startPeriod;
if (d.duration === "repeating") return period >= d.startPeriod && period < d.startPeriod + (d.durationPeriods as number);
return period >= d.startPeriod;
}
/**
* The renewal invoice for one billing period: plan, add-ons and usage, less
* the coupons in force in that period, taxed per line by
* finance.invoice.calculate. Each coupon becomes negative lines, one per VAT
* category, so the reduction is taxed at the rate of what it reduces.
*/
export function renewalInvoice(
plan: InvoiceLine,
addOns: readonly InvoiceLine[],
usage: readonly InvoiceLine[],
discounts: readonly Discount[],
periodNumber: number,
jurisdiction: string,
invoiceDate: string,
): Invoice {
if (!Number.isInteger(periodNumber) || periodNumber < 1) {
throw new RangeError(`period number must be 1 or more, received ${periodNumber}`);
}
const currency = plan.unitPrice.currency;
const items = [plan, ...addOns, ...usage];
const categories: string[] = [];
const categoryNet: number[] = [];
let running = 0;
for (const item of items) {
const net = lineTotal(item.unitPrice, item.quantity, item.discountBasisPoints);
assertSameCurrency(money(0, currency), net);
let k = categories.indexOf(item.taxCategory);
if (k < 0) {
k = categories.length;
categories.push(item.taxCategory);
categoryNet.push(0);
}
categoryNet[k] += net.minor;
running += net.minor;
}
// Credit-only categories take no share of a discount.
const weights = categoryNet.map((n) => Math.max(n, 0));
const discountLines: InvoiceLine[] = [];
for (const d of discounts) {
checkDiscount(d, currency);
if (!applies(d, periodNumber) || running <= 0) continue;
const amount = d.basisPoints !== null ? applyRate(money(running, currency), d.basisPoints, "half-up").minor : Math.min((d.amountOff as { minor: number }).minor, running);
if (amount === 0) continue;
running -= amount;
allocate(money(amount, currency), weights).forEach((share, k) => {
if (share.minor !== 0) {
discountLines.push({ description: d.description, unitPrice: money(-share.minor, currency), quantity: 1, discountBasisPoints: 0, taxCategory: categories[k] });
}
});
}
return calculateInvoice([...items, ...discountLines], jurisdiction, invoiceDate);
}