Functional Weave
Code in Rust

charity.reclaim-batch@1.0.1

impl/rust.rs

6,976 bytes · the Rust 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charity_gift_aid::gift_aid;  ← from charity.gift-aid ^1.0.0 · built alongside by fune
use super::charity_reclaim_batch_data::AGGREGATION;  ← this capability’s own data, compiled from data/aggregation.json into the same file by fune build
use super::dates_uk_tax_year::uk_tax_year;  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value};  ← from money.amount ^1.0.0 · built alongside by fune

/// 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<ExcludedDonation> = 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<ClaimLine> = 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<GiftAidDonation> = 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()))
}