1use 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#[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 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 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#[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 #[serde(default)]
116 pub party: String,
117 #[serde(default)]
119 pub reference: String,
120 #[serde(default)]
122 pub preset: String,
123 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
124 pub started: Option<Date>,
125 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
127 pub renews: Option<Date>,
128 #[serde(default)]
130 pub every: String,
131 #[serde(default)]
133 pub notice_days: u32,
134 #[serde(default)]
136 pub cancel: String,
137 #[serde(default)]
139 pub covers: String,
140 #[serde(default)]
141 pub notes: String,
142 #[serde(default)]
144 pub paper: String,
145 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
147 pub ended: Option<Date>,
148}
149
150impl Contract {
151 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 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#[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 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 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 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 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 assert_eq!(guess("redevance"), None);
319 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}