Functional Weave
Code in TypeScript

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) })),
  };
}