use super::funejson::Value; use super::charity_gift_aid::gift_aid; use super::charity_reclaim_batch_data::AGGREGATION; use super::dates_uk_tax_year::uk_tax_year; use super::money_amount::{money, money_from_value, money_to_value}; /// 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. /// /// # Panics /// Panics on a malformed date, a period that ends before it starts, a /// duplicate donation id, a non-GBP donation, or a date with no basic rate. pub fn gift_aid_claim_batch( donations: &[GiftAidDonation], period_start: &str, period_end: &str, aggregate_small: bool, ) -> GiftAidClaimBatch { uk_tax_year(period_start); uk_tax_year(period_end); if period_end < period_start { panic!("periodEnd {} is before periodStart {}", period_end, period_start); } let rule = &AGGREGATION[0]; let mut ids: Vec<&str> = Vec::new(); let mut excluded: Vec = Vec::new(); let mut eligible: Vec<&GiftAidDonation> = Vec::new(); for d in donations { if ids.contains(&d.id.as_str()) { panic!("duplicate donation id \"{}\"", d.id); } ids.push(&d.id); if d.amount.currency != "GBP" { panic!("donation \"{}\" must be in GBP, received {}", d.id, d.amount.currency); } uk_tax_year(&d.date); let reason = if d.date.as_str() < period_start || d.date.as_str() > period_end { Some("outside-period") } else if d.amount.minor <= 0 { Some("not-positive") } else if !d.has_declaration { Some("no-declaration") } else if d.donor_id.is_none() { Some("no-donor") } else { None }; match reason { None => eligible.push(d), Some(r) => excluded.push(ExcludedDonation { id: d.id.clone(), reason: r.to_string() }), } } let (small, named): (Vec<&GiftAidDonation>, Vec<&GiftAidDonation>) = if aggregate_small { eligible.iter().partition(|d| d.amount.minor <= rule.max_donation) } else { (Vec::new(), eligible.clone()) }; let mut donor_ids: Vec<&str> = Vec::new(); for d in &named { let key = d.donor_id.as_deref().unwrap(); if !donor_ids.contains(&key) { donor_ids.push(key); } } donor_ids.sort(); let mut lines: Vec = Vec::new(); for donor_id in donor_ids { let group: Vec<&&GiftAidDonation> = named.iter().filter(|d| d.donor_id.as_deref() == Some(donor_id)).collect(); lines.push(ClaimLine { kind: "donor".to_string(), donor_id: Some(donor_id.to_string()), last_date: group.iter().map(|d| d.date.clone()).max().unwrap(), count: group.len() as i64, total: money(group.iter().map(|d| d.amount.minor).sum(), "GBP"), }); } // Aggregated lines fill in date order (ties by id) up to the line limit. let mut sorted_small = small.clone(); sorted_small.sort_by(|a, b| a.date.cmp(&b.date).then_with(|| a.id.cmp(&b.id))); let mut current: Vec<&GiftAidDonation> = Vec::new(); let mut current_total = 0i64; for d in sorted_small { if current_total + d.amount.minor > rule.max_line_total && !current.is_empty() { lines.push(ClaimLine { kind: "aggregated".to_string(), donor_id: None, last_date: current[current.len() - 1].date.clone(), count: current.len() as i64, total: money(current_total, "GBP"), }); current.clear(); current_total = 0; } current.push(d); current_total += d.amount.minor; } if !current.is_empty() { lines.push(ClaimLine { kind: "aggregated".to_string(), donor_id: None, last_date: current[current.len() - 1].date.clone(), count: current.len() as i64, total: money(current_total, "GBP"), }); } // One rounding per tax year, since each year has its own basic rate. let mut by_year: Vec<(String, i64)> = Vec::new(); for d in &eligible { let start = uk_tax_year(&d.date).start; match by_year.iter_mut().find(|(s, _)| *s == start) { Some(entry) => entry.1 += d.amount.minor, None => by_year.push((start, d.amount.minor)), } } by_year.sort(); let claimable: i64 = by_year .iter() .map(|(start, total)| gift_aid(&money(*total, "GBP"), start).tax_reclaimable.minor) .sum(); GiftAidClaimBatch { lines, eligible_count: eligible.len() as i64, eligible_total: money(eligible.iter().map(|d| d.amount.minor).sum(), "GBP"), claimable: money(claimable, "GBP"), excluded, } } pub fn gift_aid_donation_from_value(v: &Value) -> GiftAidDonation { GiftAidDonation { id: v.get("id").as_str().to_string(), donor_id: if v.get("donorId").is_null() { None } else { Some(v.get("donorId").as_str().to_string()) }, date: v.get("date").as_str().to_string(), amount: money_from_value(v.get("amount")), has_declaration: v.get("hasDeclaration").as_bool(), } } pub fn gift_aid_claim_batch_to_value(b: &GiftAidClaimBatch) -> Value { Value::obj(vec![ ( "lines", Value::Arr( b.lines .iter() .map(|l| { Value::obj(vec![ ("kind", Value::str(&l.kind)), ("donorId", match &l.donor_id { Some(id) => Value::str(id), None => Value::Null }), ("lastDate", Value::str(&l.last_date)), ("count", Value::Int(l.count)), ("total", money_to_value(&l.total)), ]) }) .collect(), ), ), ("eligibleCount", Value::Int(b.eligible_count)), ("eligibleTotal", money_to_value(&b.eligible_total)), ("claimable", money_to_value(&b.claimable)), ( "excluded", Value::Arr( b.excluded .iter() .map(|e| Value::obj(vec![("id", Value::str(&e.id)), ("reason", Value::str(&e.reason))])) .collect(), ), ), ]) } pub fn fune_vector(args: &[Value]) -> Value { let donations: Vec = args[0].as_arr().iter().map(gift_aid_donation_from_value).collect(); gift_aid_claim_batch_to_value(&gift_aid_claim_batch(&donations, args[1].as_str(), args[2].as_str(), args[3].as_bool())) }