1use crate::config::{InvoiceSettings, data_dir};
15use crate::i18n::Translator;
16use crate::money::Money;
17use crate::timereport::{Entry, amount, duration};
18use jiff::Span;
19use jiff::civil::Date;
20use serde::{Deserialize, Serialize};
21use std::path::{Path, PathBuf};
22
23#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
25pub struct InvoiceLine {
26 pub label: String,
27 pub minutes: u32,
28 pub rate: f64,
29 pub cents: i64,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
34pub struct Issuer {
35 pub name: String,
36 pub address: String,
37 pub siret: String,
38 pub vat: String,
39 pub payment: String,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
43pub struct Invoice {
44 pub number: String,
45 pub date: String,
47 pub due: String,
49 pub project: String,
50 pub project_title: String,
51 pub client: String,
52 #[serde(default)]
53 pub client_address: String,
54 #[serde(default, skip_serializing_if = "String::is_empty")]
58 pub period_from: String,
59 #[serde(default, skip_serializing_if = "String::is_empty")]
60 pub period_to: String,
61 pub lines: Vec<InvoiceLine>,
62 pub total_cents: i64,
63 pub currency: String,
64 pub issuer: Issuer,
65 pub sessions: Vec<String>,
67 #[serde(default)]
68 pub paid: bool,
69}
70
71impl Invoice {
72 pub fn total(&self) -> Money {
73 Money(self.total_cents)
74 }
75}
76
77pub fn folder() -> PathBuf {
79 data_dir().join("invoices")
80}
81
82pub fn all_in(dir: &Path) -> Vec<Invoice> {
87 let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
88 let read = |path: &Path| -> Option<Invoice> {
89 let text = std::fs::read_to_string(path).ok();
90 if let Some(invoice) = text.as_deref().and_then(|t| toml::from_str::<Invoice>(t).ok()) {
91 return Some(invoice);
92 }
93 let number = text.and_then(|t| t.parse::<toml::Table>().ok()).and_then(|t| t.get("number")?.as_str().map(str::to_string));
94 let number = number.or_else(|| path.file_stem().map(|s| s.to_string_lossy().into_owned()))?;
95 Some(Invoice { number, ..Invoice::default() })
96 };
97 let mut out: Vec<Invoice> = entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "toml")).filter_map(|p| read(&p)).collect();
98 out.sort_by(|a, b| a.number.cmp(&b.number));
99 out
100}
101
102pub fn next_number(existing: &[Invoice], prefix: &str, year: i16) -> String {
108 let prefix = if prefix.trim().is_empty() { format!("{year}-") } else { prefix.trim().to_string() };
109 let last = existing.iter().filter_map(|i| i.number.strip_prefix(&prefix)).filter_map(|rest| rest.parse::<u32>().ok()).max().unwrap_or(0);
110 format!("{prefix}{:03}", last.saturating_add(1))
111}
112
113#[allow(clippy::too_many_arguments)]
116pub fn make(entries: &[Entry], project: &crate::projects::Project, client: &str, client_address: &str, settings: &InvoiceSettings, number: &str, date: Date) -> Option<Invoice> {
117 let rate = project.rate.unwrap_or(settings.rate);
118 let billed: Vec<&Entry> = entries.iter().filter(|e| e.project == project.id && e.billable && e.invoice.is_empty() && e.minutes > 0).collect();
119 if billed.is_empty() {
120 return None;
121 }
122 let mut lines: Vec<(i64, InvoiceLine)> = Vec::new();
124 for entry in &billed {
125 let label = if entry.title.trim().is_empty() { project.title.clone() } else { entry.title.clone() };
126 match lines.iter_mut().find(|(_, l)| l.label == label) {
127 Some((first, line)) => {
128 *first = (*first).min(entry.start);
129 line.minutes += entry.minutes;
130 }
131 None => lines.push((entry.start, InvoiceLine { label, minutes: entry.minutes, rate, cents: 0 })),
132 }
133 }
134 lines.sort_by_key(|(first, _)| *first);
135 let period_from = billed.iter().map(|e| e.day).min().map(|d| d.to_string()).unwrap_or_default();
137 let period_to = billed.iter().map(|e| e.day).max().map(|d| d.to_string()).unwrap_or_default();
138 let lines: Vec<InvoiceLine> = lines
139 .into_iter()
140 .map(|(_, mut line)| {
141 line.cents = amount(line.minutes, line.rate).cents();
142 line
143 })
144 .collect();
145 Some(Invoice {
146 number: number.to_string(),
147 date: date.to_string(),
148 due: date.checked_add(Span::new().days(30)).unwrap_or(date).to_string(),
149 project: project.id.clone(),
150 project_title: project.title.clone(),
151 client: client.to_string(),
152 client_address: client_address.to_string(),
153 period_from,
154 period_to,
155 total_cents: lines.iter().map(|l| l.cents).sum(),
156 lines,
157 currency: if settings.currency.trim().is_empty() { "EUR".into() } else { settings.currency.trim().to_string() },
158 issuer: Issuer { name: settings.name.clone(), address: settings.address.clone(), siret: settings.siret.clone(), vat: settings.vat.clone(), payment: settings.payment.clone() },
159 sessions: billed.iter().map(|e| e.key.clone()).collect(),
160 paid: false,
161 })
162}
163
164pub fn save_in(dir: &Path, invoice: &Invoice) -> Result<PathBuf, String> {
166 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
167 let name: String = invoice.number.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '-' } else { c }).collect();
170 let path = dir.join(format!("{}.toml", name.trim_end_matches(['.', ' '])));
171 let temporary = path.with_extension("toml.new");
172 std::fs::write(&temporary, toml::to_string(invoice).map_err(|e| e.to_string())?).map_err(|e| format!("{}: {e}", temporary.display()))?;
173 std::fs::rename(&temporary, &path).map_err(|e| format!("{}: {e}", path.display()))?;
174 Ok(path)
175}
176
177fn escape(text: &str) -> String {
178 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
179}
180
181fn lines_html(text: &str) -> String {
182 text.lines().map(escape).collect::<Vec<_>>().join("<br>")
183}
184
185pub fn html(invoice: &Invoice, tr: &Translator) -> String {
189 let money = |cents: i64| if invoice.currency == "EUR" { tr.money(Money(cents)) } else { format!("{} {}", tr.number(Money(cents)), escape(&invoice.currency)) };
190 let date = |text: &str| text.parse::<Date>().map(|d| tr.day_month(d) + " " + &d.year().to_string()).unwrap_or_else(|_| text.to_string());
191 let say = |id: &str, pairs: &[(&str, String)]| {
192 let mut args = crate::i18n::args();
193 for (k, v) in pairs {
194 args.set(k.to_string(), v.clone());
195 }
196 tr.text(id, Some(&args))
197 };
198 let rows: String = invoice
199 .lines
200 .iter()
201 .map(|l| {
202 format!(
203 "<tr><td width=\"52%\">{}</td><td width=\"14%\" align=\"right\">{}</td><td width=\"17%\" align=\"right\">{}</td><td width=\"17%\" align=\"right\">{}</td></tr>",
204 escape(&l.label),
205 duration(l.minutes),
206 money((l.rate * 100.0).round() as i64),
207 money(l.cents)
208 )
209 })
210 .collect();
211 let siret = if invoice.issuer.siret.trim().is_empty() { String::new() } else { format!("<br>{}", escape(&say("invoice-siret", &[("siret", invoice.issuer.siret.clone())]))) };
212 let period = match (invoice.period_from.as_str(), invoice.period_to.as_str()) {
214 ("", _) => String::new(),
215 (from, to) if to.is_empty() || from == to => format!("<br>{}", escape(&say("invoice-period-day", &[("date", date(from))]))),
216 (from, to) => format!("<br>{}", escape(&say("invoice-period", &[("from", date(from)), ("to", date(to))]))),
217 };
218 format!(
219 "<html><head><meta charset=\"utf-8\"><style>\
220 body {{ font-family: sans-serif; font-size: 11pt; color: #222; }}\
221 h1 {{ font-size: 20pt; font-weight: normal; margin: 0 0 4pt 0; }}\
222 table.lines {{ border-collapse: collapse; margin-top: 18pt; }}\
223 table.lines th {{ border-bottom: 1px solid #888; font-weight: normal; color: #555; }}\
224 table.lines td {{ border-bottom: 1px solid #ddd; }}\
225 .total {{ font-size: 12pt; }}\
226 .muted {{ color: #555; }}\
227 </style></head><body>\
228 <table width=\"100%\"><tr>\
229 <td valign=\"top\" width=\"55%\"><b>{issuer}</b><br>{issuer_address}{siret}</td>\
230 <td valign=\"top\"><span class=\"muted\">{to}</span><br><b>{client}</b><br>{client_address}</td>\
231 </tr></table>\
232 <p> </p>\
233 <h1>{title}</h1>\
234 <p class=\"muted\">{dated}{period}<br>{due}<br>{project}</p>\
235 <table class=\"lines\" width=\"100%\" cellpadding=\"6\" cellspacing=\"0\"><tr><th width=\"52%\" align=\"left\">{h_what}</th><th width=\"14%\" align=\"right\">{h_time}</th><th width=\"17%\" align=\"right\">{h_rate}</th><th width=\"17%\" align=\"right\">{h_amount}</th></tr>{rows}\
236 <tr><td colspan=\"3\" align=\"right\" class=\"total\"><b>{h_total}</b></td><td align=\"right\" class=\"total\"><b>{total}</b></td></tr></table>\
237 <p>{vat}</p>\
238 <p>{payment}</p>\
239 <p class=\"muted\">{late}</p>\
240 </body></html>",
241 issuer = escape(&invoice.issuer.name),
242 issuer_address = lines_html(&invoice.issuer.address),
243 to = escape(&tr.text("invoice-to", None)),
244 client = escape(&invoice.client),
245 client_address = lines_html(&invoice.client_address),
246 title = escape(&say("invoice-title", &[("number", invoice.number.clone())])),
247 dated = escape(&say("invoice-dated", &[("date", date(&invoice.date))])),
248 due = escape(&say("invoice-due", &[("date", date(&invoice.due))])),
249 project = escape(&say("invoice-project", &[("project", invoice.project_title.clone())])),
250 h_what = escape(&tr.text("invoice-what", None)),
251 h_time = escape(&tr.text("invoice-time", None)),
252 h_rate = escape(&tr.text("invoice-rate", None)),
253 h_amount = escape(&tr.text("invoice-amount", None)),
254 h_total = escape(&tr.text("invoice-total", None)),
255 total = money(invoice.total_cents),
256 vat = escape(&invoice.issuer.vat),
257 payment = lines_html(&invoice.issuer.payment),
258 late = escape(&tr.text("invoice-late", None)),
259 )
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::projects::Project;
266
267 fn entry(key: &str, start: i64, minutes: u32, title: &str, invoice: &str) -> Entry {
268 Entry {
269 key: key.into(),
270 start,
271 day: Date::ZERO,
272 minutes,
273 project: "lumen".into(),
274 task: String::new(),
275 title: title.into(),
276 note: String::new(),
277 billable: true,
278 invoice: invoice.into(),
279 }
280 }
281
282 #[test]
283 fn an_invoice_bills_each_hour_once() {
284 let project = Project { id: "lumen".into(), title: "Studio Lumen".into(), kind: Some("project".into()), rate: Some(60.0), ..Project::default() };
285 let entries = vec![
286 entry("a", 10, 90, "Build the site", ""),
287 entry("b", 20, 25, "Call with the client", ""),
288 entry("c", 30, 30, "Build the site", ""),
289 entry("d", 40, 60, "Build the site", "2026-001"),
290 Entry { billable: false, ..entry("e", 50, 60, "Coffee", "") },
291 ];
292 let settings = InvoiceSettings { name: "Jean Exemple".into(), siret: "123 456 789 00010".into(), vat: "TVA non applicable, art. 293 B du CGI".into(), rate: 40.0, ..InvoiceSettings::default() };
293 let existing = vec![Invoice { number: "2026-001".into(), ..Invoice::default() }, Invoice { number: "2025-007".into(), ..Invoice::default() }];
294 let number = next_number(&existing, "", 2026);
295 assert_eq!(number, "2026-002");
296 let invoice = make(&entries, &project, "Studio Lumen", "1 rue de la Paix\n75002 Paris", &settings, &number, "2026-10-05".parse().unwrap()).unwrap();
297 assert_eq!(invoice.lines.iter().map(|l| (l.label.as_str(), l.minutes, l.cents)).collect::<Vec<_>>(), vec![("Build the site", 120, 12000), ("Call with the client", 25, 2500)]);
298 assert_eq!((invoice.total_cents, invoice.due.as_str()), (14500, "2026-11-04"));
299 assert_eq!(invoice.sessions, vec!["a", "b", "c"], "billed and given time left out");
300 let tr = Translator::new("fr");
301 let page = html(&invoice, &tr);
302 assert!(page.contains("2026-002") && page.contains("Build the site") && page.contains("293 B") && page.contains("75002 Paris"), "{page}");
303 let dir = std::env::temp_dir().join(format!("sioul-invoices-{}", std::process::id()));
304 save_in(&dir, &invoice).unwrap();
305 assert_eq!(all_in(&dir), vec![invoice]);
306 let _ = std::fs::remove_dir_all(&dir);
307 assert!(make(&entries[3..], &project, "", "", &settings, "x", "2026-10-05".parse().unwrap()).is_none());
308 }
309
310 #[test]
314 fn an_invoice_says_the_period_of_its_service() {
315 let project = Project { id: "lumen".into(), title: "Studio Lumen".into(), kind: Some("project".into()), rate: Some(60.0), ..Project::default() };
316 let on = |key: &str, day: &str, invoice: &str| Entry { day: day.parse().unwrap(), ..entry(key, 10, 60, "Build the site", invoice) };
317 let entries = vec![on("a", "2026-09-30", ""), on("b", "2026-09-02", ""), on("c", "2026-09-14", ""), on("d", "2026-08-20", "2026-001")];
319 let settings = InvoiceSettings { name: "Camille Exemple EI".into(), ..InvoiceSettings::default() };
320 let invoice = make(&entries, &project, "Studio Lumen", "", &settings, "2026-002", "2026-10-05".parse().unwrap()).unwrap();
321 assert_eq!((invoice.period_from.as_str(), invoice.period_to.as_str()), ("2026-09-02", "2026-09-30"));
322 let french = html(&invoice, &Translator::new("fr"));
323 assert!(french.contains("Prestation réalisée du 2 septembre 2026 au 30 septembre 2026") && french.contains("Camille Exemple EI"), "{french}");
324 assert!(html(&invoice, &Translator::new("en")).contains("Service from 2 September 2026 to 30 September 2026"));
325 let one = make(&entries[2..3], &project, "", "", &settings, "2026-003", "2026-10-05".parse().unwrap()).unwrap();
327 assert!(html(&one, &Translator::new("fr")).contains("Prestation réalisée le 14 septembre 2026"));
328 assert!(html(&one, &Translator::new("en")).contains("Service on 14 September 2026"));
329 let dir = std::env::temp_dir().join(format!("sioul-invoices-period-{}", std::process::id()));
331 let _ = std::fs::remove_dir_all(&dir);
332 save_in(&dir, &invoice).unwrap();
333 assert_eq!(all_in(&dir), vec![invoice.clone()]);
334 let older = "number = \"2026-001\"\ndate = \"2026-09-01\"\ndue = \"2026-10-01\"\nproject = \"lumen\"\nproject_title = \"Studio Lumen\"\nclient = \"Studio Lumen\"\nlines = []\ntotal_cents = 0\ncurrency = \"EUR\"\nsessions = []\n[issuer]\nname = \"\"\naddress = \"\"\nsiret = \"\"\nvat = \"\"\npayment = \"\"\n";
336 std::fs::write(dir.join("2026-001.toml"), older).unwrap();
337 let read = all_in(&dir).into_iter().find(|i| i.number == "2026-001").unwrap();
338 assert_eq!((read.period_from.as_str(), read.date.as_str()), ("", "2026-09-01"));
339 let page = html(&read, &Translator::new("en"));
340 assert!(!page.contains("Service on") && !page.contains("Service from"), "{page}");
341 let _ = std::fs::remove_dir_all(&dir);
342 }
343
344 #[test]
345 fn a_number_is_never_given_twice() {
346 let dir = std::env::temp_dir().join(format!("sioul-invoices-kept-{}", std::process::id()));
347 let _ = std::fs::remove_dir_all(&dir);
348 std::fs::create_dir_all(&dir).unwrap();
349 std::fs::write(dir.join("2026-003.toml"), "number = \"2026-003\"\ndate = oops\n").unwrap();
351 std::fs::write(dir.join("2026-004.toml"), "not toml at all [").unwrap();
352 let kept = all_in(&dir);
353 assert_eq!(kept.iter().map(|i| i.number.as_str()).collect::<Vec<_>>(), ["2026-003", "2026-004"]);
354 assert_eq!(next_number(&kept, "", 2026), "2026-005");
355 let path = save_in(&dir, &Invoice { number: "F:2026/001".into(), ..Invoice::default() }).unwrap();
357 assert_eq!(path.file_name().unwrap(), "F-2026-001.toml");
358 let _ = std::fs::remove_dir_all(&dir);
359 }
360}