1use 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#[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 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
57impl 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#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Amount {
95 pub money: Money,
96 pub currency: &'static str,
97}
98
99const CURRENCIES: &[(&str, &str)] = &[("EUR", "EUR"), ("USD", "USD"), ("GBP", "GBP"), ("CHF", "CHF"), ("CAD", "CAD"), ("€", "EUR"), ("$", "USD"), ("£", "GBP")];
101
102pub 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#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Found {
119 pub amount: Amount,
120 pub after: String,
122}
123
124pub 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
148pub 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
191fn line_amounts(line: &str) -> Vec<(Amount, String)> {
193 line_amounts_after(line, 0).into_iter().map(|f| (f.amount, f.after)).collect()
194}
195
196fn 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
207fn 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
218struct Placed {
220 start: usize,
221 end: usize,
222 amount: Amount,
223}
224
225fn 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 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
257fn 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
269fn in_number(c: char) -> bool {
271 c.is_ascii_digit() || matches!(c, '.' | ',' | ' ' | '\u{a0}' | '\u{202f}' | '\'')
272}
273
274fn 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 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
291fn 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
306fn 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 let units: i64 = grouped_digits(whole).parse().ok()?;
326 Some(Money(units.checked_mul(100)?.checked_add(cents)?))
327}
328
329fn 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 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 assert_eq!(paid("SHOP: 23,98 € EUR", "Prix 19,99 €\nLivraison 3,99 €"), Some(2398));
381 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 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 assert_eq!(paid("Reçu de votre paiement", "84,99 € EUR\n0,00 € EUR\n-8,49 € EUR\n76,50 € EUR"), Some(7650));
389 assert_eq!(paid("Message de notification", "Un message vous attend.\nExemple SA au capital de 1 000 000 € - RCS Paris"), None);
391 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 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 assert!(find_amount("€ 99999999999999999").is_none());
402 assert!(find_amount("€ 999999999999999999999999").is_none());
403 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 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}