1use 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 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 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 suggestions: Vec<Suggestion>,
69 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
99pub(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 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
181pub(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 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
230pub(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
256pub(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}