Skip to main content

invoice_cli/
db.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Database layer — invoice-cli queries over the shared finance-core SQLite.
3//
4// Connection opening, the refinery migration runner, and the `Issuer`
5// primitive all live in finance-core so the whole accounting suite shares
6// one schema and one DB file. This file owns the invoice-specific queries
7// (clients, products, invoices, invoice_items, number_series).
8// ═══════════════════════════════════════════════════════════════════════════
9
10use rusqlite::{params, Connection, OptionalExtension};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::path::Path;
14use std::str::FromStr;
15
16use crate::error::{AppError, Result};
17use crate::money::MinorUnits;
18use crate::tax::Jurisdiction;
19
20pub use finance_core::entity::Issuer;
21
22pub fn open() -> Result<Connection> {
23    let paths = finance_core::paths::Paths::resolve()?;
24    Ok(finance_core::db::open(&paths)?)
25}
26
27pub fn open_at(path: &Path) -> Result<Connection> {
28    Ok(finance_core::db::open_at(path)?)
29}
30
31// ─── Domain types ────────────────────────────────────────────────────────
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Client {
35    pub id: i64,
36    pub slug: String,
37    pub name: String,
38    pub attn: Option<String>,
39    pub country: Option<String>,
40    pub tax_id: Option<String>,
41    pub address: Vec<String>,
42    pub email: Option<String>,
43    pub notes: Option<String>,
44    /// If set, `invoices new` defaults `--as` to this issuer slug when omitted.
45    pub default_issuer_slug: Option<String>,
46    /// If set, render uses this template before falling back to the issuer's
47    /// default_template. Explicit `--template` CLI flag still wins.
48    pub default_template: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Product {
53    pub id: i64,
54    pub slug: String,
55    pub description: String,
56    pub subtitle: Option<String>,
57    pub unit: String,
58    pub unit_price: MinorUnits,
59    pub currency: String,
60    pub tax_rate: Decimal,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Invoice {
65    pub id: i64,
66    pub number: String,
67    pub issuer_id: i64,
68    pub client_id: i64,
69    pub issue_date: String,
70    pub due_date: String,
71    pub terms: String,
72    pub currency: String,
73    pub symbol: String,
74    pub tax_label: String,
75    pub status: String,
76    pub notes: Option<String>,
77    pub reverse_charge: bool,
78    /// Optional URL (Stripe Payment Link, EPC-QR payload, any URL) that the
79    /// renderer encodes as a QR code on the PDF.
80    pub pay_link: Option<String>,
81    /// Timestamp (ISO-8601) when the invoice was first marked 'issued'.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub issued_at: Option<String>,
84    /// Timestamp (ISO-8601) when the invoice was first marked 'paid'.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub paid_at: Option<String>,
87    /// Grand total in minor units. Only populated by `invoice_list`, not by
88    /// `invoice_get` (which returns all items so callers can sum themselves).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub total_minor: Option<i64>,
91    /// "invoice" or "credit_note". Credit notes reference a source invoice
92    /// via `credits_invoice_id` and are typically displayed with a "CN-"
93    /// prefix and "CREDIT NOTE" title.
94    pub kind: String,
95    pub credits_invoice_id: Option<i64>,
96    /// Invoice-level discount rate (percent, as decimal string e.g. "10").
97    /// Applied to pre-tax subtotal after line-level discounts.
98    pub discount_rate: Option<Decimal>,
99    /// Invoice-level fixed discount in minor units.
100    pub discount_fixed: Option<MinorUnits>,
101    pub items: Vec<InvoiceItem>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct InvoiceItem {
106    pub id: i64,
107    pub invoice_id: i64,
108    pub position: i64,
109    pub description: String,
110    pub subtitle: Option<String>,
111    pub qty: Decimal,
112    pub unit: String,
113    pub unit_price: MinorUnits,
114    pub tax_rate: Decimal,
115    pub product_id: Option<i64>,
116    /// Per-line discount rate (percent, as Decimal e.g. 10 for 10%). Applied
117    /// to (qty * unit_price) pre-tax. Mutually exclusive with discount_fixed.
118    pub discount_rate: Option<Decimal>,
119    /// Per-line fixed discount in minor units. Applied to line pre-tax.
120    pub discount_fixed: Option<MinorUnits>,
121}
122
123// ─── Helpers ─────────────────────────────────────────────────────────────
124
125fn addr_to_text(lines: &[String]) -> String {
126    lines.join("\n")
127}
128fn text_to_addr(s: &str) -> Vec<String> {
129    s.split('\n').map(|l| l.to_string()).collect()
130}
131
132// ─── Issuers ─────────────────────────────────────────────────────────────
133
134pub fn issuer_create(conn: &Connection, issuer: &Issuer) -> Result<i64> {
135    conn.execute(
136        "INSERT INTO issuers (slug, name, legal_name, jurisdiction, tax_registered,
137                              tax_id, company_no, tagline, address, email, phone,
138                              bank_details, default_template,
139                              currency, symbol, number_format, logo_path,
140                              default_output_dir, default_notes)
141         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
142        params![
143            issuer.slug,
144            issuer.name,
145            issuer.legal_name,
146            issuer.jurisdiction.as_str(),
147            issuer.tax_registered as i32,
148            issuer.tax_id,
149            issuer.company_no,
150            issuer.tagline,
151            addr_to_text(&issuer.address),
152            issuer.email,
153            issuer.phone,
154            issuer.bank_details,
155            issuer.default_template,
156            issuer.currency,
157            issuer.symbol,
158            issuer.number_format,
159            issuer.logo_path,
160            issuer.default_output_dir,
161            issuer.default_notes,
162        ],
163    )?;
164    Ok(conn.last_insert_rowid())
165}
166
167pub fn issuer_list(conn: &Connection) -> Result<Vec<Issuer>> {
168    let mut stmt = conn.prepare(
169        "SELECT id, slug, name, legal_name, jurisdiction, tax_registered,
170                tax_id, company_no, tagline, address, email, phone,
171                bank_details, default_template,
172                currency, symbol, number_format, logo_path,
173                default_output_dir, default_notes
174         FROM issuers ORDER BY slug",
175    )?;
176    let rows = stmt
177        .query_map([], |row| {
178            Ok(Issuer {
179                id: row.get(0)?,
180                slug: row.get(1)?,
181                name: row.get(2)?,
182                legal_name: row.get(3)?,
183                jurisdiction: Jurisdiction::from_str(&row.get::<_, String>(4)?)
184                    .unwrap_or(Jurisdiction::Custom),
185                tax_registered: row.get::<_, i32>(5)? != 0,
186                tax_id: row.get(6)?,
187                company_no: row.get(7)?,
188                tagline: row.get(8)?,
189                address: text_to_addr(&row.get::<_, String>(9)?),
190                email: row.get(10)?,
191                phone: row.get(11)?,
192                bank_details: row.get(12)?,
193                default_template: row.get(13)?,
194                currency: row.get(14)?,
195                symbol: row.get(15)?,
196                number_format: row.get(16)?,
197                logo_path: row.get(17)?,
198                default_output_dir: row.get(18)?,
199                default_notes: row.get(19)?,
200            })
201        })?
202        .collect::<std::result::Result<Vec<_>, _>>()?;
203    Ok(rows)
204}
205
206pub fn issuer_by_slug(conn: &Connection, slug: &str) -> Result<Issuer> {
207    // Exact match first
208    for i in issuer_list(conn)? {
209        if i.slug == slug {
210            return Ok(i);
211        }
212    }
213    // Fuzzy fallback (substring on slug or case-insensitive contains on name)
214    let lower = slug.to_lowercase();
215    let matches: Vec<Issuer> = issuer_list(conn)?
216        .into_iter()
217        .filter(|i| i.slug.contains(slug) || i.name.to_lowercase().contains(&lower))
218        .collect();
219    match matches.len() {
220        0 => Err(AppError::NotFound(format!("issuer '{slug}'"))),
221        1 => Ok(matches.into_iter().next().unwrap()),
222        _ => Err(AppError::Ambiguous(format!(
223            "issuer '{slug}' matches {}",
224            matches
225                .iter()
226                .map(|m| m.slug.as_str())
227                .collect::<Vec<_>>()
228                .join(", ")
229        ))),
230    }
231}
232
233pub fn issuer_delete(conn: &Connection, slug: &str) -> Result<()> {
234    let affected = conn.execute("DELETE FROM issuers WHERE slug = ?1", params![slug])?;
235    if affected == 0 {
236        return Err(AppError::NotFound(format!("issuer '{slug}'")));
237    }
238    Ok(())
239}
240
241/// Full-replace UPDATE. Matches by slug (PK-like). Slug itself cannot change.
242pub fn issuer_update(conn: &Connection, issuer: &Issuer) -> Result<()> {
243    let affected = conn.execute(
244        "UPDATE issuers SET
245             name = ?1, legal_name = ?2, jurisdiction = ?3, tax_registered = ?4,
246             tax_id = ?5, company_no = ?6, tagline = ?7, address = ?8,
247             email = ?9, phone = ?10, bank_details = ?11, default_template = ?12,
248             currency = ?13, symbol = ?14, number_format = ?15, logo_path = ?16,
249             default_output_dir = ?17, default_notes = ?18
250         WHERE slug = ?19",
251        params![
252            issuer.name,
253            issuer.legal_name,
254            issuer.jurisdiction.as_str(),
255            issuer.tax_registered as i32,
256            issuer.tax_id,
257            issuer.company_no,
258            issuer.tagline,
259            addr_to_text(&issuer.address),
260            issuer.email,
261            issuer.phone,
262            issuer.bank_details,
263            issuer.default_template,
264            issuer.currency,
265            issuer.symbol,
266            issuer.number_format,
267            issuer.logo_path,
268            issuer.default_output_dir,
269            issuer.default_notes,
270            issuer.slug,
271        ],
272    )?;
273    if affected == 0 {
274        return Err(AppError::NotFound(format!("issuer '{}'", issuer.slug)));
275    }
276    Ok(())
277}
278
279// ─── Clients ─────────────────────────────────────────────────────────────
280
281pub fn client_create(conn: &Connection, client: &Client) -> Result<i64> {
282    conn.execute(
283        "INSERT INTO clients (slug, name, attn, country, tax_id, address, email, notes,
284                              default_issuer_slug, default_template)
285         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
286        params![
287            client.slug,
288            client.name,
289            client.attn,
290            client.country,
291            client.tax_id,
292            addr_to_text(&client.address),
293            client.email,
294            client.notes,
295            client.default_issuer_slug,
296            client.default_template,
297        ],
298    )?;
299    Ok(conn.last_insert_rowid())
300}
301
302pub fn client_list(conn: &Connection) -> Result<Vec<Client>> {
303    let mut stmt = conn.prepare(
304        "SELECT id, slug, name, attn, country, tax_id, address, email, notes,
305                default_issuer_slug, default_template
306         FROM clients ORDER BY slug",
307    )?;
308    let rows = stmt
309        .query_map([], |row| {
310            Ok(Client {
311                id: row.get(0)?,
312                slug: row.get(1)?,
313                name: row.get(2)?,
314                attn: row.get(3)?,
315                country: row.get(4)?,
316                tax_id: row.get(5)?,
317                address: text_to_addr(&row.get::<_, String>(6)?),
318                email: row.get(7)?,
319                notes: row.get(8)?,
320                default_issuer_slug: row.get(9)?,
321                default_template: row.get(10)?,
322            })
323        })?
324        .collect::<std::result::Result<Vec<_>, _>>()?;
325    Ok(rows)
326}
327
328pub fn client_by_slug(conn: &Connection, slug: &str) -> Result<Client> {
329    for c in client_list(conn)? {
330        if c.slug == slug {
331            return Ok(c);
332        }
333    }
334    // Try fuzzy match
335    let matches: Vec<Client> = client_list(conn)?
336        .into_iter()
337        .filter(|c| c.slug.contains(slug) || c.name.to_lowercase().contains(&slug.to_lowercase()))
338        .collect();
339    match matches.len() {
340        0 => Err(AppError::NotFound(format!("client '{slug}'"))),
341        1 => Ok(matches.into_iter().next().unwrap()),
342        _ => Err(AppError::Ambiguous(format!(
343            "client '{slug}' matches {}",
344            matches
345                .iter()
346                .map(|m| m.slug.as_str())
347                .collect::<Vec<_>>()
348                .join(", ")
349        ))),
350    }
351}
352
353pub fn client_delete(conn: &Connection, slug: &str) -> Result<()> {
354    let affected = conn.execute("DELETE FROM clients WHERE slug = ?1", params![slug])?;
355    if affected == 0 {
356        return Err(AppError::NotFound(format!("client '{slug}'")));
357    }
358    Ok(())
359}
360
361/// Full-replace UPDATE. Matches by slug.
362pub fn client_update(conn: &Connection, client: &Client) -> Result<()> {
363    let affected = conn.execute(
364        "UPDATE clients SET
365             name = ?1, attn = ?2, country = ?3, tax_id = ?4, address = ?5,
366             email = ?6, notes = ?7, default_issuer_slug = ?8, default_template = ?9
367         WHERE slug = ?10",
368        params![
369            client.name,
370            client.attn,
371            client.country,
372            client.tax_id,
373            addr_to_text(&client.address),
374            client.email,
375            client.notes,
376            client.default_issuer_slug,
377            client.default_template,
378            client.slug,
379        ],
380    )?;
381    if affected == 0 {
382        return Err(AppError::NotFound(format!("client '{}'", client.slug)));
383    }
384    Ok(())
385}
386
387// ─── Products ────────────────────────────────────────────────────────────
388
389pub fn product_create(conn: &Connection, p: &Product) -> Result<i64> {
390    conn.execute(
391        "INSERT INTO products (slug, description, subtitle, unit, unit_price_minor, currency, tax_rate)
392         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
393        params![
394            p.slug,
395            p.description,
396            p.subtitle,
397            p.unit,
398            p.unit_price.0,
399            p.currency,
400            p.tax_rate.to_string(),
401        ],
402    )?;
403    Ok(conn.last_insert_rowid())
404}
405
406pub fn product_list(conn: &Connection) -> Result<Vec<Product>> {
407    let mut stmt = conn.prepare(
408        "SELECT id, slug, description, subtitle, unit, unit_price_minor, currency, tax_rate
409         FROM products ORDER BY slug",
410    )?;
411    let rows = stmt
412        .query_map([], |row| {
413            Ok(Product {
414                id: row.get(0)?,
415                slug: row.get(1)?,
416                description: row.get(2)?,
417                subtitle: row.get(3)?,
418                unit: row.get(4)?,
419                unit_price: MinorUnits(row.get::<_, i64>(5)?),
420                currency: row.get(6)?,
421                tax_rate: Decimal::from_str(&row.get::<_, String>(7)?).unwrap_or_default(),
422            })
423        })?
424        .collect::<std::result::Result<Vec<_>, _>>()?;
425    Ok(rows)
426}
427
428pub fn product_by_slug(conn: &Connection, slug: &str) -> Result<Product> {
429    for p in product_list(conn)? {
430        if p.slug == slug {
431            return Ok(p);
432        }
433    }
434    let matches: Vec<Product> = product_list(conn)?
435        .into_iter()
436        .filter(|p| p.slug.contains(slug))
437        .collect();
438    match matches.len() {
439        0 => Err(AppError::NotFound(format!("product '{slug}'"))),
440        1 => Ok(matches.into_iter().next().unwrap()),
441        _ => Err(AppError::Ambiguous(format!(
442            "product '{slug}' matches {}",
443            matches
444                .iter()
445                .map(|m| m.slug.as_str())
446                .collect::<Vec<_>>()
447                .join(", ")
448        ))),
449    }
450}
451
452pub fn product_delete(conn: &Connection, slug: &str) -> Result<()> {
453    let affected = conn.execute("DELETE FROM products WHERE slug = ?1", params![slug])?;
454    if affected == 0 {
455        return Err(AppError::NotFound(format!("product '{slug}'")));
456    }
457    Ok(())
458}
459
460/// Full-replace UPDATE. Matches by slug.
461pub fn product_update(conn: &Connection, product: &Product) -> Result<()> {
462    let affected = conn.execute(
463        "UPDATE products SET
464             description = ?1, subtitle = ?2, unit = ?3, unit_price_minor = ?4,
465             currency = ?5, tax_rate = ?6
466         WHERE slug = ?7",
467        params![
468            product.description,
469            product.subtitle,
470            product.unit,
471            product.unit_price.0,
472            product.currency,
473            product.tax_rate.to_string(),
474            product.slug,
475        ],
476    )?;
477    if affected == 0 {
478        return Err(AppError::NotFound(format!("product '{}'", product.slug)));
479    }
480    Ok(())
481}
482
483// ─── Invoices ────────────────────────────────────────────────────────────
484
485/// Generate the next document number for an issuer/year/kind combination.
486/// `kind` is typically "invoice" or "credit_note" — credit notes get an
487/// independent sequence and are formatted with a "CN-" prefix.
488pub fn next_invoice_number(
489    conn: &Connection,
490    issuer: &Issuer,
491    year: i32,
492    kind: &str,
493) -> Result<String> {
494    let seq: i64 = conn.query_row(
495        "INSERT INTO number_series (issuer_id, year, kind, next_seq)
496         VALUES (?1, ?2, ?3, 2)
497         ON CONFLICT(issuer_id, year, kind) DO UPDATE SET next_seq = next_seq + 1
498         RETURNING next_seq - 1",
499        params![issuer.id, year, kind],
500        |r| r.get(0),
501    )?;
502
503    let out = format_document_number(issuer, year, seq, kind);
504    ensure_globally_unique_number(conn, issuer, &out)
505}
506
507fn format_document_number(issuer: &Issuer, year: i32, seq: i64, kind: &str) -> String {
508    let mut out = issuer.number_format.clone();
509    out = out.replace("{issuer}", &issuer.slug);
510    out = out.replace("{year}", &year.to_string());
511    out = apply_sequence_format(&out, seq);
512    if kind == "credit_note" {
513        out = format!("CN-{out}");
514    }
515    out
516}
517
518fn ensure_globally_unique_number(
519    conn: &Connection,
520    issuer: &Issuer,
521    candidate: &str,
522) -> Result<String> {
523    if !invoice_number_exists(conn, candidate)? {
524        return Ok(candidate.to_string());
525    }
526
527    let prefixed = issuer_prefixed_number(&issuer.slug, candidate);
528    if !invoice_number_exists(conn, &prefixed)? {
529        return Ok(prefixed);
530    }
531
532    Err(AppError::InvalidInput(format!(
533        "generated invoice number '{candidate}' already exists globally. Set a unique issuer number format, e.g. `invoice issuer edit {} --number-format \"{}-{{year}}-{{seq:04}}\"`",
534        issuer.slug, issuer.slug
535    )))
536}
537
538fn invoice_number_exists(conn: &Connection, number: &str) -> Result<bool> {
539    Ok(conn
540        .query_row(
541            "SELECT 1 FROM invoices WHERE number = ?1 LIMIT 1",
542            params![number],
543            |_| Ok(()),
544        )
545        .optional()?
546        .is_some())
547}
548
549fn issuer_prefixed_number(issuer_slug: &str, candidate: &str) -> String {
550    if let Some(rest) = candidate.strip_prefix("CN-") {
551        format!("CN-{issuer_slug}-{rest}")
552    } else {
553        format!("{issuer_slug}-{candidate}")
554    }
555}
556
557fn apply_sequence_format(format: &str, seq: i64) -> String {
558    if let Some(start) = format.find("{seq:") {
559        let width_start = start + "{seq:".len();
560        if let Some(relative_end) = format[width_start..].find('}') {
561            let end = width_start + relative_end;
562            if let Ok(width) = format[width_start..end].parse::<usize>() {
563                let token = &format[start..=end];
564                return format.replace(token, &format!("{:0width$}", seq, width = width));
565            }
566        }
567    }
568
569    format.replace("{seq}", &seq.to_string())
570}
571
572pub fn invoice_create(conn: &mut Connection, inv: &Invoice) -> Result<i64> {
573    let tx = conn.transaction()?;
574    tx.execute(
575        "INSERT INTO invoices (number, issuer_id, client_id, issue_date, due_date,
576                               terms, currency, symbol, tax_label, status, notes,
577                               reverse_charge, pay_link, issued_at, paid_at,
578                               kind, credits_invoice_id, discount_rate, discount_fixed_minor)
579         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
580        params![
581            inv.number,
582            inv.issuer_id,
583            inv.client_id,
584            inv.issue_date,
585            inv.due_date,
586            inv.terms,
587            inv.currency,
588            inv.symbol,
589            inv.tax_label,
590            inv.status,
591            inv.notes,
592            inv.reverse_charge as i32,
593            inv.pay_link,
594            inv.issued_at,
595            inv.paid_at,
596            inv.kind,
597            inv.credits_invoice_id,
598            inv.discount_rate.as_ref().map(|d| d.to_string()),
599            inv.discount_fixed.as_ref().map(|m| m.0),
600        ],
601    )?;
602    let id = tx.last_insert_rowid();
603    for (pos, item) in inv.items.iter().enumerate() {
604        tx.execute(
605            "INSERT INTO invoice_items (invoice_id, position, description, subtitle,
606                                        qty, unit, unit_price_minor, tax_rate, product_id,
607                                        discount_rate, discount_fixed_minor)
608             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
609            params![
610                id,
611                pos as i64,
612                item.description,
613                item.subtitle,
614                item.qty.to_string(),
615                item.unit,
616                item.unit_price.0,
617                item.tax_rate.to_string(),
618                item.product_id,
619                item.discount_rate.as_ref().map(|d| d.to_string()),
620                item.discount_fixed.as_ref().map(|m| m.0),
621            ],
622        )?;
623    }
624    tx.commit()?;
625    Ok(id)
626}
627
628pub fn invoice_get(conn: &Connection, number: &str) -> Result<Invoice> {
629    let mut inv: Invoice = conn.query_row(
630        "SELECT id, number, issuer_id, client_id, issue_date, due_date, terms,
631                currency, symbol, tax_label, status, notes, reverse_charge, pay_link,
632                issued_at, paid_at, kind, credits_invoice_id,
633                discount_rate, discount_fixed_minor
634         FROM invoices WHERE number = ?1",
635        params![number],
636        |row| {
637            Ok(Invoice {
638                id: row.get(0)?,
639                number: row.get(1)?,
640                issuer_id: row.get(2)?,
641                client_id: row.get(3)?,
642                issue_date: row.get(4)?,
643                due_date: row.get(5)?,
644                terms: row.get(6)?,
645                currency: row.get(7)?,
646                symbol: row.get(8)?,
647                tax_label: row.get(9)?,
648                status: row.get(10)?,
649                notes: row.get(11)?,
650                reverse_charge: row.get::<_, i32>(12)? != 0,
651                pay_link: row.get(13)?,
652                issued_at: row.get(14)?,
653                paid_at: row.get(15)?,
654                kind: row.get(16)?,
655                credits_invoice_id: row.get(17)?,
656                discount_rate: row
657                    .get::<_, Option<String>>(18)?
658                    .and_then(|s| Decimal::from_str(&s).ok()),
659                discount_fixed: row.get::<_, Option<i64>>(19)?.map(MinorUnits),
660                total_minor: None,
661                items: vec![],
662            })
663        },
664    )?;
665
666    let mut stmt = conn.prepare(
667        "SELECT id, invoice_id, position, description, subtitle, qty, unit,
668                unit_price_minor, tax_rate, product_id, discount_rate, discount_fixed_minor
669         FROM invoice_items WHERE invoice_id = ?1 ORDER BY position",
670    )?;
671    let items = stmt
672        .query_map(params![inv.id], |row| {
673            Ok(InvoiceItem {
674                id: row.get(0)?,
675                invoice_id: row.get(1)?,
676                position: row.get(2)?,
677                description: row.get(3)?,
678                subtitle: row.get(4)?,
679                qty: Decimal::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
680                unit: row.get(6)?,
681                unit_price: MinorUnits(row.get::<_, i64>(7)?),
682                tax_rate: Decimal::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
683                product_id: row.get(9)?,
684                discount_rate: row
685                    .get::<_, Option<String>>(10)?
686                    .and_then(|s| Decimal::from_str(&s).ok()),
687                discount_fixed: row.get::<_, Option<i64>>(11)?.map(MinorUnits),
688            })
689        })?
690        .collect::<std::result::Result<Vec<_>, _>>()?;
691    inv.items = items;
692    Ok(inv)
693}
694
695pub fn invoice_list(
696    conn: &Connection,
697    status: Option<&str>,
698    issuer_slug: Option<&str>,
699) -> Result<Vec<Invoice>> {
700    let mut query = String::from(
701        "SELECT i.id, i.number, i.issuer_id, i.client_id, i.issue_date, i.due_date,
702                i.terms, i.currency, i.symbol, i.tax_label, i.status, i.notes,
703                i.reverse_charge, i.pay_link, i.issued_at, i.paid_at,
704                i.kind, i.credits_invoice_id, i.discount_rate, i.discount_fixed_minor
705         FROM invoices i JOIN issuers s ON s.id = i.issuer_id WHERE 1=1",
706    );
707    let mut p: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
708    if let Some(st) = status {
709        query.push_str(" AND i.status = ?");
710        p.push(Box::new(st.to_string()));
711    }
712    if let Some(sl) = issuer_slug {
713        query.push_str(" AND s.slug = ?");
714        p.push(Box::new(sl.to_string()));
715    }
716    query.push_str(" ORDER BY i.issue_date DESC");
717    let mut stmt = conn.prepare(&query)?;
718    let mut rows: Vec<Invoice> = stmt
719        .query_map(
720            rusqlite::params_from_iter(p.iter().map(|b| b.as_ref())),
721            |row| {
722                Ok(Invoice {
723                    id: row.get(0)?,
724                    number: row.get(1)?,
725                    issuer_id: row.get(2)?,
726                    client_id: row.get(3)?,
727                    issue_date: row.get(4)?,
728                    due_date: row.get(5)?,
729                    terms: row.get(6)?,
730                    currency: row.get(7)?,
731                    symbol: row.get(8)?,
732                    tax_label: row.get(9)?,
733                    status: row.get(10)?,
734                    notes: row.get(11)?,
735                    reverse_charge: row.get::<_, i32>(12)? != 0,
736                    pay_link: row.get(13)?,
737                    issued_at: row.get(14)?,
738                    paid_at: row.get(15)?,
739                    kind: row.get(16)?,
740                    credits_invoice_id: row.get(17)?,
741                    discount_rate: row
742                        .get::<_, Option<String>>(18)?
743                        .and_then(|s| Decimal::from_str(&s).ok()),
744                    discount_fixed: row.get::<_, Option<i64>>(19)?.map(MinorUnits),
745                    total_minor: None,
746                    items: vec![],
747                })
748            },
749        )?
750        .collect::<std::result::Result<Vec<_>, _>>()?;
751
752    // Populate `total_minor` per invoice. Discount-aware and tax-aware:
753    // keep the same proportional invoice-level discount math used by the
754    // renderer so `invoices list` and PDFs agree.
755    if !rows.is_empty() {
756        use crate::money::{line_total_discounted, tax_amount, MinorUnits};
757        let mut items_stmt = conn.prepare(
758            "SELECT invoice_id, qty, unit_price_minor, tax_rate,
759                    discount_rate, discount_fixed_minor
760             FROM invoice_items
761             WHERE invoice_id IN (SELECT id FROM invoices)",
762        )?;
763        #[derive(Default)]
764        struct Acc {
765            subtotal: i64,
766            by_rate: std::collections::BTreeMap<String, i64>,
767        }
768        let mut acc: std::collections::HashMap<i64, Acc> = std::collections::HashMap::new();
769        let item_rows = items_stmt.query_map([], |row| {
770            Ok((
771                row.get::<_, i64>(0)?,
772                row.get::<_, String>(1)?,
773                row.get::<_, i64>(2)?,
774                row.get::<_, String>(3)?,
775                row.get::<_, Option<String>>(4)?,
776                row.get::<_, Option<i64>>(5)?,
777            ))
778        })?;
779        for r in item_rows {
780            let (iid, qty_s, up_minor, rate_s, disc_rate, disc_fixed) = r?;
781            let qty = Decimal::from_str(&qty_s).unwrap_or_default();
782            let rate = Decimal::from_str(&rate_s).unwrap_or_default();
783            let dr = disc_rate.and_then(|s| Decimal::from_str(&s).ok());
784            let df = disc_fixed.map(MinorUnits);
785            let line = line_total_discounted(qty, MinorUnits(up_minor), dr, df);
786            let e = acc.entry(iid).or_default();
787            e.subtotal += line.0;
788            *e.by_rate.entry(rate.to_string()).or_insert(0) += line.0;
789        }
790        for inv in rows.iter_mut() {
791            if let Some(a) = acc.get(&inv.id) {
792                let discount =
793                    invoice_discount_minor(a.subtotal, inv.discount_rate, inv.discount_fixed);
794                inv.total_minor = Some(total_minor_from_bases(
795                    a.subtotal,
796                    &a.by_rate,
797                    discount,
798                    |base, rate| tax_amount(MinorUnits(base), rate).0,
799                ));
800            }
801        }
802    }
803    Ok(rows)
804}
805
806fn invoice_discount_minor(
807    subtotal: i64,
808    discount_rate: Option<Decimal>,
809    discount_fixed: Option<MinorUnits>,
810) -> i64 {
811    match (discount_rate, discount_fixed) {
812        (Some(rate), _) => crate::money::apply_rate(MinorUnits(subtotal), rate)
813            .0
814            .clamp(0, subtotal),
815        (None, Some(fixed)) => fixed.0.clamp(0, subtotal),
816        _ => 0,
817    }
818}
819
820fn total_minor_from_bases<F>(
821    subtotal: i64,
822    by_rate: &std::collections::BTreeMap<String, i64>,
823    discount: i64,
824    tax_fn: F,
825) -> i64
826where
827    F: Fn(i64, Decimal) -> i64,
828{
829    let subtotal_after_discount = subtotal - discount;
830    let mut tax_total = 0;
831    for (rate_str, base) in by_rate {
832        let rate = Decimal::from_str(rate_str).unwrap_or_default();
833        let scaled_base = if subtotal > 0 && discount > 0 {
834            ((*base as i128) * (subtotal_after_discount as i128) / (subtotal as i128)) as i64
835        } else {
836            *base
837        };
838        tax_total += tax_fn(scaled_base, rate);
839    }
840    subtotal_after_discount + tax_total
841}
842
843/// Update status and, on first transition into `issued` / `paid`, stamp the
844/// corresponding timestamp column. Idempotent — re-marking doesn't overwrite
845/// the original timestamp.
846pub fn invoice_set_status(conn: &Connection, number: &str, status: &str) -> Result<()> {
847    let now = chrono::Utc::now().to_rfc3339();
848    let affected = match status {
849        "issued" => conn.execute(
850            "UPDATE invoices
851                SET status = ?1,
852                    issued_at = COALESCE(issued_at, ?2)
853              WHERE number = ?3",
854            params![status, now, number],
855        )?,
856        "paid" => conn.execute(
857            "UPDATE invoices
858                SET status = ?1,
859                    paid_at = COALESCE(paid_at, ?2)
860              WHERE number = ?3",
861            params![status, now, number],
862        )?,
863        _ => conn.execute(
864            "UPDATE invoices SET status = ?1 WHERE number = ?2",
865            params![status, number],
866        )?,
867    };
868    if affected == 0 {
869        return Err(AppError::NotFound(format!("invoice '{number}'")));
870    }
871    Ok(())
872}
873
874/// Delete an invoice by number. Refuses non-draft invoices unless `force`.
875/// Deleting a non-draft invoice breaks the numbering sequence — which is a
876/// regulatory problem in many jurisdictions. Forcing should be deliberate.
877pub fn invoice_delete(conn: &Connection, number: &str, force: bool) -> Result<()> {
878    let status: Option<String> = conn
879        .query_row(
880            "SELECT status FROM invoices WHERE number = ?1",
881            params![number],
882            |r| r.get(0),
883        )
884        .optional()?;
885    let status = status.ok_or_else(|| AppError::NotFound(format!("invoice '{number}'")))?;
886    if status != "draft" && !force {
887        return Err(AppError::InvalidInput(format!(
888            "refusing to delete non-draft invoice '{number}' (status='{status}') — pass --force to override. Prefer voiding or issuing a credit note."
889        )));
890    }
891    conn.execute("DELETE FROM invoices WHERE number = ?1", params![number])?;
892    Ok(())
893}
894
895/// Draft-only metadata edit. Rejects edits to issued / paid / void invoices —
896/// the correct path for those is a credit note.
897pub fn invoice_update_draft(conn: &Connection, inv: &Invoice) -> Result<()> {
898    let status: Option<String> = conn
899        .query_row(
900            "SELECT status FROM invoices WHERE number = ?1",
901            params![inv.number],
902            |r| r.get(0),
903        )
904        .optional()?;
905    let status = status.ok_or_else(|| AppError::NotFound(format!("invoice '{}'", inv.number)))?;
906    if status != "draft" {
907        return Err(AppError::InvalidInput(format!(
908            "invoice '{}' is {status}, not draft — issued invoices are immutable. Use a credit note to correct.",
909            inv.number
910        )));
911    }
912    conn.execute(
913        "UPDATE invoices SET
914             client_id = ?1, issue_date = ?2, due_date = ?3, terms = ?4,
915             currency = ?5, symbol = ?6, tax_label = ?7, notes = ?8,
916             reverse_charge = ?9, pay_link = ?10,
917             discount_rate = ?11, discount_fixed_minor = ?12
918         WHERE number = ?13",
919        params![
920            inv.client_id,
921            inv.issue_date,
922            inv.due_date,
923            inv.terms,
924            inv.currency,
925            inv.symbol,
926            inv.tax_label,
927            inv.notes,
928            inv.reverse_charge as i32,
929            inv.pay_link,
930            inv.discount_rate.as_ref().map(|d| d.to_string()),
931            inv.discount_fixed.as_ref().map(|m| m.0),
932            inv.number,
933        ],
934    )?;
935    Ok(())
936}
937
938fn require_draft(conn: &Connection, invoice_id: i64) -> Result<()> {
939    let status: String = conn.query_row(
940        "SELECT status FROM invoices WHERE id = ?1",
941        params![invoice_id],
942        |r| r.get(0),
943    )?;
944    if status != "draft" {
945        return Err(AppError::InvalidInput(format!(
946            "invoice is {status}, not draft — items cannot be modified. Use a credit note to correct."
947        )));
948    }
949    Ok(())
950}
951
952/// Append an item to a draft invoice. Fails if the invoice isn't draft.
953pub fn invoice_item_add(conn: &Connection, invoice_id: i64, item: &InvoiceItem) -> Result<i64> {
954    require_draft(conn, invoice_id)?;
955    let next_pos: i64 = conn
956        .query_row(
957            "SELECT COALESCE(MAX(position) + 1, 0) FROM invoice_items WHERE invoice_id = ?1",
958            params![invoice_id],
959            |r| r.get(0),
960        )
961        .unwrap_or(0);
962    conn.execute(
963        "INSERT INTO invoice_items (invoice_id, position, description, subtitle,
964                                    qty, unit, unit_price_minor, tax_rate, product_id,
965                                    discount_rate, discount_fixed_minor)
966         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
967        params![
968            invoice_id,
969            next_pos,
970            item.description,
971            item.subtitle,
972            item.qty.to_string(),
973            item.unit,
974            item.unit_price.0,
975            item.tax_rate.to_string(),
976            item.product_id,
977            item.discount_rate.as_ref().map(|d| d.to_string()),
978            item.discount_fixed.as_ref().map(|m| m.0),
979        ],
980    )?;
981    Ok(conn.last_insert_rowid())
982}
983
984/// Remove the item at `position` from a draft invoice; re-compacts trailing
985/// positions so there are no holes.
986pub fn invoice_item_remove(conn: &mut Connection, invoice_id: i64, position: i64) -> Result<()> {
987    require_draft(conn, invoice_id)?;
988    let tx = conn.transaction()?;
989    let affected = tx.execute(
990        "DELETE FROM invoice_items WHERE invoice_id = ?1 AND position = ?2",
991        params![invoice_id, position],
992    )?;
993    if affected == 0 {
994        return Err(AppError::NotFound(format!(
995            "item at position {position} of invoice id {invoice_id}"
996        )));
997    }
998    tx.execute(
999        "UPDATE invoice_items SET position = position - 1
1000           WHERE invoice_id = ?1 AND position > ?2",
1001        params![invoice_id, position],
1002    )?;
1003    tx.commit()?;
1004    Ok(())
1005}
1006
1007/// Replace the item at `position` with `item`'s fields. Draft-only.
1008pub fn invoice_item_edit(
1009    conn: &Connection,
1010    invoice_id: i64,
1011    position: i64,
1012    item: &InvoiceItem,
1013) -> Result<()> {
1014    require_draft(conn, invoice_id)?;
1015    let affected = conn.execute(
1016        "UPDATE invoice_items SET
1017             description = ?1, subtitle = ?2, qty = ?3, unit = ?4,
1018             unit_price_minor = ?5, tax_rate = ?6, product_id = ?7,
1019             discount_rate = ?8, discount_fixed_minor = ?9
1020         WHERE invoice_id = ?10 AND position = ?11",
1021        params![
1022            item.description,
1023            item.subtitle,
1024            item.qty.to_string(),
1025            item.unit,
1026            item.unit_price.0,
1027            item.tax_rate.to_string(),
1028            item.product_id,
1029            item.discount_rate.as_ref().map(|d| d.to_string()),
1030            item.discount_fixed.as_ref().map(|m| m.0),
1031            invoice_id,
1032            position,
1033        ],
1034    )?;
1035    if affected == 0 {
1036        return Err(AppError::NotFound(format!(
1037            "item at position {position} of invoice id {invoice_id}"
1038        )));
1039    }
1040    Ok(())
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use crate::money::MinorUnits;
1047    use rust_decimal::Decimal;
1048    use std::collections::BTreeMap;
1049    use std::str::FromStr;
1050
1051    #[test]
1052    fn applies_variable_width_sequence_tokens() {
1053        assert_eq!(
1054            apply_sequence_format("199-AP-{year}-{seq:03}", 2),
1055            "199-AP-{year}-002"
1056        );
1057        assert_eq!(apply_sequence_format("{year}-{seq:04}", 42), "{year}-0042");
1058        assert_eq!(apply_sequence_format("{year}-{seq}", 7), "{year}-7");
1059    }
1060
1061    #[test]
1062    fn supports_issuer_token_in_number_format() {
1063        let issuer = test_issuer("paperfoot", "{issuer}-{year}-{seq:03}");
1064        assert_eq!(
1065            format_document_number(&issuer, 2026, 12, "invoice"),
1066            "paperfoot-2026-012"
1067        );
1068    }
1069
1070    #[test]
1071    fn prefixes_legacy_colliding_number_for_second_issuer() {
1072        let tmp = tempfile::NamedTempFile::new().unwrap();
1073        let mut conn = open_at(tmp.path()).unwrap();
1074
1075        let mut alpha = test_issuer("alpha", "{year}-{seq:04}");
1076        alpha.id = issuer_create(&conn, &alpha).unwrap();
1077        let mut beta = test_issuer("beta", "{year}-{seq:04}");
1078        beta.id = issuer_create(&conn, &beta).unwrap();
1079
1080        let client_id = client_create(&conn, &test_client()).unwrap();
1081        let first = next_invoice_number(&conn, &alpha, 2026, "invoice").unwrap();
1082        assert_eq!(first, "2026-0001");
1083        let inv = test_invoice(first, alpha.id, client_id);
1084        invoice_create(&mut conn, &inv).unwrap();
1085
1086        let second = next_invoice_number(&conn, &beta, 2026, "invoice").unwrap();
1087        assert_eq!(second, "beta-2026-0001");
1088    }
1089
1090    #[test]
1091    fn list_total_scales_tax_base_after_invoice_discount() {
1092        let mut bases = BTreeMap::new();
1093        bases.insert("20".to_string(), 10_000);
1094        let discount = invoice_discount_minor(10_000, Some(Decimal::from_str("50").unwrap()), None);
1095        let total = total_minor_from_bases(10_000, &bases, discount, |base, rate| {
1096            crate::money::tax_amount(MinorUnits(base), rate).0
1097        });
1098        assert_eq!(total, 6_000);
1099    }
1100
1101    fn test_issuer(slug: &str, number_format: &str) -> Issuer {
1102        Issuer {
1103            id: 0,
1104            slug: slug.to_string(),
1105            name: slug.to_string(),
1106            legal_name: None,
1107            jurisdiction: Jurisdiction::Uk,
1108            tax_registered: false,
1109            tax_id: None,
1110            company_no: None,
1111            tagline: None,
1112            address: vec!["1 Test Street".into()],
1113            email: None,
1114            phone: None,
1115            bank_details: None,
1116            default_template: "vienna".into(),
1117            currency: Some("GBP".into()),
1118            symbol: Some("£".into()),
1119            number_format: number_format.into(),
1120            logo_path: None,
1121            default_output_dir: None,
1122            default_notes: None,
1123        }
1124    }
1125
1126    fn test_client() -> Client {
1127        Client {
1128            id: 0,
1129            slug: "client".into(),
1130            name: "Client".into(),
1131            attn: None,
1132            country: None,
1133            tax_id: None,
1134            address: vec!["1 Client Street".into()],
1135            email: None,
1136            notes: None,
1137            default_issuer_slug: None,
1138            default_template: None,
1139        }
1140    }
1141
1142    fn test_invoice(number: String, issuer_id: i64, client_id: i64) -> Invoice {
1143        Invoice {
1144            id: 0,
1145            number,
1146            issuer_id,
1147            client_id,
1148            issue_date: "2026-01-01".into(),
1149            due_date: "2026-01-08".into(),
1150            terms: "Pay in full".into(),
1151            currency: "GBP".into(),
1152            symbol: "£".into(),
1153            tax_label: "VAT".into(),
1154            status: "draft".into(),
1155            notes: None,
1156            reverse_charge: false,
1157            pay_link: None,
1158            issued_at: None,
1159            paid_at: None,
1160            total_minor: None,
1161            kind: "invoice".into(),
1162            credits_invoice_id: None,
1163            discount_rate: None,
1164            discount_fixed: None,
1165            items: vec![],
1166        }
1167    }
1168}