Functional Weave
Code in Rust

banking.fees-cap@1.0.0

impl/typescript.ts

3,094 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 { parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type CappedCharge, type FeeCapResult, type FeeCharge } from "./banking_fees_cap_types.ts";

/**
 * The charging period a date falls in, named by the year and month it starts
 * in. With cycleDay 15, 2026-03-14 belongs to the period that began on
 * 2026-02-15 and 2026-03-15 to the one that begins that day.
 */
function periodKey(iso: string, cycleDay: number): string {
  const d = parseIsoDate(iso);
  let year = d.year;
  let month = d.month;
  if (d.day < cycleDay) {
    month -= 1;
    if (month === 0) {
      month = 12;
      year -= 1;
    }
  }
  return `${year}-${month}`;
}

/**
 * Apply a monthly cap to a list of charges. Within each charging period the
 * charges are taken in date order (ties in input order) until the cap is
 * used up: the charge that crosses the cap is reduced to what is left, and
 * every later charge in that period is waived in full. The result keeps the
 * input order, so it lines up with the caller's own list.
 */
export function applyFeeCap(charges: readonly FeeCharge[], monthlyCap: Money, cycleDay: number): FeeCapResult {
  if (!Number.isInteger(cycleDay) || cycleDay < 1 || cycleDay > 28) {
    throw new RangeError(`cycleDay must be 1 to 28, received ${cycleDay}`);
  }
  if (monthlyCap.minor < 0) {
    throw new RangeError(`monthlyCap must not be negative, received ${monthlyCap.minor}`);
  }
  const currency = monthlyCap.currency;
  const keys: string[] = [];
  charges.forEach((charge) => {
    if (charge.amount.currency !== currency) {
      throw new RangeError(`currency mismatch: ${charge.amount.currency} and ${currency}`);
    }
    if (charge.amount.minor < 0) {
      throw new RangeError(`charge "${charge.label}" must not be negative, received ${charge.amount.minor}`);
    }
    keys.push(periodKey(charge.date, cycleDay));
  });

  const order = charges.map((_, index) => index);
  // Array.prototype.sort is stable, so equal dates keep their input order.
  order.sort((a, b) => (charges[a].date < charges[b].date ? -1 : charges[a].date > charges[b].date ? 1 : 0));

  const used = new Map<string, number>();
  const chargedMinor: number[] = charges.map(() => 0);
  for (const index of order) {
    const spent = used.get(keys[index]) ?? 0;
    const room = monthlyCap.minor - spent;
    const take = Math.min(charges[index].amount.minor, room);
    chargedMinor[index] = take;
    used.set(keys[index], spent + take);
  }

  let totalCharged = 0;
  let totalWaived = 0;
  const out: CappedCharge[] = charges.map((charge, index) => {
    const charged = chargedMinor[index];
    const waived = charge.amount.minor - charged;
    totalCharged += charged;
    totalWaived += waived;
    return {
      date: charge.date,
      label: charge.label,
      amount: money(charge.amount.minor, currency),
      charged: money(charged, currency),
      waived: money(waived, currency),
    };
  });
  return { charges: out, totalCharged: money(totalCharged, currency), totalWaived: money(totalWaived, currency) };
}