Skip to main content

sioul_app/
contracts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Contracts and subscriptions, for the window (docs/accounting.md): the list
5//! on the Budgets page, the recurring payments that have no contract yet, the
6//! form, the letter that stops one, a mail kept as a contract.
7
8use crate::backend::{json, load_config, say, tr};
9use serde::{Deserialize, Serialize};
10use sioul_core::budget::Ledger;
11use sioul_core::contracts::{Contract, Contracts, Kind};
12
13fn contracts() -> Result<Contracts, String> {
14    let root = load_config().notes_root_path().ok_or_else(|| tr().text("papers-no-store", None))?;
15    Contracts::load(&root)
16}
17
18fn ledger() -> Option<Ledger> {
19    load_config().notes_root_path().and_then(|root| Ledger::load(&root).ok())
20}
21
22#[derive(Serialize)]
23struct ContractView {
24    id: String,
25    kind: &'static str,
26    kind_label: String,
27    title: String,
28    party: String,
29    reference: String,
30    preset: String,
31    /// What it costs, from the payment that pays it: "€62.00 a month".
32    cost: String,
33    started: String,
34    renews: String,
35    every: String,
36    notice_days: u32,
37    cancel: String,
38    cancel_is_page: bool,
39    covers: String,
40    notes: String,
41    ended: String,
42    /// Where it stands, in a sentence.
43    line: String,
44}
45
46#[derive(Serialize)]
47struct Suggestion {
48    preset: String,
49    title: String,
50    cost: String,
51    kind: &'static str,
52}
53
54#[derive(Serialize)]
55struct Choice {
56    id: &'static str,
57    label: String,
58    notice: u32,
59}
60
61#[derive(Serialize, Default)]
62struct View {
63    store: bool,
64    problem: String,
65    kinds: Vec<Choice>,
66    contracts: Vec<ContractView>,
67    /// Recurring payments out, with no contract yet.
68    suggestions: Vec<Suggestion>,
69    /// Every recurring payment out, to tie a contract to: {id, title}.
70    presets: Vec<PresetChoice>,
71}
72
73#[derive(Serialize)]
74struct PresetChoice {
75    id: String,
76    title: String,
77}
78
79fn cost_of(ledger: Option<&Ledger>, preset: &str) -> String {
80    let Some(p) = ledger.and_then(|l| l.presets.iter().find(|p| p.id.as_deref() == Some(preset))) else { return String::new() };
81    let amount = tr().money(sioul_core::money::Money(-p.amount.cents()));
82    say(if matches!(p.every, sioul_core::budget::Period::Year) { "contract-cost-year" } else { "contract-cost-month" }, &[("amount", amount)])
83}
84
85fn line_of(contract: &Contract, today: jiff::civil::Date) -> String {
86    let day = |d: jiff::civil::Date| tr().day_in(d, today);
87    if let Some(ended) = contract.ended {
88        return say("contract-ended", &[("date", day(ended))]);
89    }
90    match (contract.next_renewal(today), contract.cancel_by(today)) {
91        (Some(renewal), Some(by)) if contract.notice_days > 0 && by >= today => say("contract-renews-notice", &[("date", day(renewal)), ("by", day(by))]),
92        (Some(renewal), Some(_)) if contract.notice_days > 0 => say("contract-renews-late", &[("date", day(renewal))]),
93        (Some(renewal), _) => say("contract-renews", &[("date", day(renewal))]),
94        _ if contract.notice_days > 0 => say("contract-open-notice", &[("days", contract.notice_days.to_string())]),
95        _ => tr().text("contract-open", None),
96    }
97}
98
99/// The Budgets page's contracts, as JSON.
100pub(crate) fn view() -> String {
101    let kinds = Kind::ALL.iter().map(|k| Choice { id: k.id(), label: tr().text(&format!("contract-kind-{}", k.id()), None), notice: k.usual_notice() }).collect();
102    let contracts = match contracts() {
103        Ok(c) => c,
104        Err(problem) => return json(&View { store: false, problem, kinds, ..View::default() }),
105    };
106    let ledger = ledger();
107    let today = jiff::Zoned::now().date();
108    let mut list: Vec<&Contract> = contracts.list.iter().collect();
109    // Open ones first, by their next date; ended ones last.
110    list.sort_by_key(|c| (c.ended.is_some(), c.next_renewal(today).unwrap_or(jiff::civil::Date::MAX), c.title.to_lowercase()));
111    let views = list
112        .into_iter()
113        .map(|c| ContractView {
114            id: c.id.clone(),
115            kind: c.kind.id(),
116            kind_label: tr().text(&format!("contract-kind-{}", c.kind.id()), None),
117            title: c.title.clone(),
118            party: c.party.clone(),
119            reference: c.reference.clone(),
120            preset: c.preset.clone(),
121            cost: cost_of(ledger.as_ref(), &c.preset),
122            started: c.started.map(|d| d.to_string()).unwrap_or_default(),
123            renews: c.renews.map(|d| d.to_string()).unwrap_or_default(),
124            every: c.every.clone(),
125            notice_days: c.notice_days,
126            cancel: c.cancel.clone(),
127            cancel_is_page: c.cancel.starts_with("https://") || c.cancel.starts_with("http://"),
128            covers: c.covers.clone(),
129            notes: c.notes.clone(),
130            ended: c.ended.map(|d| d.to_string()).unwrap_or_default(),
131            line: line_of(c, today),
132        })
133        .collect();
134    let tied: Vec<&str> = contracts.list.iter().map(|c| c.preset.as_str()).filter(|p| !p.is_empty()).collect();
135    let looked = sioul_core::words::Words::of(&load_config());
136    let suggestions = ledger
137        .as_ref()
138        .map(|l| {
139            l.presets
140                .iter()
141                .filter(|p| p.amount.cents() < 0 && !p.estimate && p.until.is_none_or(|u| u >= today))
142                .filter(|p| p.id.as_deref().is_some_and(|id| !tied.contains(&id)))
143                .map(|p| Suggestion { preset: p.id.clone().unwrap_or_default(), title: p.label.clone(), cost: cost_of(Some(l), p.id.as_deref().unwrap_or("")), kind: Kind::guess(&looked, &p.label).unwrap_or_default().id() })
144                .collect()
145        })
146        .unwrap_or_default();
147    let presets = ledger.as_ref().map(|l| l.presets.iter().filter(|p| p.amount.cents() < 0 && !p.estimate).filter_map(|p| Some(PresetChoice { id: p.id.clone()?, title: p.label.clone() })).collect()).unwrap_or_default();
148    json(&View { store: true, problem: String::new(), kinds, contracts: views, suggestions, presets })
149}
150
151#[derive(Deserialize)]
152struct Edit {
153    #[serde(default)]
154    kind: String,
155    #[serde(default)]
156    title: String,
157    #[serde(default)]
158    party: String,
159    #[serde(default)]
160    reference: String,
161    #[serde(default)]
162    preset: String,
163    #[serde(default)]
164    started: String,
165    #[serde(default)]
166    renews: String,
167    #[serde(default)]
168    every: String,
169    #[serde(default)]
170    notice_days: u32,
171    #[serde(default)]
172    cancel: String,
173    #[serde(default)]
174    covers: String,
175    #[serde(default)]
176    notes: String,
177    #[serde(default)]
178    ended: String,
179}
180
181/// A contract saved (new when `id` is empty); returns what went wrong, else "".
182pub(crate) fn save(id: &str, edit: &str) -> String {
183    let edit: Edit = match serde_json::from_str(edit) {
184        Ok(e) => e,
185        Err(e) => return e.to_string(),
186    };
187    let Some(root) = load_config().notes_root_path() else { return tr().text("papers-no-store", None) };
188    if edit.title.trim().is_empty() {
189        return tr().text("papers-no-title", None);
190    }
191    let date = |text: &str| -> Result<Option<jiff::civil::Date>, String> {
192        let text = text.trim();
193        if text.is_empty() { Ok(None) } else { text.parse().map(Some).map_err(|_| say("papers-bad-date", &[("date", text.to_string())])) }
194    };
195    let (started, renews, ended) = match (date(&edit.started), date(&edit.renews), date(&edit.ended)) {
196        (Ok(a), Ok(b), Ok(c)) => (a, b, c),
197        (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => return e,
198    };
199    // Set over the contracts as the file holds them now, under its lock, which the sharing takes too.
200    Contracts::change(&root, |contracts| {
201        let old = contracts.get(id).cloned().unwrap_or_default();
202        let contract = Contract {
203            id: old.id.clone(),
204            kind: Kind::of(&edit.kind),
205            title: edit.title.trim().to_string(),
206            party: edit.party.trim().to_string(),
207            reference: edit.reference.trim().to_string(),
208            preset: edit.preset.trim().to_string(),
209            started,
210            renews,
211            every: if matches!(edit.every.as_str(), "year" | "month") { edit.every.clone() } else { String::new() },
212            notice_days: edit.notice_days.min(365),
213            cancel: edit.cancel.trim().to_string(),
214            covers: edit.covers.trim().to_string(),
215            notes: edit.notes.trim().to_string(),
216            paper: old.paper.clone(),
217            ended,
218        };
219        contracts.put(contract);
220    })
221    .err()
222    .unwrap_or_default()
223}
224
225pub(crate) fn remove(id: &str) -> String {
226    let Some(root) = load_config().notes_root_path() else { return tr().text("papers-no-store", None) };
227    Contracts::change(&root, |contracts| contracts.remove(id)).err().unwrap_or_default()
228}
229
230/// The letter that stops a contract, as a draft to read, sign and send: to its
231/// address when it is a mail address. Returns {"draft"} or {"error"}.
232pub(crate) fn letter(id: &str) -> String {
233    let made = (|| -> Result<String, String> {
234        let contracts = contracts()?;
235        let contract = contracts.get(id).ok_or_else(|| tr().text("papers-gone", None))?;
236        let config = load_config();
237        let account = config.accounts.iter().filter(|a| a.syncs()).min_by_key(|a| a.priority).ok_or_else(|| tr().text("mail-no-account", None))?;
238        let mut draft = sioul_core::compose::Draft::new(&account.id);
239        if contract.cancel.contains('@') && !contract.cancel.contains(' ') {
240            draft.to = vec![contract.cancel.clone()];
241        }
242        let reference = if contract.reference.is_empty() { String::new() } else { say("contract-letter-reference", &[("reference", contract.reference.clone())]) };
243        draft.subject = say("contract-letter-subject", &[("title", contract.title.clone()), ("reference", reference.clone())]).trim().to_string();
244        draft.body = say("contract-letter-body", &[("title", contract.title.clone()), ("party", if contract.party.is_empty() { contract.title.clone() } else { contract.party.clone() }), ("reference", reference)]);
245        draft.links = vec![format!("sioul:contract/{}", contract.id)];
246        draft.add_signature(account.signature.as_deref());
247        draft.save()?;
248        Ok(draft.id)
249    })();
250    match made {
251        Ok(id) => serde_json::json!({ "draft": id }).to_string(),
252        Err(e) => serde_json::json!({ "error": e }).to_string(),
253    }
254}
255
256/// What a mail says of a contract, to start the form: who sent it, its subject, the kind they suggest.
257pub(crate) fn from_card(subject: &str, from: &str) -> String {
258    let looked = sioul_core::words::Words::of(&load_config());
259    let kind = Kind::guess(&looked, subject).or_else(|| Kind::guess(&looked, from)).unwrap_or_default();
260    serde_json::json!({ "title": subject, "party": from, "kind": kind.id(), "notice_days": kind.usual_notice() }).to_string()
261}