Skip to main content

sioul_core/
invoice.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Invoices for work done for someone. One is made from a project's billable
5//! time not billed yet: a line per task (or per note, for time noted without
6//! a task), its hours at the project's rate. Numbers follow each other
7//! within a year ("2026-001", "2026-002"), as French law asks, and are never
8//! reused. Each invoice is kept as a record
9//! (`$XDG_DATA_HOME/sioul/invoices/<number>.toml`); the time it bills carries
10//! its number, so it is never billed twice; the window prints it to PDF, with
11//! the date or period of the service (the billed sessions' first and last
12//! days), which French law asks for (service-public.gouv.fr, sheet F31808).
13
14use 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/// One line: what was done, how long, at what rate.
24#[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/// Who sends it, as it was when the invoice was made.
33#[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    /// "2026-10-05".
46    pub date: String,
47    /// When payment is due: thirty days after.
48    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    /// The date or period of the service, which French law asks for: the
55    /// billed sessions' first and last days ("2026-09-02"), the same day for
56    /// one. Empty on an invoice made before Sioul printed it: none is said.
57    #[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    /// The time it bills, by session key.
66    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
77/// Where invoices are kept.
78pub fn folder() -> PathBuf {
79    data_dir().join("invoices")
80}
81
82/// Every invoice kept in `dir`, by number. A record that no longer reads
83/// (edited by hand, written by a newer Sioul) still holds its number, so the
84/// number is never given again, nor its file written over: it comes back
85/// with its number only.
86pub 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
102/// The number after the last one given with the same prefix: `prefix` (else
103/// the year, "2026-") and three digits. With the usual prefix, the year, the
104/// count restarts each January (2026-001, then 2027-001); with your own
105/// prefix it never restarts, and goes on from the last one whatever the year
106/// (F-041, F-042…), as the guide says (Clients, "Invoices").
107pub 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/// An invoice for a project's billable time not billed yet, among `entries`;
114/// None when there is none.
115#[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    // A line per task or note, in the order they were first worked on.
123    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    // The service's date or period: the first and last days worked.
136    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
164/// Keeps an invoice in `dir`.
165pub 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    // A number becomes a file name on every system: what Windows refuses in one
168    // (":" would even write into a hidden stream) becomes "-".
169    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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
179}
180
181fn lines_html(text: &str) -> String {
182    text.lines().map(escape).collect::<Vec<_>>().join("<br>")
183}
184
185/// The invoice as a page to print: who sends it and to whom, the date or
186/// period of the service, its lines, the total, the legal mentions and how to
187/// pay.
188pub 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    // The date or period of the service, on its own line under the date of issue.
213    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>&nbsp;</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    /// The date or period of the service, which French law asks for: the
311    /// billed sessions' first and last days, one date for one day; kept with
312    /// the invoice, printed in both languages; none on an invoice made before.
313    #[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        // Billed in September, an older session billed already in August: not counted.
318        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        // One day: one date.
326        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        // Kept with the invoice, read back.
330        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        // Made before Sioul printed it: read as before, no period said.
335        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        // A record broken by hand still holds its number, and its file is not written over.
350        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        // A number with what Windows refuses in a file name is kept under a name it takes.
356        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}