Skip to main content

sioul_core/
contracts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Contracts and subscriptions (docs/accounting.md, "Contracts"): what you are
5//! bound to (rent, energy, phone, insurances, hosting, subscriptions), when
6//! each renews, the notice it needs, how to stop it, what it covers. Found
7//! from the recurring payments of your budgets and from mail; a reminder comes
8//! before the last day to send a notice; "Cancel" opens the provider's own
9//! cancel page (in France a three-click button is required online since
10//! June 2023, loi 2022-1158) or drafts the letter. Facts about your contracts,
11//! never offers.
12//!
13//! `sioul-contracts.toml` at the root of the notes folder.
14
15use jiff::civil::Date;
16use jiff::{Span, ToSpan};
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19
20pub const MANIFEST: &str = "sioul-contracts.toml";
21
22/// What a contract is.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum Kind {
26    Rent,
27    Energy,
28    Telecom,
29    Insurance,
30    Health,
31    Subscription,
32    Hosting,
33    Bank,
34    #[default]
35    Other,
36}
37
38impl Kind {
39    pub const ALL: [Kind; 9] = [Kind::Rent, Kind::Energy, Kind::Telecom, Kind::Insurance, Kind::Health, Kind::Subscription, Kind::Hosting, Kind::Bank, Kind::Other];
40
41    pub fn id(self) -> &'static str {
42        match self {
43            Kind::Rent => "rent",
44            Kind::Energy => "energy",
45            Kind::Telecom => "telecom",
46            Kind::Insurance => "insurance",
47            Kind::Health => "health",
48            Kind::Subscription => "subscription",
49            Kind::Hosting => "hosting",
50            Kind::Bank => "bank",
51            Kind::Other => "other",
52        }
53    }
54
55    pub fn of(id: &str) -> Kind {
56        Kind::ALL.into_iter().find(|k| k.id() == id).unwrap_or_default()
57    }
58
59    /// The notice usually asked, in days, to propose when none is written; you
60    /// change it to what your contract says. A tenant gives three months (one
61    /// in a furnished flat or a tight area); insurances and health covers after
62    /// their first year one month (loi Hamon, and since December 2020 for health
63    /// covers); phone and internet after the first twelve months at most ten
64    /// days; energy, none (art. L224-13 of the consumer code: change at any time).
65    pub fn usual_notice(self) -> u32 {
66        match self {
67            Kind::Rent => 90,
68            Kind::Insurance | Kind::Health => 30,
69            Kind::Telecom => 10,
70            _ => 0,
71        }
72    }
73
74    /// The kind a payment's label, a mail's subject or a sender's name
75    /// suggests, by the words and brands of each kind (`words::ContractKinds`:
76    /// the language packs' words, the country packs' brands, yours), the
77    /// kinds tried in this order.
78    pub fn guess(words: &crate::words::Words, text: &str) -> Option<Kind> {
79        let folded: String = crate::text::fold(text).into_iter().collect();
80        let has = |list: &[String]| {
81            list.iter().map(|w| crate::words::folded(w)).any(|w| !w.is_empty() && (folded.split(|c: char| !c.is_alphanumeric()).any(|word| word == w) || (w.contains(' ') && folded.contains(&w))))
82        };
83        let k = &words.contracts.kinds;
84        let kind = if has(&k.rent) {
85            Kind::Rent
86        } else if has(&k.energy) {
87            Kind::Energy
88        } else if has(&k.health) {
89            Kind::Health
90        } else if has(&k.insurance) {
91            Kind::Insurance
92        } else if has(&k.telecom) {
93            Kind::Telecom
94        } else if has(&k.hosting) {
95            Kind::Hosting
96        } else if has(&k.subscription) {
97            Kind::Subscription
98        } else if has(&k.bank) {
99            Kind::Bank
100        } else {
101            return None;
102        };
103        Some(kind)
104    }
105}
106
107/// One contract.
108#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
109pub struct Contract {
110    pub id: String,
111    #[serde(default)]
112    pub kind: Kind,
113    pub title: String,
114    /// Who it is with: "EDF", "MAIF".
115    #[serde(default)]
116    pub party: String,
117    /// Your customer or contract number with them.
118    #[serde(default)]
119    pub reference: String,
120    /// The budget preset that pays it, by id.
121    #[serde(default)]
122    pub preset: String,
123    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
124    pub started: Option<Date>,
125    /// When it renews by itself (its anniversary), if it does.
126    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
127    pub renews: Option<Date>,
128    /// "year", "month", or "" when it does not renew on a date.
129    #[serde(default)]
130    pub every: String,
131    /// Days of notice before a renewal (or before it ends, when it runs on).
132    #[serde(default)]
133    pub notice_days: u32,
134    /// How to stop it: its cancel page ("https://…") or an address to write to.
135    #[serde(default)]
136    pub cancel: String,
137    /// What it covers (an insurance: legal protection, liability…).
138    #[serde(default)]
139    pub covers: String,
140    #[serde(default)]
141    pub notes: String,
142    /// The paper holding it, in the papers wallet.
143    #[serde(default)]
144    pub paper: String,
145    /// It ended then: kept as a record.
146    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
147    pub ended: Option<Date>,
148}
149
150impl Contract {
151    /// Its next renewal on or after `today`: its date, moved on by its term.
152    /// Each turn is counted from the date written, so the 31st stays the 31st
153    /// after a short month (the 30th in April, the 31st again in May).
154    pub fn next_renewal(&self, today: Date) -> Option<Date> {
155        let first = self.renews?;
156        let turns = |n: i64| match self.every.as_str() {
157            "year" => Some(Span::new().years(n)),
158            "month" => Some(Span::new().months(n)),
159            _ => None,
160        };
161        if turns(0).is_none() {
162            return (first >= today).then_some(first);
163        }
164        for n in 0..1200 {
165            let at = first.checked_add(turns(n)?).ok()?;
166            if at >= today {
167                return Some(at);
168            }
169        }
170        None
171    }
172
173    /// The last day a notice can leave to stop the next renewal.
174    pub fn cancel_by(&self, today: Date) -> Option<Date> {
175        let renewal = self.next_renewal(today)?;
176        renewal.checked_sub((i64::from(self.notice_days)).days()).ok()
177    }
178
179    pub fn is_open(&self) -> bool {
180        self.ended.is_none()
181    }
182}
183
184/// The contracts of a notes folder.
185#[derive(Debug, Clone, Default)]
186pub struct Contracts {
187    pub root: PathBuf,
188    pub list: Vec<Contract>,
189}
190
191#[derive(Deserialize, Default)]
192struct File {
193    #[serde(default, rename = "contract")]
194    list: Vec<Contract>,
195}
196
197impl Contracts {
198    /// Read, changed by `change` and written back, all under the file's lock,
199    /// which the sharing takes too: a change is set over the file as it is
200    /// then, never over a copy read earlier. Returns what `change` returns.
201    pub fn change<R>(root: &Path, change: impl FnOnce(&mut Contracts) -> R) -> Result<R, String> {
202        crate::filelock::with_lock(&root.join(MANIFEST), || {
203            let mut contracts = Contracts::load(root)?;
204            let out = change(&mut contracts);
205            contracts.save()?;
206            Ok(out)
207        })
208    }
209
210    pub fn load(root: &Path) -> Result<Contracts, String> {
211        let path = root.join(MANIFEST);
212        let list = match std::fs::read_to_string(&path) {
213            Ok(text) => toml::from_str::<File>(&text).map_err(|e| format!("{}: {e}", path.display()))?.list,
214            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
215            Err(e) => return Err(format!("{}: {e}", path.display())),
216        };
217        Ok(Contracts { root: root.to_path_buf(), list })
218    }
219
220    pub fn get(&self, id: &str) -> Option<&Contract> {
221        self.list.iter().find(|c| c.id == id)
222    }
223
224    /// Added or changed (by id; a new one gets an id from its title); its id.
225    pub fn put(&mut self, mut contract: Contract) -> String {
226        if contract.id.is_empty() {
227            let taken: Vec<String> = self.list.iter().map(|c| c.id.clone()).collect();
228            contract.id = crate::projects::new_id(&contract.title, &taken);
229        }
230        let id = contract.id.clone();
231        match self.list.iter_mut().find(|c| c.id == id) {
232            Some(place) => *place = contract,
233            None => self.list.push(contract),
234        }
235        id
236    }
237
238    pub fn remove(&mut self, id: &str) -> bool {
239        let before = self.list.len();
240        self.list.retain(|c| c.id != id);
241        before != self.list.len()
242    }
243
244    /// Written whole, next to its place then moved, under the file's lock.
245    pub fn save(&self) -> Result<(), String> {
246        crate::filelock::with_lock(&self.root.join(MANIFEST), || self.write())
247    }
248
249    fn write(&self) -> Result<(), String> {
250        let mut doc = toml_edit::DocumentMut::new();
251        doc.decor_mut().set_prefix("# What you are bound to: renewals, notices, how to stop each (docs/accounting.md).\n\n");
252        let date = |d: Date| toml_edit::value(toml_edit::Datetime { date: Some(toml_edit::Date { year: d.year() as u16, month: d.month() as u8, day: d.day() as u8 }), time: None, offset: None });
253        let mut list = toml_edit::ArrayOfTables::new();
254        for c in &self.list {
255            let mut t = toml_edit::Table::new();
256            t["id"] = toml_edit::value(&c.id);
257            t["kind"] = toml_edit::value(c.kind.id());
258            t["title"] = toml_edit::value(&c.title);
259            for (name, text) in [("party", &c.party), ("reference", &c.reference), ("preset", &c.preset), ("every", &c.every), ("cancel", &c.cancel), ("covers", &c.covers), ("notes", &c.notes), ("paper", &c.paper)] {
260                if !text.is_empty() {
261                    t[name] = toml_edit::value(text);
262                }
263            }
264            if c.notice_days > 0 {
265                t["notice_days"] = toml_edit::value(i64::from(c.notice_days));
266            }
267            for (name, day) in [("started", c.started), ("renews", c.renews), ("ended", c.ended)] {
268                if let Some(d) = day {
269                    t[name] = date(d);
270                }
271            }
272            list.push(t);
273        }
274        doc.insert("contract", toml_edit::Item::ArrayOfTables(list));
275        let path = self.root.join(MANIFEST);
276        let temporary = self.root.join(format!("{MANIFEST}.new"));
277        std::fs::write(&temporary, doc.to_string()).and_then(|()| std::fs::rename(&temporary, &path)).map_err(|e| format!("{}: {e}", path.display()))
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn day(text: &str) -> Date {
286        text.parse().unwrap()
287    }
288
289    #[test]
290    fn renewals_and_notices() {
291        let today = day("2026-10-03");
292        let home = Contract { kind: Kind::Insurance, renews: Some(day("2025-12-01")), every: "year".into(), notice_days: 60, ..Contract::default() };
293        assert_eq!(home.next_renewal(today), Some(day("2026-12-01")));
294        assert_eq!(home.cancel_by(today), Some(day("2026-10-02")), "two months before");
295        let phone = Contract { renews: Some(day("2026-01-15")), every: "month".into(), notice_days: 10, ..Contract::default() };
296        assert_eq!(phone.next_renewal(today), Some(day("2026-10-15")));
297        // The 31st stays the 31st after a short month.
298        let last = Contract { renews: Some(day("2026-01-31")), every: "month".into(), ..Contract::default() };
299        assert_eq!(last.next_renewal(today), Some(day("2026-10-31")));
300        let once = Contract { renews: Some(day("2026-09-01")), ..Contract::default() };
301        assert_eq!(once.next_renewal(today), None, "a date past, no term: nothing comes");
302        assert_eq!(Contract::default().cancel_by(today), None);
303    }
304
305    #[test]
306    fn kinds_guessed() {
307        let words = crate::words::Words::builtin();
308        let guess = |text: &str| Kind::guess(&words, text);
309        assert_eq!(guess("Loyer octobre"), Some(Kind::Rent));
310        assert_eq!(guess("EDF électricité"), Some(Kind::Energy));
311        assert_eq!(guess("Free Mobile"), Some(Kind::Telecom));
312        assert_eq!(guess("MAIF assurance habitation"), Some(Kind::Insurance));
313        assert_eq!(guess("Mutuelle"), Some(Kind::Health));
314        assert_eq!(guess("Abonnement annuel"), Some(Kind::Subscription));
315        assert_eq!(guess("Exemple hébergement"), Some(Kind::Hosting));
316        assert_eq!(guess("Boulangerie"), None);
317        // A word inside another is not it: "redevance" is not RED.
318        assert_eq!(guess("redevance"), None);
319        // A brand of your own subscriptions, added in [words].
320        assert_eq!(guess("Exemplestream Premium"), None);
321        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.contracts.kinds.subscription]\nadd = [\"Exemplestream\"]\n").unwrap();
322        assert_eq!(Kind::guess(&crate::words::Words::of(&config), "Exemplestream Premium"), Some(Kind::Subscription));
323    }
324
325    #[test]
326    fn kept_and_read_again() {
327        let root = std::env::temp_dir().join(format!("sioul-contracts-{}", std::process::id()));
328        let _ = std::fs::remove_dir_all(&root);
329        std::fs::create_dir_all(&root).unwrap();
330        let mut contracts = Contracts::load(&root).unwrap();
331        let id = contracts.put(Contract { kind: Kind::Insurance, title: "Assurance habitation".into(), party: "MAIF".into(), reference: "12345".into(), renews: Some(day("2026-12-01")), every: "year".into(), notice_days: 30, cancel: "https://example.org/resilier".into(), covers: "Responsabilité civile, protection juridique".into(), ..Contract::default() });
332        assert_eq!(id, "assurance-habitation");
333        contracts.save().unwrap();
334        let again = Contracts::load(&root).unwrap();
335        assert_eq!(again.list, contracts.list);
336        let _ = std::fs::remove_dir_all(&root);
337    }
338    #[test]
339    fn the_letter_reads_as_a_letter() {
340        for (language, opening) in [("fr", "Madame, Monsieur,\n\nPar la présente"), ("en", "Dear Sir or Madam,\n\nI hereby")] {
341            let tr = crate::i18n::Translator::new(language);
342            let mut args = crate::i18n::args();
343            args.set("title", "Assurance habitation");
344            args.set("party", "MAIF");
345            args.set("reference", "n° 123");
346            let body = tr.text("contract-letter-body", Some(&args));
347            assert!(body.starts_with(opening), "{body}");
348            assert!(body.contains("Assurance habitation") && body.contains("n° 123") && !body.contains("    "), "{body}");
349        }
350    }
351
352}