from typing import Dict, List, Optional, Sequence from .charity_gift_aid import gift_aid from .charity_reclaim_batch_data import AGGREGATION from .charity_reclaim_batch_types import ClaimLine, ExcludedDonation, GiftAidClaimBatch, GiftAidDonation from .dates_uk_tax_year import uk_tax_year from .money_amount import money def gift_aid_claim_batch( donations: Sequence[GiftAidDonation], period_start: str, period_end: str, aggregate_small: bool, ) -> GiftAidClaimBatch: """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. """ uk_tax_year(period_start) uk_tax_year(period_end) if period_end < period_start: raise ValueError("periodEnd %s is before periodStart %s" % (period_end, period_start)) rule = AGGREGATION[0] ids = set() excluded: List[ExcludedDonation] = [] eligible: List[GiftAidDonation] = [] for d in donations: if d.id in ids: raise ValueError('duplicate donation id "%s"' % (d.id,)) ids.add(d.id) if d.amount.currency != "GBP": raise ValueError('donation "%s" must be in GBP, received %s' % (d.id, d.amount.currency)) uk_tax_year(d.date) reason: Optional[str] = None if d.date < period_start or d.date > period_end: reason = "outside-period" elif d.amount.minor <= 0: reason = "not-positive" elif not d.has_declaration: reason = "no-declaration" elif d.donor_id is None: reason = "no-donor" if reason is None: eligible.append(d) else: excluded.append(ExcludedDonation(id=d.id, reason=reason)) small = [d for d in eligible if d.amount.minor <= rule.max_donation] if aggregate_small else [] named = [d for d in eligible if d.amount.minor > rule.max_donation] if aggregate_small else eligible by_donor: Dict[str, List[GiftAidDonation]] = {} for d in named: by_donor.setdefault(d.donor_id, []).append(d) lines: List[ClaimLine] = [] for donor_id in sorted(by_donor.keys()): group = by_donor[donor_id] lines.append( ClaimLine( kind="donor", donor_id=donor_id, last_date=max(d.date for d in group), count=len(group), total=money(sum(d.amount.minor for d in group), "GBP"), ) ) # Aggregated lines fill in date order (ties by id) up to the line limit. current: List[GiftAidDonation] = [] current_total = 0 def flush() -> None: nonlocal current, current_total if not current: return lines.append( ClaimLine(kind="aggregated", donor_id=None, last_date=current[-1].date, count=len(current), total=money(current_total, "GBP")) ) current = [] current_total = 0 for d in sorted(small, key=lambda x: (x.date, x.id)): if current_total + d.amount.minor > rule.max_line_total: flush() current.append(d) current_total += d.amount.minor flush() # One rounding per tax year, since each year has its own basic rate. by_year: Dict[str, int] = {} for d in eligible: start = uk_tax_year(d.date).start by_year[start] = by_year.get(start, 0) + d.amount.minor claimable = 0 for start in sorted(by_year.keys()): claimable += gift_aid(money(by_year[start], "GBP"), start).tax_reclaimable.minor return GiftAidClaimBatch( lines=lines, eligible_count=len(eligible), eligible_total=money(sum(d.amount.minor for d in eligible), "GBP"), claimable=money(claimable, "GBP"), excluded=excluded, )