Skip to main content

sioul_core/
money.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Money: exact amounts, and the amounts written in mail.
5//!
6//! Amounts are kept in cents so sums never drift. Mail writes them in every
7//! national style ("1 234,56 €", "€1,234.56", "$20.00 USD", "-8,49 €").
8//!
9//! Two readers: `find_amount`, the first amount after a word like "total" or
10//! "you received"; and `payment_amount`, for receipts, which also knows that
11//! the amount paid is stated in a sentence, on the total's line, or last, and
12//! that a company's share capital in the legal footer is never it.
13
14use crate::text::{find_word, fold};
15use crate::words::MoneyWords;
16use serde::Deserialize;
17use std::iter::Sum;
18use std::ops::{Add, AddAssign, Neg, Sub};
19
20/// An amount in cents. Written in euros in configuration files: `-250`, `68.5`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Deserialize)]
22#[serde(from = "f64")]
23pub struct Money(pub i64);
24
25impl Money {
26    pub const ZERO: Money = Money(0);
27
28    pub fn from_units(units: f64) -> Money {
29        Money((units * 100.0).round() as i64)
30    }
31
32    pub fn cents(self) -> i64 {
33        self.0
34    }
35
36    pub fn abs(self) -> Money {
37        Money(self.0.saturating_abs())
38    }
39
40    pub fn is_negative(self) -> bool {
41        self.0 < 0
42    }
43
44    /// Whole units and cents, for display: (1234, 56).
45    pub fn split(self) -> (i64, i64) {
46        let all = self.0.saturating_abs();
47        (all / 100, all % 100)
48    }
49}
50
51impl From<f64> for Money {
52    fn from(units: f64) -> Self {
53        Money::from_units(units)
54    }
55}
56
57// Saturating: a hand-written `amount = inf` (or a number past any real
58// balance) gives the largest amount, never an overflow.
59impl Add for Money {
60    type Output = Money;
61    fn add(self, other: Money) -> Money {
62        Money(self.0.saturating_add(other.0))
63    }
64}
65
66impl AddAssign for Money {
67    fn add_assign(&mut self, other: Money) {
68        self.0 = self.0.saturating_add(other.0);
69    }
70}
71
72impl Sub for Money {
73    type Output = Money;
74    fn sub(self, other: Money) -> Money {
75        Money(self.0.saturating_sub(other.0))
76    }
77}
78
79impl Neg for Money {
80    type Output = Money;
81    fn neg(self) -> Money {
82        Money(self.0.saturating_neg())
83    }
84}
85
86impl Sum for Money {
87    fn sum<I: Iterator<Item = Money>>(iter: I) -> Money {
88        iter.fold(Money::ZERO, Add::add)
89    }
90}
91
92/// An amount found in a message, with its currency (ISO 4217).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Amount {
95    pub money: Money,
96    pub currency: &'static str,
97}
98
99/// Currency marks, longest first so "EUR" is not read as something shorter.
100const CURRENCIES: &[(&str, &str)] = &[("EUR", "EUR"), ("USD", "USD"), ("GBP", "GBP"), ("CHF", "CHF"), ("CAD", "CAD"), ("€", "EUR"), ("$", "USD"), ("£", "GBP")];
101
102/// The amount a message is about: the first after a word after which it
103/// usually comes (`MoneyWords::keywords`: "total", "you received"), else the first.
104pub fn find_amount(words: &MoneyWords, text: &str) -> Option<Amount> {
105    let chars: Vec<char> = text.chars().collect();
106    let amounts = all_amounts(&chars);
107    let folded = fold(text);
108    let after = words.keywords.iter().filter_map(|k| find_word(&folded, k, 0)).min();
109    after
110        .and_then(|k| amounts.iter().find(|a| a.start >= k))
111        .or_else(|| amounts.first())
112        .map(|a| a.amount.clone())
113}
114
115
116/// An amount found in a message, where it was, and what follows it.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Found {
119    pub amount: Amount,
120    /// The text after the amount on its line: "à Exemple Retail.", "de Jean (jean@…)".
121    pub after: String,
122}
123
124/// The amount a payment message is about, in this order of trust:
125/// 1. the only amount of the subject ("SHOP: 23,98 € EUR");
126/// 2. the amount after a sentence that states the payment ("Vous avez payé 387,00 €");
127/// 3. the amount on the total's line, or on the line after its label (receipts set them in a table);
128/// 4. the last amount above zero, the way receipts end with what was paid.
129///
130/// Lines of the legal footer are skipped, so a share capital is never read
131/// as a payment. The words: `MoneyWords` (the sentences that state a
132/// payment, the labels of the total's line, the legal footer's words).
133pub fn payment_amount(words: &MoneyWords, subject: &str, text: &str) -> Option<Found> {
134    let in_subject = line_amounts(subject);
135    let distinct: Vec<&Amount> = in_subject.iter().map(|(a, _)| a).fold(Vec::new(), |mut v, a| {
136        if !v.contains(&a) {
137            v.push(a);
138        }
139        v
140    });
141    if distinct.len() == 1 {
142        return Some(Found { amount: distinct[0].clone(), after: String::new() });
143    }
144    let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).filter(|l| !is_legal(words, l)).collect();
145    stated(words, &lines).or_else(|| total(words, &lines)).or_else(|| last(&lines))
146}
147
148/// The amount stated by a sentence ("Vous avez payé 387,00 € à…"), with what follows it.
149pub fn stated_amount(words: &MoneyWords, text: &str) -> Option<Found> {
150    let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).filter(|l| !is_legal(words, l)).collect();
151    stated(words, &lines)
152}
153
154fn is_legal(words: &MoneyWords, line: &str) -> bool {
155    let folded = fold(line);
156    words.legal.iter().any(|w| find_word(&folded, w, 0).is_some())
157}
158
159fn stated(words: &MoneyWords, lines: &[&str]) -> Option<Found> {
160    lines.iter().find_map(|line| {
161        let folded = fold(line);
162        let at = words.stated.iter().filter_map(|p| find_word(&folded, p, 0)).min()?;
163        line_amounts_after(line, at).into_iter().next()
164    })
165}
166
167fn total(words: &MoneyWords, lines: &[&str]) -> Option<Found> {
168    lines.iter().enumerate().find_map(|(i, line)| {
169        let folded: String = fold(line).into_iter().collect();
170        let label = folded.trim_start_matches(|c: char| !c.is_alphanumeric());
171        let starts = |list: &[String]| list.iter().map(|t| crate::words::folded(t)).any(|t| !t.is_empty() && label.starts_with(&t));
172        let is_total = starts(&words.totals) && !starts(&words.subtotal);
173        if !is_total {
174            return None;
175        }
176        let here = line_amounts(line).into_iter().next();
177        let next = || lines.get(i + 1).and_then(|l| line_amounts(l).into_iter().next());
178        here.or_else(next).map(|(amount, after)| Found { amount, after })
179    })
180}
181
182fn last(lines: &[&str]) -> Option<Found> {
183    lines
184        .iter()
185        .flat_map(|l| line_amounts(l))
186        .filter(|(a, _)| a.money.cents() > 0)
187        .last()
188        .map(|(amount, after)| Found { amount, after })
189}
190
191/// The amounts of one line, each with the rest of the line after it.
192fn line_amounts(line: &str) -> Vec<(Amount, String)> {
193    line_amounts_after(line, 0).into_iter().map(|f| (f.amount, f.after)).collect()
194}
195
196/// The amounts of a line starting at character `from` (as `fold` counts them:
197/// folding keeps one character per character).
198fn line_amounts_after(line: &str, from: usize) -> Vec<Found> {
199    let chars: Vec<char> = line.chars().collect();
200    all_amounts(&chars)
201        .into_iter()
202        .filter(|a| a.start >= from)
203        .map(|a| Found { amount: a.amount, after: without_code(chars[a.end.min(chars.len())..].iter().collect::<String>().trim()) })
204        .collect()
205}
206
207/// "EUR à Exemple" → "à Exemple": a currency code repeated after the symbol is not what follows.
208fn without_code(after: &str) -> String {
209    CURRENCIES
210        .iter()
211        .filter(|(mark, _)| mark.len() == 3)
212        .find_map(|(mark, _)| after.strip_prefix(mark).filter(|rest| !rest.starts_with(char::is_alphanumeric)))
213        .unwrap_or(after)
214        .trim()
215        .to_string()
216}
217
218/// An amount written next to a currency mark, and where it starts and ends.
219struct Placed {
220    start: usize,
221    end: usize,
222    amount: Amount,
223}
224
225/// Every amount written next to a currency mark, in order. A minus sign right
226/// before the number makes it negative ("-8,49 €", a discount).
227fn all_amounts(chars: &[char]) -> Vec<Placed> {
228    let mut found: Vec<Placed> = Vec::new();
229    let mut i = 0;
230    while i < chars.len() {
231        match currency_at(chars, i) {
232            Some((mark_len, currency)) => {
233                let before = number_before(chars, i).map(|(start, money)| (start, i + mark_len, money));
234                let after = || number_after(chars, i + mark_len).map(|(start, money, end)| (i.min(start), end, money));
235                if let Some((start, end, money)) = before.or_else(after) {
236                    let negative = start > 0 && matches!(chars[start - 1], '-' | '−');
237                    let money = if negative { -money } else { money };
238                    found.push(Placed { start: if negative { start - 1 } else { start }, end, amount: Amount { money, currency } });
239                }
240                i += mark_len;
241            }
242            None => i += 1,
243        }
244    }
245    found.sort_by_key(|p| p.start);
246    // "€12,00 EUR" is one amount with two marks: the second mark ends it.
247    let mut merged: Vec<Placed> = Vec::new();
248    for p in found {
249        match merged.last_mut() {
250            Some(previous) if previous.amount == p.amount && p.start <= previous.end + 1 => previous.end = previous.end.max(p.end),
251            _ => merged.push(p),
252        }
253    }
254    merged
255}
256
257/// A currency mark starting at `i`: a symbol, or a code that is a whole word.
258fn currency_at(chars: &[char], i: usize) -> Option<(usize, &'static str)> {
259    CURRENCIES.iter().find_map(|(mark, code)| {
260        let mark: Vec<char> = mark.chars().collect();
261        let matches = chars.get(i..i + mark.len()) == Some(&mark[..]);
262        let is_code = mark.len() == 3;
263        let bounded = !is_code
264            || ((i == 0 || !chars[i - 1].is_alphanumeric()) && chars.get(i + 3).is_none_or(|c| !c.is_alphanumeric()));
265        (matches && bounded).then_some((mark.len(), *code))
266    })
267}
268
269/// Characters that can be inside a written number: digits, separators, spaces.
270fn in_number(c: char) -> bool {
271    c.is_ascii_digit() || matches!(c, '.' | ',' | ' ' | '\u{a0}' | '\u{202f}' | '\'')
272}
273
274/// The number that ends just before position `end` (one space allowed).
275fn number_before(chars: &[char], end: usize) -> Option<(usize, Money)> {
276    let mut stop = end;
277    if stop > 0 && matches!(chars[stop - 1], ' ' | '\u{a0}' | '\u{202f}') {
278        stop -= 1;
279    }
280    let mut start = stop;
281    while start > 0 && in_number(chars[start - 1]) {
282        start -= 1;
283    }
284    // Separators and spaces before the first digit are not the number's;
285    // counted in characters, as `start` is (a narrow no-break space is three bytes).
286    let skipped = chars[start..stop].iter().take_while(|c| !c.is_ascii_digit()).count();
287    let text: String = chars[start + skipped..stop].iter().collect();
288    parse_number(text.trim()).map(|m| (start + skipped, m))
289}
290
291/// The number that starts just after position `start` (one space allowed), and where it ends.
292fn number_after(chars: &[char], start: usize) -> Option<(usize, Money, usize)> {
293    let mut begin = start;
294    if chars.get(begin).is_some_and(|c| matches!(c, ' ' | '\u{a0}' | '\u{202f}')) {
295        begin += 1;
296    }
297    let mut end = begin;
298    while end < chars.len() && in_number(chars[end]) {
299        end += 1;
300    }
301    let text: String = chars[begin..end].iter().collect();
302    let trimmed = text.trim_end();
303    parse_number(trimmed.trim()).map(|m| (begin, m, begin + trimmed.chars().count()))
304}
305
306/// "1 234,56", "1,234.56", "25,00", "12,5", "1250", "20.00" → cents.
307///
308/// The last `,` or `.` followed by one or two digits only is the decimal
309/// mark; every other separator groups thousands (by threes).
310fn parse_number(text: &str) -> Option<Money> {
311    let text = text.trim_end_matches(['.', ',', ' ']);
312    if !text.chars().next().is_some_and(|c| c.is_ascii_digit()) {
313        return None;
314    }
315    let decimal_at = text.rfind(['.', ',']).filter(|&p| {
316        let tail = &text[p + 1..];
317        (1..=2).contains(&tail.len()) && tail.chars().all(|c| c.is_ascii_digit())
318    });
319    let (whole, cents) = match decimal_at {
320        Some(p) if text.len() - p == 2 => (&text[..p], text[p + 1..].parse::<i64>().ok()? * 10),
321        Some(p) => (&text[..p], text[p + 1..].parse::<i64>().ok()?),
322        None => (text, 0),
323    };
324    // A run of digits too long for any amount is no amount.
325    let units: i64 = grouped_digits(whole).parse().ok()?;
326    Some(Money(units.checked_mul(100)?.checked_add(cents)?))
327}
328
329/// The digits of a whole part whose thousands are grouped by threes: "1 234" →
330/// "1234". When the groups are not threes ("3 25", a reference then a price),
331/// only the last group is the number.
332fn grouped_digits(whole: &str) -> String {
333    let groups: Vec<&str> = whole.split(['.', ',', ' ', '\u{a0}', '\u{202f}', '\'']).filter(|g| !g.is_empty()).collect();
334    let well_grouped = groups.first().is_some_and(|g| g.len() <= 3) && groups.iter().skip(1).all(|g| g.len() == 3);
335    if well_grouped || groups.len() == 1 { groups.concat() } else { groups.last().map_or_else(String::new, |g| (*g).to_string()) }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::words::Words;
342
343    /// The readers with the packs built in (French and English).
344    fn find_amount(text: &str) -> Option<Amount> {
345        super::find_amount(&Words::builtin().money, text)
346    }
347
348    fn payment_amount(subject: &str, text: &str) -> Option<Found> {
349        super::payment_amount(&Words::builtin().money, subject, text)
350    }
351
352    fn amount(text: &str) -> (i64, &'static str) {
353        let a = find_amount(text).unwrap();
354        (a.money.cents(), a.currency)
355    }
356
357    #[test]
358    fn national_styles() {
359        assert_eq!(amount("Vous avez reçu un paiement de 25,00 € EUR de Jean Exemple"), (2500, "EUR"));
360        assert_eq!(amount("A payout of €1,234.56 is on its way"), (123456, "EUR"));
361        assert_eq!(amount("Plan Pro … Total $20.00 USD"), (2000, "USD"));
362        assert_eq!(amount("Montant TTC\u{a0}: 1\u{202f}234,00\u{a0}€"), (123400, "EUR"));
363        assert_eq!(amount("Prix 12 EUR"), (1200, "EUR"));
364        assert_eq!(amount("N° 3 25,00 €"), (2500, "EUR"));
365    }
366
367    #[test]
368    fn the_amount_after_the_keyword() {
369        assert_eq!(amount("Abonnement 5,00 € par mois. Total : 60,00 €"), (6000, "EUR"));
370        assert!(find_amount("Votre code est 482913").is_none());
371    }
372
373    fn paid(subject: &str, text: &str) -> Option<i64> {
374        payment_amount(subject, text).map(|f| f.amount.money.cents())
375    }
376
377    #[test]
378    fn what_was_paid_in_a_receipt() {
379        // The subject says it.
380        assert_eq!(paid("SHOP: 23,98 € EUR", "Prix 19,99 €\nLivraison 3,99 €"), Some(2398));
381        // A sentence says it, and who it went to.
382        let found = payment_amount("Reçu", "Merci.\nVous avez payé 387,00 € EUR à Exemple Retail.\n307,50 €").unwrap();
383        assert_eq!(found.amount.money.cents(), 38700);
384        assert_eq!(found.after, "à Exemple Retail.");
385        // The total's label, with the amount on the next line, after a discount.
386        assert_eq!(paid("Commande confirmée", "84,99€\n-8,49€\nSous-total\n76,50€\nTotal\n76,50€\nVous avez économisé 8,49€"), Some(7650));
387        // Without labels, the last amount above zero; a discount is negative.
388        assert_eq!(paid("Reçu de votre paiement", "84,99 € EUR\n0,00 € EUR\n-8,49 € EUR\n76,50 € EUR"), Some(7650));
389        // A share capital in the footer is never the payment.
390        assert_eq!(paid("Message de notification", "Un message vous attend.\nExemple SA au capital de 1 000 000 € - RCS Paris"), None);
391        // One amount with two marks.
392        assert_eq!(paid("Paiement reçu", "Vous avez reçu un paiement de €12,00 EUR de Jean Exemple (jean@example.org)."), Some(1200));
393    }
394
395    #[test]
396    fn untrusted_text_never_panics() {
397        // Narrow no-break spaces are three bytes each: the start is counted in characters.
398        assert_eq!(amount("\u{202f}\u{202f}\u{202f}\u{202f}1 €"), (100, "EUR"));
399        assert_eq!(amount("Prix\u{a0}\u{a0}\u{a0}\u{a0}: 12 €"), (1200, "EUR"));
400        // A digit run no amount reaches is none.
401        assert!(find_amount("€ 99999999999999999").is_none());
402        assert!(find_amount("€ 999999999999999999999999").is_none());
403        // One decimal digit is decimals, not a thousands group.
404        assert_eq!(amount("Total : 12,5 €"), (1250, "EUR"));
405        assert_eq!(amount("Total. 12 €"), (1200, "EUR"));
406    }
407
408    #[test]
409    fn exact_sums() {
410        let total: Money = [Money::from_units(0.1), Money::from_units(0.2)].into_iter().sum();
411        assert_eq!(total, Money(30));
412        assert_eq!(Money::from_units(-1234.56).split(), (1234, 56));
413        // An infinite amount written by hand saturates; sums never overflow.
414        let huge = Money::from_units(f64::INFINITY);
415        assert_eq!((huge + huge, -Money(i64::MIN), Money(i64::MIN).abs()), (Money(i64::MAX), Money(i64::MAX), Money(i64::MAX)));
416    }
417}