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