Functional Weave
Code in Rust

charity.reclaim-batch@1.0.0

impl/typescript.ts

4,122 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 { giftAid } from "./charity_gift_aid.ts";  ← from charity.gift-aid ^1.0.0 · built alongside by fune
import { ukTaxYear } from "./dates_uk_tax_year.ts";  ← from dates.uk-tax-year ^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 { AGGREGATION } from "./charity_reclaim_batch_data.ts";  ← this capability’s own data, compiled from data/aggregation.json into the same file by fune build
import { type ClaimLine, type ExcludedDonation, type GiftAidClaimBatch, type GiftAidDonation } from "./charity_reclaim_batch_types.ts";

function compare(a: string, b: string): number {
  return a < b ? -1 : a > b ? 1 : 0;
}

/**
 * The totals of a Gift Aid repayment claim for a period. Every donation is
 * either on the claim or listed as excluded with its reason, lines come out in
 * a fixed order (donor lines by donorId, then aggregated lines by date), and
 * the tax is worked out on each tax year's total, so the same records always
 * give the same claim.
 */
export function giftAidClaimBatch(
  donations: readonly GiftAidDonation[],
  periodStart: string,
  periodEnd: string,
  aggregateSmall: boolean
): GiftAidClaimBatch {
  ukTaxYear(periodStart);
  ukTaxYear(periodEnd);
  if (periodEnd < periodStart) throw new RangeError(`periodEnd ${periodEnd} is before periodStart ${periodStart}`);
  const rule = AGGREGATION[0];
  const ids = new Set<string>();
  const excluded: ExcludedDonation[] = [];
  const eligible: GiftAidDonation[] = [];
  for (const d of donations) {
    if (ids.has(d.id)) throw new RangeError(`duplicate donation id "${d.id}"`);
    ids.add(d.id);
    if (d.amount.currency !== "GBP") throw new RangeError(`donation "${d.id}" must be in GBP, received ${d.amount.currency}`);
    ukTaxYear(d.date);
    let reason: string | null = null;
    if (d.date < periodStart || d.date > periodEnd) reason = "outside-period";
    else if (d.amount.minor <= 0) reason = "not-positive";
    else if (!d.hasDeclaration) reason = "no-declaration";
    else if (d.donorId === null) reason = "no-donor";
    if (reason === null) eligible.push(d);
    else excluded.push({ id: d.id, reason });
  }

  const small = aggregateSmall ? eligible.filter((d) => d.amount.minor <= rule.maxDonation) : [];
  const named = aggregateSmall ? eligible.filter((d) => d.amount.minor > rule.maxDonation) : eligible;

  const byDonor = new Map<string, GiftAidDonation[]>();
  for (const d of named) {
    const key = d.donorId as string;
    const list = byDonor.get(key);
    if (list === undefined) byDonor.set(key, [d]);
    else list.push(d);
  }
  const lines: ClaimLine[] = [];
  for (const donorId of [...byDonor.keys()].sort(compare)) {
    const list = byDonor.get(donorId) as GiftAidDonation[];
    lines.push({
      kind: "donor",
      donorId,
      lastDate: list.map((d) => d.date).reduce((a, b) => (b > a ? b : a)),
      count: list.length,
      total: money(list.reduce((s, d) => s + d.amount.minor, 0), "GBP"),
    });
  }

  // Aggregated lines fill in date order (ties by id) up to the line limit.
  const sortedSmall = [...small].sort((a, b) => compare(a.date, b.date) || compare(a.id, b.id));
  let current: GiftAidDonation[] = [];
  let currentTotal = 0;
  const flush = (): void => {
    if (current.length === 0) return;
    lines.push({
      kind: "aggregated",
      donorId: null,
      lastDate: current[current.length - 1].date,
      count: current.length,
      total: money(currentTotal, "GBP"),
    });
    current = [];
    currentTotal = 0;
  };
  for (const d of sortedSmall) {
    if (currentTotal + d.amount.minor > rule.maxLineTotal) flush();
    current.push(d);
    currentTotal += d.amount.minor;
  }
  flush();

  // One rounding per tax year, since each year has its own basic rate.
  const byYear = new Map<string, number>();
  for (const d of eligible) {
    const start = ukTaxYear(d.date).start;
    byYear.set(start, (byYear.get(start) ?? 0) + d.amount.minor);
  }
  let claimable = 0;
  for (const start of [...byYear.keys()].sort(compare)) {
    claimable += giftAid(money(byYear.get(start) as number, "GBP"), start).taxReclaimable.minor;
  }

  return {
    lines,
    eligibleCount: eligible.length,
    eligibleTotal: money(eligible.reduce((s, d) => s + d.amount.minor, 0), "GBP"),
    claimable: money(claimable, "GBP"),
    excluded,
  };
}