charity.restricted-funds@1.0.0
impl/typescript.ts
2,804 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 Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type Fund, type FundAllocation, type FundDraw, type FundSpend } from "./charity_restricted_funds_types.ts";
function sameCurrency(currency: string, amount: Money): void {
if (amount.currency !== currency) throw new RangeError(`currency mismatch: ${amount.currency} and ${currency}`);
}
/**
* Charge spending to funds the way charity fund accounting requires: a spend
* for a restricted purpose uses that purpose's restricted funds first (in the
* order given), and only what they cannot cover falls on the unrestricted
* funds. General spending never touches a restricted fund, and no fund may go
* below zero: spending that the funds cannot cover is refused, not recorded
* as a deficit on a restricted fund.
*/
export function allocateFundSpend(funds: readonly Fund[], spends: readonly FundSpend[]): FundAllocation {
const currency = funds.length > 0 ? funds[0].balance.currency : spends.length > 0 ? spends[0].amount.currency : "GBP";
const seen = new Set<string>();
for (const fund of funds) {
if (seen.has(fund.id)) throw new RangeError(`duplicate fund id "${fund.id}"`);
seen.add(fund.id);
sameCurrency(currency, fund.balance);
if (fund.restricted && fund.purpose === null) throw new RangeError(`restricted fund "${fund.id}" needs a purpose`);
if (!fund.restricted && fund.purpose !== null) {
throw new RangeError(`unrestricted fund "${fund.id}" must not have a purpose`);
}
if (fund.balance.minor < 0) throw new RangeError(`fund "${fund.id}" balance must not be negative, received ${fund.balance.minor}`);
}
for (const spend of spends) {
sameCurrency(currency, spend.amount);
if (spend.amount.minor < 0) {
throw new RangeError(`spend "${spend.id}" amount must not be negative, received ${spend.amount.minor}`);
}
}
const balances = funds.map((f) => f.balance.minor);
const draws: FundDraw[] = [];
for (const spend of spends) {
let remaining = spend.amount.minor;
const take = (i: number): void => {
const amount = Math.min(remaining, balances[i]);
if (amount <= 0) return;
balances[i] -= amount;
remaining -= amount;
draws.push({ spendId: spend.id, fundId: funds[i].id, amount: money(amount, currency) });
};
if (spend.purpose !== null) {
funds.forEach((f, i) => {
if (f.restricted && f.purpose === spend.purpose) take(i);
});
}
funds.forEach((f, i) => {
if (!f.restricted) take(i);
});
if (remaining > 0) throw new RangeError(`insufficient funds for spend "${spend.id}": short by ${remaining}`);
}
return {
draws,
funds: funds.map((f, i) => ({ id: f.id, restricted: f.restricted, purpose: f.purpose, balance: money(balances[i], currency) })),
};
}