Numbers

Money split

Money split fairly in whole cents, the parts always adding up: by ratios, evenly, into dated instalments, a restaurant bill by who had what with tax and tip, a group's shared expenses settled in few payments, and a plan prorated by days.

Version 1.0.0 · 6 functions · 0 crates

#money#payments#accounting#calculators

Functions

What use money_split.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use money_split_ratios, money_split_even from money_split (Quillo 0.6).

money_split_ratios(total: int, ratios: list) → listor an error

total (in minor units: cents) split by ratios ([1, 1, 1], [50, 30, 20], [0.6, 0.4]) with the largest-remainder rule; the shares add up to total exactly. A negative total (a refund) splits the same way.

money_split_even(total: int, parts: int) → listor an error

total split into parts equal shares, the extra cents to the first ones: 1000 in 3 is 334, 333, 333.

money_split_installments(total: int, count: int, options: any) → listor an error

total paid in count instalments: equal, the leftover cents on the first (remainder: "first") or the last ("last"); with start (YYYY-MM-DD) and every ("month", "week", "day") each gets its due date. Gives [{ n, amount, due }].

money_split_bill(items: list, people: list, options: any) → anyor an error

A restaurant bill split by who had what: items are { name, price (cents), shared_by: ["Ann", "Bo"] } (empty or missing: everyone at the table), people the table. options: { tax_percent, tip_percent, service (cents, shared evenly), discount (cents, in proportion) }. Tax and tip go in proportion to what each had. Gives { people: [{ name, items, subtotal, tax, tip, service, discount, total }], totals }; every column adds up to the bill's own.

money_split_settle(expenses: list, people: list) → anyor an error

A group's shared expenses settled: each expense is { paid_by, amount (cents), between: [names] (everyone when missing), weights: [..] (optional, as between) }. Gives { balances: { name: cents (positive: is owed) }, payments: [{ from, to, amount }] }, the debts paid in at most one payment fewer than the people involved.

money_split_prorate(total: int, period_start: str, period_end: str, from: str, to: str) → anyor an error

The part of a period's price for the days used: total for period_start to period_end (dates, the end not counted), used from from to to. Rounded half up to the cent. Gives { amount, days_used, days_in_period }; for a mid-month upgrade, credit the old plan's unused days and charge the new plan's.

Get it

$ quillo install money_split               # money_split.qwer into this folder$ quillo install money_split --example     # and its example, money_split.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/money_split.qwer$ curl -O https://quillolang.org/plugins/files/money_split.qwe

Then, in your program, all of it or only what you call:

main.qwe
use money_split.qweruse money_split_ratios, money_split_even from money_split      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

money_split.qwe
// Money split to the cent, the parts always adding up: partners' profit by their shares, a// subscription in instalments, a dinner bill by who had what (tax and tip in proportion), a// holiday's shared costs settled in few payments, and a plan changed mid-month. All in cents.////   quillo run money_split.qwe      then open http://localhost:8080/split and http://localhost:8080/dinneruse "money_split.qwer"route GET /split = {    profit: money_split_ratios(100000, [50.0, 30.0, 20.0])?,    thirds: money_split_ratios(100, [1.0, 1.0, 1.0])?,    refund: money_split_even(-1000, 3)?,    plan: money_split_installments(100000, 3, { start: "2026-01-31", every: "month", remainder: "last" })?,    upgrade: money_split_prorate(3000, "2026-11-01", "2026-12-01", "2026-11-18", "2026-12-01")?,}route GET /dinner = money_split_bill([    { name: "Pizza", price: 1450, shared_by: ["Ann", "Bo"] },    { name: "Salad", price: 990, shared_by: ["Cy"] },    { name: "Wine", price: 3200 },    { name: "Tiramisu", price: 750, shared_by: ["Ann"] },], ["Ann", "Bo", "Cy"], { tax_percent: 8.875, tip_percent: 18.0 })?route GET /holiday = money_split_settle([    { paid_by: "Ann", amount: 42000, between: ["Ann", "Bo", "Cy", "Dee"] },    { paid_by: "Bo", amount: 9000 },    { paid_by: "Cy", amount: 12000, between: ["Cy", "Dee"], weights: [1.0, 2.0] },], ["Ann", "Bo", "Cy", "Dee"])?
$ quillo run money_split.qwe$ curl 'localhost:8080/split'$ curl 'localhost:8080/dinner'$ curl 'localhost:8080/holiday'

