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
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 errortotal (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 errortotal 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 errortotal 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 errorA 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 errorA 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 errorThe 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/money_split.qwer$ curl -O https://quillolang.org/plugins/files/money_split.qweThen, in your program, all of it or only what you call:
use money_split.qweruse money_split_ratios, money_split_even from money_split // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// 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 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 }))}Related
finance.qwer · 1.0.0Loan payments and amortization tables, compound growth with deposits, net present value and internal rate of return, in cents.7 views · 6 downloadsISO 20022 bank files iso20022.qwer · 1.0.0ISO 20022 bank files: camt.053 statements (and camt.052 and 054) read into balances, entries and each payment's counterparty and reference, and pain.001 SEPA credit transfers built with checked IBANs and exact totals.7 views · 6 downloadsMT940 mt940.qwer · 1.0.0MT940 bank statements: accounts, opening and closing balances, and each transaction with its date, amount and counterparty.8 views · 6 downloadsCurrencies currencies.qwer · 1.0.0ISO 4217 currencies: names, symbols, minor units and the countries using them, prices formatted, and amounts turned into the cents payment APIs take.6 views · 7 downloadsBig numbers bignum.qwer · 1.0.0Integers and fractions of any size, exact: factorials, powers, gcd, modular powers, and fractions such as 1/3 + 1/6 = 1/2 with no rounding.8 views · 7 downloadsFractions fractions.qwer · 1.0.0Exact fractions for recipes, carpentry and school maths: "1 1/2", "3/8" and "12.5%" read and calculated without rounding, expressions worked out, decimals turned into simple fractions, measures rounded to 1/16 inch, and recipes scaled.7 views · 9 downloadsThread 0 posts
On Money split: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.