Skip to main content

sioul_core/
payments.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail about money: a payment made or received, a bill, an order, a refund.
5//!
6//! Read from the subject, then the start of the text, in French and English:
7//! the payment is stated at the top, and footers say things like "vous avez
8//! reçu ce message". A detection only proposes a budget line; nothing counts
9//! until a rule, or you, says so (docs/accounting.md). Newsletters, activity
10//! reports and shipping notices are not payments. The words looked for are
11//! the `payments` and `money` lists of the word packs in use, with your
12//! changes (`words::Payments`, docs/words.md).
13
14use crate::card::Card;
15use crate::money::{self, Amount};
16use crate::text::{find_word, fold};
17use crate::words::Words;
18use serde::Serialize;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum PaymentKind {
23    /// Money that came in: a payment, a donation, a payout.
24    Received,
25    /// A payment you made: a receipt, a debit, a bill settled.
26    Paid,
27    /// A merchant's order confirmation: often the same payment a processor reports.
28    Order,
29    /// A bill to pay.
30    Bill,
31    /// Money given back.
32    Refund,
33}
34
35impl PaymentKind {
36    /// Money in or out.
37    pub fn is_credit(self) -> bool {
38        matches!(self, PaymentKind::Received | PaymentKind::Refund)
39    }
40
41    /// The word for it, as an id the translator knows ("kind-received").
42    pub fn message_id(self) -> &'static str {
43        match self {
44            PaymentKind::Received => "kind-received",
45            PaymentKind::Paid => "kind-paid",
46            PaymentKind::Order => "kind-order",
47            PaymentKind::Bill => "kind-bill",
48            PaymentKind::Refund => "kind-refund",
49        }
50    }
51}
52
53/// What a message says about money.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Payment {
56    pub kind: PaymentKind,
57    /// Without sign: the kind gives the direction. None when the message gives no amount
58    /// (a bill to read in a customer space).
59    pub amount: Option<Amount>,
60    /// Who paid or was paid, as the message names them; else the sender.
61    pub party: String,
62}
63
64/// Characters of the text read: the payment is stated at the top.
65const READ: usize = 2500;
66
67/// What a message says about money, if it says something: subjects about
68/// money that are no payment left out (`Payments::not_payments`), then the
69/// phrases of each kind, tried in this order: a refund, a payment made,
70/// money received, an order, a bill (a receipt "pour votre paiement" must not
71/// be read as a payment received, nor the settlement of a bill as the bill).
72pub fn detect(words: &Words, card: &Card) -> Option<Payment> {
73    if card.is_list {
74        return None;
75    }
76    let subject = fold(&card.subject);
77    if words.payments.not_payments.iter().any(|p| find_word(&subject, p, 0).is_some()) {
78        return None;
79    }
80    let start: String = card.excerpt.chars().take(READ).collect();
81    let kind = kind_in(words, &subject).or_else(|| order_subject(words, &subject)).or_else(|| kind_in(words, &fold(&start)))?;
82    Some(read(words, card, kind, &start))
83}
84
85/// The amount and the party, for a message known to be about money (a rule says so).
86/// The party comes from the sentence stating the payment, else from a receipt's
87/// label ("Paiement à" and the name under it), else it is the sender.
88pub fn read(words: &Words, card: &Card, kind: PaymentKind, start: &str) -> Payment {
89    let found = money::payment_amount(&words.money, &card.subject, start);
90    let party = money::stated_amount(&words.money, start)
91        .and_then(|f| party(&words.payments.leads, &f.after))
92        .or_else(|| labelled_party(words, start, kind))
93        .unwrap_or_else(|| card.sender().to_string());
94    let amount = found.map(|f| Amount { money: f.amount.money.abs(), currency: f.amount.currency });
95    Payment { kind, amount, party }
96}
97
98/// Receipts set who was paid, or who paid, under a label: "Paiement à" then "Exemple Web".
99fn labelled_party(words: &Words, text: &str, kind: PaymentKind) -> Option<String> {
100    let labels: Vec<String> = if kind.is_credit() { &words.payments.from } else { &words.payments.to }.iter().map(|w| crate::words::folded(w)).collect();
101    let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
102    lines.windows(2).find_map(|pair| {
103        let label: String = fold(pair[0]).into_iter().collect();
104        let label = label.trim_end_matches([':', ' ', '\u{a0}', '\u{202f}']);
105        let name = pair[1];
106        (labels.iter().any(|l| l == label) && name.chars().count() <= 60 && !name.contains('@')).then(|| name.to_string())
107    })
108}
109
110/// The start of a message's text, as `detect` reads it.
111pub fn start(card: &Card) -> String {
112    card.excerpt.chars().take(READ).collect()
113}
114
115fn kind_in(words: &Words, folded: &[char]) -> Option<PaymentKind> {
116    let p = &words.payments;
117    [(PaymentKind::Refund, &p.refund), (PaymentKind::Paid, &p.paid), (PaymentKind::Received, &p.received), (PaymentKind::Order, &p.order), (PaymentKind::Bill, &p.bill)]
118        .into_iter()
119        .find(|(_, phrases)| phrases.iter().any(|w| find_word(folded, w, 0).is_some()))
120        .map(|(kind, _)| kind)
121}
122
123/// "Commande FR000000 confirmée": the order and its confirmation, apart.
124fn order_subject(words: &Words, folded: &[char]) -> Option<PaymentKind> {
125    let said = |list: &[String]| list.iter().any(|w| find_word(folded, w, 0).is_some());
126    (said(&words.payments.order_words) && said(&words.payments.confirmed_words)).then_some(PaymentKind::Order)
127}
128
129/// Who the payment went to or came from, in what follows its amount: "à Exemple
130/// Retail.", "en faveur de SHOP", "de Jean Exemple (jean@example.org)".
131/// `leads`: the words before the name (`Payments::leads`), the longest tried
132/// first ("de la part de" before "de"), a space after each.
133fn party(leads: &[String], after: &str) -> Option<String> {
134    let mut leads: Vec<&str> = leads.iter().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
135    leads.sort_by_key(|l| std::cmp::Reverse(l.chars().count()));
136    let rest = leads.iter().find_map(|lead| after.strip_prefix(lead).and_then(|r| r.strip_prefix(' ')))?.trim();
137    // A payer known only by an address: "de (shop@example.org)".
138    let inside = rest.strip_prefix(['(', '<']).and_then(|r| r.split([')', '>']).next());
139    let name = inside.unwrap_or_else(|| rest.split(['(', '<', ',', '\n']).next().unwrap_or("")).trim().trim_end_matches('.').trim();
140    (!name.is_empty() && name.chars().count() <= 60).then(|| name.to_string())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    /// What mail says about money, with the packs built in and one payment
148    /// processor's wording added, as its users add it (`words::with_processor`).
149    fn detect(card: &Card) -> Option<Payment> {
150        super::detect(&crate::words::with_processor(), card)
151    }
152
153    fn card(from: &str, subject: &str, body: &str) -> Card {
154        let raw = format!("From: {from}\r\nSubject: {subject}\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{body}\r\n");
155        Card::from_bytes(raw.as_bytes()).unwrap()
156    }
157
158    const PAY: &str = "Pay Exemple <service@pay.example>";
159
160    fn cents(p: &Payment) -> Option<i64> {
161        p.amount.as_ref().map(|a| a.money.cents())
162    }
163
164    /// A processor's own wording is no shipped word: its authorisation is read once added.
165    #[test]
166    fn a_processors_wording_once_added() {
167        let authorised = card("Pay Exemple <service@pay.example>", "SHOP: 23,98 € EUR", "Vous avez autorisé un paiement de 23,98 € EUR en faveur de SHOP");
168        assert_eq!(super::detect(&crate::words::Words::builtin(), &authorised), None);
169        assert_eq!(detect(&authorised).map(|p| (p.kind, p.party)), Some((PaymentKind::Paid, "SHOP".to_string())));
170        // A receipt in plain words is read with the packs alone.
171        let paid = card("Shop <shop@example.org>", "Votre paiement", "Vous avez payé 387,00 € à Exemple Retail.");
172        assert_eq!(super::detect(&crate::words::Words::builtin(), &paid).map(|p| p.kind), Some(PaymentKind::Paid));
173    }
174
175    #[test]
176    fn payments_received_and_made() {
177        let received = detect(&card(
178            "Pay Exemple <service@pay.example>",
179            "Notification de paiement reçu",
180            "Vous avez reçu un paiement de €12,00 EUR de Jean Exemple (jean@example.org).\r\nVous avez reçu ce message car…",
181        ))
182        .unwrap();
183        assert_eq!((received.kind, cents(&received), received.party.as_str()), (PaymentKind::Received, Some(1200), "Jean Exemple"));
184        let paid = detect(&card("Pay Exemple <service@pay.example>", "Reçu pour votre paiement à Exemple Retail", "Vous avez payé 387,00 € EUR à Exemple Retail.\r\n307,50 €")).unwrap();
185        assert_eq!((paid.kind, cents(&paid), paid.party.as_str()), (PaymentKind::Paid, Some(38700), "Exemple Retail"));
186        let authorised = detect(&card("Pay Exemple <service@pay.example>", "SHOP: 23,98 € EUR", "Vous avez autorisé un paiement de 23,98 € EUR en faveur de SHOP")).unwrap();
187        assert_eq!((authorised.kind, cents(&authorised)), (PaymentKind::Paid, Some(2398)));
188        let by_address = detect(&card("Pay Exemple <service@pay.example>", "Paiement reçu de shop@example.org", "Vous avez reçu un paiement de 500,00 € EUR de (shop@example.org).")).unwrap();
189        assert_eq!(by_address.party, "shop@example.org");
190    }
191
192    #[test]
193    fn orders_bills_and_settlements() {
194        let order = detect(&card("Boutique <contact@shop.example>", "Commande FR1234 confirmée", "Sous-total\r\n76,50€\r\nTotal\r\n76,50€")).unwrap();
195        assert_eq!((order.kind, cents(&order), order.party.as_str()), (PaymentKind::Order, Some(7650), "Boutique"));
196        let bill = detect(&card("Opérateur <factures@telecom.example>", "Votre facture mobile est disponible", "Connectez-vous à votre espace.")).unwrap();
197        assert_eq!((bill.kind, bill.amount), (PaymentKind::Bill, None));
198        let settled = detect(&card("Opérateur <factures@telecom.example>", "Le règlement de votre facture a bien été effectué", "Merci.")).unwrap();
199        assert_eq!(settled.kind, PaymentKind::Paid);
200        let billed = detect(&card("Opérateur <factures@telecom.example>", "Votre facture est disponible", "Votre facture d'un montant de 29,99 € pour votre ligne est disponible.")).unwrap();
201        assert_eq!((billed.kind, cents(&billed), billed.party.as_str()), (PaymentKind::Bill, Some(2999), "Opérateur"));
202        let receipt = detect(&card(PAY, "Reçu de votre paiement", "Merci.\r\nPaiement de\r\nMoi Même\r\nPaiement à\r\nExemple Web\r\nshop@example.org\r\n84,99 € EUR\r\n-8,49 € EUR\r\n76,50 € EUR")).unwrap();
203        assert_eq!((receipt.kind, cents(&receipt), receipt.party.as_str()), (PaymentKind::Paid, Some(7650), "Exemple Web"));
204    }
205
206    #[test]
207    fn what_is_not_a_payment() {
208        assert!(detect(&card("Pay Exemple <service@pay.example>", "Rapport d'activité prêt à être téléchargé", "Total 1 200,00 €")).is_none());
209        assert!(detect(&card("Shop <news@shop.example>", "Votre commande a été expédiée", "Total 23,98 €")).is_none());
210        assert!(detect(&card("Banque <notification@bank.example>", "Message de notification", "Un nouveau message vous attend.\r\nBanque Exemple, SA au capital de 1 000 000 €")).is_none());
211        assert!(detect(&card("Ami <ami@example.org>", "Samedi ?", "On se voit samedi ?")).is_none());
212        assert!(detect(&card("Opérateur <factures@telecom.example>", "Votre mandat SEPA a bien été signé", "Les prélèvements commenceront le mois prochain.")).is_none());
213    }
214}