Source

money_split.qwer
//! Money split fairly in whole cents, so the parts always add up: by ratios with the largest-remainder rule, evenly, into instalments with due dates, a restaurant bill by who had what with tax and tip shared in proportion, a group's shared expenses settled in the fewest payments, and a subscription prorated by days.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Map, Value};/// Shares of `total` (minor units) in proportion to `weights`, by the largest remainder: each gets its floor, the units left go to the largest fractions (the earliest on a tie).fn largest_remainder(total: i64, weights: &[f64]) -> Result<Vec<i64>, String> {    if weights.is_empty() {        return Err("give at least one ratio".into());    }    if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {        return Err("ratios are 0 or more".into());    }    let sum: f64 = weights.iter().sum();    if sum <= 0.0 {        return Err("the ratios add up to more than 0".into());    }    let neg = total < 0;    let t = total.unsigned_abs() as i128;    // Exact for whole-number ratios: work in integers scaled by 10^6.    let w: Vec<i128> = weights.iter().map(|x| (x * 1e6).round() as i128).collect();    let ws: i128 = w.iter().sum();    let mut shares: Vec<i128> = w.iter().map(|x| t * x / ws).collect();    let rem: Vec<i128> = w.iter().map(|x| t * x % ws).collect();    let left = t - shares.iter().sum::<i128>();    let mut order: Vec<usize> = (0..w.len()).collect();    order.sort_by(|a, b| rem[*b].cmp(&rem[*a]).then(a.cmp(b)));    for i in order.into_iter().take(left as usize) {        shares[i] += 1;    }    Ok(shares.into_iter().map(|s| if neg { -(s as i64) } else { s as i64 }).collect())}/// `total` (in minor units: cents) split by `ratios` (`[1, 1, 1]`, `[50, 30, 20]`, `[0.6, 0.4]`) with the largest-remainder rule; the shares add up to `total` exactly. A negative total (a refund) splits the same way.pub fn money_split_ratios(total: i64, ratios: Vec<f64>) -> Result<Vec<i64>, String> {    largest_remainder(total, &ratios)}/// `total` split into `parts` equal shares, the extra cents to the first ones: 1000 in 3 is 334, 333, 333.pub fn money_split_even(total: i64, parts: i64) -> Result<Vec<i64>, String> {    if !(1..=100_000).contains(&parts) {        return Err("1 to 100,000 parts".into());    }    largest_remainder(total, &vec![1.0; parts as usize])}fn ymd(s: &str) -> Result<(i32, u32, u32), String> {    let p: Vec<&str> = s.split('-').collect();    if p.len() != 3 {        return Err(format!("{:?} is not a date (YYYY-MM-DD)", s));    }    Ok((p[0].parse().map_err(|_| "bad year")?, p[1].parse().map_err(|_| "bad month")?, p[2].parse().map_err(|_| "bad day")?))}fn days_in(y: i32, m: u32) -> u32 {    match m {        2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,        2 => 28,        4 | 6 | 9 | 11 => 30,        _ => 31,    }}/// Days from 1970-01-01 (proleptic Gregorian).fn day_number(y: i32, m: u32, d: u32) -> i64 {    let (y, m) = if m <= 2 { (y as i64 - 1, m as i64 + 9) } else { (y as i64, m as i64 - 3) };    let era = y.div_euclid(400);    let yoe = y - era * 400;    let doy = (153 * m + 2) / 5 + d as i64 - 1;    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;    era * 146097 + doe - 719468}fn from_day(n: i64) -> String {    let z = n + 719468;    let era = z.div_euclid(146097);    let doe = z - era * 146097;    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;    let y = yoe + era * 400;    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);    let mp = (5 * doy + 2) / 153;    let d = doy - (153 * mp + 2) / 5 + 1;    let m = if mp < 10 { mp + 3 } else { mp - 9 };    format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)}/// The date `k` steps after `start`: months keep the day (the 31st becomes the month's last day), weeks and days add up.fn step(start: &str, every: &str, k: i64) -> Result<String, String> {    let (y, m, d) = ymd(start)?;    Ok(match every {        "month" | "monthly" => {            let total = y as i64 * 12 + (m as i64 - 1) + k;            let (yy, mm) = (total.div_euclid(12) as i32, (total.rem_euclid(12) + 1) as u32);            format!("{:04}-{:02}-{:02}", yy, mm, d.min(days_in(yy, mm)))        }        "week" | "weekly" => from_day(day_number(y, m, d) + 7 * k),        "day" | "daily" => from_day(day_number(y, m, d) + k),        other => return Err(format!("every {:?}: month, week or day", other)),    })}/// `total` paid in `count` instalments: equal, the leftover cents on the first (`remainder: "first"`) or the last (`"last"`); with `start` (YYYY-MM-DD) and `every` ("month", "week", "day") each gets its due date./// Gives `[{ n, amount, due }]`.pub fn money_split_installments(total: i64, count: i64, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    let mut parts = money_split_even(total, count)?;    if options["remainder"].as_str() == Some("last") {        parts.reverse();    }    let every = options["every"].as_str().unwrap_or("month");    parts        .iter()        .enumerate()        .map(|(k, a)| {            let due = match options["start"].as_str() {                Some(s) => json!(step(s, every, k as i64)?),                None => Value::Null,            };            Ok(json!({ "n": k + 1, "amount": a, "due": due }))        })        .collect()}/// A restaurant bill split by who had what: `items` are `{ name, price (cents), shared_by: ["Ann", "Bo"] }` (empty or missing: everyone at the table), `people` the table./// `options`: `{ tax_percent, tip_percent, service (cents, shared evenly), discount (cents, in proportion) }`. Tax and tip go in proportion to what each had./// Gives `{ people: [{ name, items, subtotal, tax, tip, service, discount, total }], totals }`; every column adds up to the bill's own.pub fn money_split_bill(items: Vec<serde_json::Value>, people: Vec<String>, options: serde_json::Value) -> Result<serde_json::Value, String> {    if people.is_empty() {        return Err("who is at the table?".into());    }    let n = people.len();    let mut sub = vec![0i64; n];    let mut lines: Vec<Vec<Value>> = vec![vec![]; n];    for it in &items {        let price = it["price"].as_i64().ok_or_else(|| format!("an item needs its price in cents: {}", it))?;        let who: Vec<usize> = match it["shared_by"].as_array() {            Some(a) if !a.is_empty() => a.iter().map(|w| {                let w = w.as_str().unwrap_or("");                people.iter().position(|p| p == w).ok_or_else(|| format!("{:?} is not at the table", w))            }).collect::<Result<_, _>>()?,            _ => (0..n).collect(),        };        let shares = largest_remainder(price, &vec![1.0; who.len()])?;        for (k, i) in who.iter().enumerate() {            sub[*i] += shares[k];            lines[*i].push(json!({ "name": it["name"], "share": shares[k] }));        }    }    let subtotal: i64 = sub.iter().sum();    let weights: Vec<f64> = if subtotal == 0 { vec![1.0; n] } else { sub.iter().map(|s| *s as f64).collect() };    let pct = |k: &str| options[k].as_f64().unwrap_or(0.0);    let discount_total = options["discount"].as_i64().unwrap_or(0).min(subtotal);    let discount = largest_remainder(discount_total, &weights)?;    let after: i64 = subtotal - discount_total;    let tax = largest_remainder((after as f64 * pct("tax_percent") / 100.0).round() as i64, &weights)?;    let tip = largest_remainder((after as f64 * pct("tip_percent") / 100.0).round() as i64, &weights)?;    let service = largest_remainder(options["service"].as_i64().unwrap_or(0), &vec![1.0; n])?;    let rows: Vec<Value> = (0..n)        .map(|i| json!({ "name": people[i], "items": lines[i], "subtotal": sub[i], "discount": discount[i], "tax": tax[i], "tip": tip[i], "service": service[i], "total": sub[i] - discount[i] + tax[i] + tip[i] + service[i] }))        .collect();    let col = |k: &str| rows.iter().map(|r| r[k].as_i64().unwrap_or(0)).sum::<i64>();    Ok(json!({ "people": rows, "totals": { "subtotal": col("subtotal"), "discount": col("discount"), "tax": col("tax"), "tip": col("tip"), "service": col("service"), "total": col("total") } }))}/// A group's shared expenses settled: each expense is `{ paid_by, amount (cents), between: [names] (everyone when missing), weights: [..] (optional, as between) }`./// Gives `{ balances: { name: cents (positive: is owed) }, payments: [{ from, to, amount }] }`, the debts paid in at most one payment fewer than the people involved.pub fn money_split_settle(expenses: Vec<serde_json::Value>, people: Vec<String>) -> Result<serde_json::Value, String> {    let mut names = people.clone();    let mut bal: Vec<i64> = vec![0; names.len()];    let mut idx = |n: &str, names: &mut Vec<String>, bal: &mut Vec<i64>| -> usize {        match names.iter().position(|x| x == n) {            Some(i) => i,            None => {                names.push(n.to_string());                bal.push(0);                names.len() - 1            }        }    };    for e in &expenses {        let payer = e["paid_by"].as_str().ok_or_else(|| format!("an expense needs paid_by: {}", e))?;        let amount = e["amount"].as_i64().ok_or_else(|| format!("an expense needs its amount in cents: {}", e))?;        let between: Vec<String> = match e["between"].as_array() {            Some(a) if !a.is_empty() => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),            _ => {                if names.is_empty() {                    return Err("say who shares each expense, or give the people".into());                }                people.clone()            }        };        let weights: Vec<f64> = match e["weights"].as_array() {            Some(w) if w.len() == between.len() => w.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(),            Some(_) => return Err("weights go with `between`, one each".into()),            None => vec![1.0; between.len()],        };        let shares = largest_remainder(amount, &weights)?;        let p = idx(payer, &mut names, &mut bal);        bal[p] += amount;        for (k, b) in between.iter().enumerate() {            let i = idx(b, &mut names, &mut bal);            bal[i] -= shares[k];        }    }    // Greedy: the one owed most is paid by the one owing most, until all are even.    let mut left = bal.clone();    let mut payments = vec![];    loop {        let (ci, cmax) = left.iter().enumerate().max_by_key(|(_, v)| **v).map(|(i, v)| (i, *v)).unwrap_or((0, 0));        let (di, dmin) = left.iter().enumerate().min_by_key(|(_, v)| **v).map(|(i, v)| (i, *v)).unwrap_or((0, 0));        if cmax <= 0 || dmin >= 0 {            break;        }        let pay = cmax.min(-dmin);        payments.push(json!({ "from": names[di], "to": names[ci], "amount": pay }));        left[ci] -= pay;        left[di] += pay;    }    let balances: Map<String, Value> = names.iter().cloned().zip(bal.iter().map(|b| json!(b))).collect();    Ok(json!({ "balances": balances, "payments": payments }))}/// The part of a period's price for the days used: `total` for `period_start` to `period_end` (dates, the end not counted), used from `from` to `to`. Rounded half up to the cent./// Gives `{ amount, days_used, days_in_period }`; for a mid-month upgrade, credit the old plan's unused days and charge the new plan's.pub fn money_split_prorate(total: i64, period_start: &str, period_end: &str, from: &str, to: &str) -> Result<serde_json::Value, String> {    let d = |s: &str| -> Result<i64, String> {        let (y, m, dd) = ymd(s)?;        Ok(day_number(y, m, dd))    };    let (ps, pe, f, t) = (d(period_start)?, d(period_end)?, d(from)?, d(to)?);    if pe <= ps {        return Err("the period ends after it starts".into());    }    let used = (t.min(pe) - f.max(ps)).max(0);    let days = pe - ps;    let amount = ((total as i128 * used as i128 * 2 + days as i128) / (2 * days as i128)) as i64;    Ok(json!({ "amount": amount, "days_used": used, "days_in_period": days }))}
Thread
78Useful? Like it, or add to its thread below.

Thread 0 posts

On Money split: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.