1use 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 Received,
25 Paid,
27 Order,
29 Bill,
31 Refund,
33}
34
35impl PaymentKind {
36 pub fn is_credit(self) -> bool {
38 matches!(self, PaymentKind::Received | PaymentKind::Refund)
39 }
40
41 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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Payment {
56 pub kind: PaymentKind,
57 pub amount: Option<Amount>,
60 pub party: String,
62}
63
64const READ: usize = 2500;
66
67pub 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
85pub 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
98fn 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
110pub 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
123fn 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
129fn 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 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 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 #[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 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}