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
495        .query_row(
496            "SELECT next_seq FROM number_series
497               WHERE issuer_id = ?1 AND year = ?2 AND kind = ?3",
498            params![issuer.id, year, kind],
499            |r| r.get(0),
500        )
501        .optional()?
502        .unwrap_or(1);
503
504    conn.execute(
505        "INSERT INTO number_series (issuer_id, year, kind, next_seq)
506         VALUES (?1, ?2, ?3, ?4)
507         ON CONFLICT(issuer_id, year, kind) DO UPDATE SET next_seq = next_seq + 1",
508        params![issuer.id, year, kind, seq + 1],
509    )?;
510
511    let mut out = issuer.number_format.clone();
512    out = out.replace("{year}", &year.to_string());
513    out = apply_sequence_format(&out, seq);
514    if kind == "credit_note" {
515        out = format!("CN-{out}");
516    }
517    Ok(out)
518}
519
520fn apply_sequence_format(format: &str, seq: i64) -> String {
521    if let Some(start) = format.find("{seq:") {
522        let width_start = start + "{seq:".len();
523        if let Some(relative_end) = format[width_start..].find('}') {
524            let end = width_start + relative_end;
525            if let Ok(width) = format[width_start..end].parse::<usize>() {
526                let token = &format[start..=end];
527                return format.replace(token, &format!("{:0width$}", seq, width = width));
528            }
529        }
530    }
531
532    format.replace("{seq}", &seq.to_string())
533}
534
535pub fn invoice_create(conn: &mut Connection, inv: &Invoice) -> Result<i64> {
536    let tx = conn.transaction()?;
537    tx.execute(
538        "INSERT INTO invoices (number, issuer_id, client_id, issue_date, due_date,
539                               terms, currency, symbol, tax_label, status, notes,
540                               reverse_charge, pay_link, issued_at, paid_at,
541                               kind, credits_invoice_id, discount_rate, discount_fixed_minor)
542         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
543        params![
544            inv.number,
545            inv.issuer_id,
546            inv.client_id,
547            inv.issue_date,
548            inv.due_date,
549            inv.terms,
550            inv.currency,
551            inv.symbol,
552            inv.tax_label,
553            inv.status,
554            inv.notes,
555            inv.reverse_charge as i32,
556            inv.pay_link,
557            inv.issued_at,
558            inv.paid_at,
559            inv.kind,
560            inv.credits_invoice_id,
561            inv.discount_rate.as_ref().map(|d| d.to_string()),
562            inv.discount_fixed.as_ref().map(|m| m.0),
563        ],
564    )?;
565    let id = tx.last_insert_rowid();
566    for (pos, item) in inv.items.iter().enumerate() {
567        tx.execute(
568            "INSERT INTO invoice_items (invoice_id, position, description, subtitle,
569                                        qty, unit, unit_price_minor, tax_rate, product_id,
570                                        discount_rate, discount_fixed_minor)
571             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
572            params![
573                id,
574                pos as i64,
575                item.description,
576                item.subtitle,
577                item.qty.to_string(),
578                item.unit,
579                item.unit_price.0,
580                item.tax_rate.to_string(),
581                item.product_id,
582                item.discount_rate.as_ref().map(|d| d.to_string()),
583                item.discount_fixed.as_ref().map(|m| m.0),
584            ],
585        )?;
586    }
587    tx.commit()?;
588    Ok(id)
589}
590
591pub fn invoice_get(conn: &Connection, number: &str) -> Result<Invoice> {
592    let mut inv: Invoice = conn.query_row(
593        "SELECT id, number, issuer_id, client_id, issue_date, due_date, terms,
594                currency, symbol, tax_label, status, notes, reverse_charge, pay_link,
595                issued_at, paid_at, kind, credits_invoice_id,
596                discount_rate, discount_fixed_minor
597         FROM invoices WHERE number = ?1",
598        params![number],
599        |row| {
600            Ok(Invoice {
601                id: row.get(0)?,
602                number: row.get(1)?,
603                issuer_id: row.get(2)?,
604                client_id: row.get(3)?,
605                issue_date: row.get(4)?,
606                due_date: row.get(5)?,
607                terms: row.get(6)?,
608                currency: row.get(7)?,
609                symbol: row.get(8)?,
610                tax_label: row.get(9)?,
611                status: row.get(10)?,
612                notes: row.get(11)?,
613                reverse_charge: row.get::<_, i32>(12)? != 0,
614                pay_link: row.get(13)?,
615                issued_at: row.get(14)?,
616                paid_at: row.get(15)?,
617                kind: row.get(16)?,
618                credits_invoice_id: row.get(17)?,
619                discount_rate: row
620                    .get::<_, Option<String>>(18)?
621                    .and_then(|s| Decimal::from_str(&s).ok()),
622                discount_fixed: row.get::<_, Option<i64>>(19)?.map(MinorUnits),
623                total_minor: None,
624                items: vec![],
625            })
626        },
627    )?;
628
629    let mut stmt = conn.prepare(
630        "SELECT id, invoice_id, position, description, subtitle, qty, unit,
631                unit_price_minor, tax_rate, product_id, discount_rate, discount_fixed_minor
632         FROM invoice_items WHERE invoice_id = ?1 ORDER BY position",
633    )?;
634    let items = stmt
635        .query_map(params![inv.id], |row| {
636            Ok(InvoiceItem {
637                id: row.get(0)?,
638                invoice_id: row.get(1)?,
639                position: row.get(2)?,
640                description: row.get(3)?,
641                subtitle: row.get(4)?,
642                qty: Decimal::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
643                unit: row.get(6)?,
644                unit_price: MinorUnits(row.get::<_, i64>(7)?),
645                tax_rate: Decimal::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
646                product_id: row.get(9)?,
647                discount_rate: row
648                    .get::<_, Option<String>>(10)?
649                    .and_then(|s| Decimal::from_str(&s).ok()),
650                discount_fixed: row.get::<_, Option<i64>>(11)?.map(MinorUnits),
651            })
652        })?
653        .collect::<std::result::Result<Vec<_>, _>>()?;
654    inv.items = items;
655    Ok(inv)
656}
657
658pub fn invoice_list(
659    conn: &Connection,
660    status: Option<&str>,
661    issuer_slug: Option<&str>,
662) -> Result<Vec<Invoice>> {
663    let mut query = String::from(
664        "SELECT i.id, i.number, i.issuer_id, i.client_id, i.issue_date, i.due_date,
665                i.terms, i.currency, i.symbol, i.tax_label, i.status, i.notes,
666                i.reverse_charge, i.pay_link, i.issued_at, i.paid_at,
667                i.kind, i.credits_invoice_id, i.discount_rate, i.discount_fixed_minor
668         FROM invoices i JOIN issuers s ON s.id = i.issuer_id WHERE 1=1",
669    );
670    let mut p: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
671    if let Some(st) = status {
672        query.push_str(" AND i.status = ?");
673        p.push(Box::new(st.to_string()));
674    }
675    if let Some(sl) = issuer_slug {
676        query.push_str(" AND s.slug = ?");
677        p.push(Box::new(sl.to_string()));
678    }
679    query.push_str(" ORDER BY i.issue_date DESC");
680    let mut stmt = conn.prepare(&query)?;
681    let mut rows: Vec<Invoice> = stmt
682        .query_map(
683            rusqlite::params_from_iter(p.iter().map(|b| b.as_ref())),
684            |row| {
685                Ok(Invoice {
686                    id: row.get(0)?,
687                    number: row.get(1)?,
688                    issuer_id: row.get(2)?,
689                    client_id: row.get(3)?,
690                    issue_date: row.get(4)?,
691                    due_date: row.get(5)?,
692                    terms: row.get(6)?,
693                    currency: row.get(7)?,
694                    symbol: row.get(8)?,
695                    tax_label: row.get(9)?,
696                    status: row.get(10)?,
697                    notes: row.get(11)?,
698                    reverse_charge: row.get::<_, i32>(12)? != 0,
699                    pay_link: row.get(13)?,
700                    issued_at: row.get(14)?,
701                    paid_at: row.get(15)?,
702                    kind: row.get(16)?,
703                    credits_invoice_id: row.get(17)?,
704                    discount_rate: row
705                        .get::<_, Option<String>>(18)?
706                        .and_then(|s| Decimal::from_str(&s).ok()),
707                    discount_fixed: row.get::<_, Option<i64>>(19)?.map(MinorUnits),
708                    total_minor: None,
709                    items: vec![],
710                })
711            },
712        )?
713        .collect::<std::result::Result<Vec<_>, _>>()?;
714
715    // Populate `total_minor` per invoice. Discount-aware and tax-aware:
716    // keep the same proportional invoice-level discount math used by the
717    // renderer so `invoices list` and PDFs agree.
718    if !rows.is_empty() {
719        use crate::money::{line_total_discounted, tax_amount, MinorUnits};
720        let mut items_stmt = conn.prepare(
721            "SELECT invoice_id, qty, unit_price_minor, tax_rate,
722                    discount_rate, discount_fixed_minor
723             FROM invoice_items
724             WHERE invoice_id IN (SELECT id FROM invoices)",
725        )?;
726        #[derive(Default)]
727        struct Acc {
728            subtotal: i64,
729            by_rate: std::collections::BTreeMap<String, i64>,
730        }
731        let mut acc: std::collections::HashMap<i64, Acc> = std::collections::HashMap::new();
732        let item_rows = items_stmt.query_map([], |row| {
733            Ok((
734                row.get::<_, i64>(0)?,
735                row.get::<_, String>(1)?,
736                row.get::<_, i64>(2)?,
737                row.get::<_, String>(3)?,
738                row.get::<_, Option<String>>(4)?,
739                row.get::<_, Option<i64>>(5)?,
740            ))
741        })?;
742        for r in item_rows {
743            let (iid, qty_s, up_minor, rate_s, disc_rate, disc_fixed) = r?;
744            let qty = Decimal::from_str(&qty_s).unwrap_or_default();
745            let rate = Decimal::from_str(&rate_s).unwrap_or_default();
746            let dr = disc_rate.and_then(|s| Decimal::from_str(&s).ok());
747            let df = disc_fixed.map(MinorUnits);
748            let line = line_total_discounted(qty, MinorUnits(up_minor), dr, df);
749            let e = acc.entry(iid).or_default();
750            e.subtotal += line.0;
751            *e.by_rate.entry(rate.to_string()).or_insert(0) += line.0;
752        }
753        for inv in rows.iter_mut() {
754            if let Some(a) = acc.get(&inv.id) {
755                let discount =
756                    invoice_discount_minor(a.subtotal, inv.discount_rate, inv.discount_fixed);
757                inv.total_minor = Some(total_minor_from_bases(
758                    a.subtotal,
759                    &a.by_rate,
760                    discount,
761                    |base, rate| tax_amount(MinorUnits(base), rate).0,
762                ));
763            }
764        }
765    }
766    Ok(rows)
767}
768
769fn invoice_discount_minor(
770    subtotal: i64,
771    discount_rate: Option<Decimal>,
772    discount_fixed: Option<MinorUnits>,
773) -> i64 {
774    match (discount_rate, discount_fixed) {
775        (Some(rate), _) => crate::money::apply_rate(MinorUnits(subtotal), rate)
776            .0
777            .clamp(0, subtotal),
778        (None, Some(fixed)) => fixed.0.clamp(0, subtotal),
779        _ => 0,
780    }
781}
782
783fn total_minor_from_bases<F>(
784    subtotal: i64,
785    by_rate: &std::collections::BTreeMap<String, i64>,
786    discount: i64,
787    tax_fn: F,
788) -> i64
789where
790    F: Fn(i64, Decimal) -> i64,
791{
792    let subtotal_after_discount = subtotal - discount;
793    let mut tax_total = 0;
794    for (rate_str, base) in by_rate {
795        let rate = Decimal::from_str(rate_str).unwrap_or_default();
796        let scaled_base = if subtotal > 0 && discount > 0 {
797            ((*base as i128) * (subtotal_after_discount as i128) / (subtotal as i128)) as i64
798        } else {
799            *base
800        };
801        tax_total += tax_fn(scaled_base, rate);
802    }
803    subtotal_after_discount + tax_total
804}
805
806/// Update status and, on first transition into `issued` / `paid`, stamp the
807/// corresponding timestamp column. Idempotent — re-marking doesn't overwrite
808/// the original timestamp.
809pub fn invoice_set_status(conn: &Connection, number: &str, status: &str) -> Result<()> {
810    let now = chrono::Utc::now().to_rfc3339();
811    let affected = match status {
812        "issued" => conn.execute(
813            "UPDATE invoices
814                SET status = ?1,
815                    issued_at = COALESCE(issued_at, ?2)
816              WHERE number = ?3",
817            params![status, now, number],
818        )?,
819        "paid" => conn.execute(
820            "UPDATE invoices
821                SET status = ?1,
822                    paid_at = COALESCE(paid_at, ?2)
823              WHERE number = ?3",
824            params![status, now, number],
825        )?,
826        _ => conn.execute(
827            "UPDATE invoices SET status = ?1 WHERE number = ?2",
828            params![status, number],
829        )?,
830    };
831    if affected == 0 {
832        return Err(AppError::NotFound(format!("invoice '{number}'")));
833    }
834    Ok(())
835}
836
837/// Delete an invoice by number. Refuses non-draft invoices unless `force`.
838/// Deleting a non-draft invoice breaks the numbering sequence — which is a
839/// regulatory problem in many jurisdictions. Forcing should be deliberate.
840pub fn invoice_delete(conn: &Connection, number: &str, force: bool) -> Result<()> {
841    let status: Option<String> = conn
842        .query_row(
843            "SELECT status FROM invoices WHERE number = ?1",
844            params![number],
845            |r| r.get(0),
846        )
847        .optional()?;
848    let status = status.ok_or_else(|| AppError::NotFound(format!("invoice '{number}'")))?;
849    if status != "draft" && !force {
850        return Err(AppError::InvalidInput(format!(
851            "refusing to delete non-draft invoice '{number}' (status='{status}') — pass --force to override. Prefer voiding or issuing a credit note."
852        )));
853    }
854    conn.execute("DELETE FROM invoices WHERE number = ?1", params![number])?;
855    Ok(())
856}
857
858/// Draft-only metadata edit. Rejects edits to issued / paid / void invoices —
859/// the correct path for those is a credit note.
860pub fn invoice_update_draft(conn: &Connection, inv: &Invoice) -> Result<()> {
861    let status: Option<String> = conn
862        .query_row(
863            "SELECT status FROM invoices WHERE number = ?1",
864            params![inv.number],
865            |r| r.get(0),
866        )
867        .optional()?;
868    let status = status.ok_or_else(|| AppError::NotFound(format!("invoice '{}'", inv.number)))?;
869    if status != "draft" {
870        return Err(AppError::InvalidInput(format!(
871            "invoice '{}' is {status}, not draft — issued invoices are immutable. Use a credit note to correct.",
872            inv.number
873        )));
874    }
875    conn.execute(
876        "UPDATE invoices SET
877             client_id = ?1, issue_date = ?2, due_date = ?3, terms = ?4,
878             currency = ?5, symbol = ?6, tax_label = ?7, notes = ?8,
879             reverse_charge = ?9, pay_link = ?10,
880             discount_rate = ?11, discount_fixed_minor = ?12
881         WHERE number = ?13",
882        params![
883            inv.client_id,
884            inv.issue_date,
885            inv.due_date,
886            inv.terms,
887            inv.currency,
888            inv.symbol,
889            inv.tax_label,
890            inv.notes,
891            inv.reverse_charge as i32,
892            inv.pay_link,
893            inv.discount_rate.as_ref().map(|d| d.to_string()),
894            inv.discount_fixed.as_ref().map(|m| m.0),
895            inv.number,
896        ],
897    )?;
898    Ok(())
899}
900
901fn require_draft(conn: &Connection, invoice_id: i64) -> Result<()> {
902    let status: String = conn.query_row(
903        "SELECT status FROM invoices WHERE id = ?1",
904        params![invoice_id],
905        |r| r.get(0),
906    )?;
907    if status != "draft" {
908        return Err(AppError::InvalidInput(format!(
909            "invoice is {status}, not draft — items cannot be modified. Use a credit note to correct."
910        )));
911    }
912    Ok(())
913}
914
915/// Append an item to a draft invoice. Fails if the invoice isn't draft.
916pub fn invoice_item_add(conn: &Connection, invoice_id: i64, item: &InvoiceItem) -> Result<i64> {
917    require_draft(conn, invoice_id)?;
918    let next_pos: i64 = conn
919        .query_row(
920            "SELECT COALESCE(MAX(position) + 1, 0) FROM invoice_items WHERE invoice_id = ?1",
921            params![invoice_id],
922            |r| r.get(0),
923        )
924        .unwrap_or(0);
925    conn.execute(
926        "INSERT INTO invoice_items (invoice_id, position, description, subtitle,
927                                    qty, unit, unit_price_minor, tax_rate, product_id,
928                                    discount_rate, discount_fixed_minor)
929         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
930        params![
931            invoice_id,
932            next_pos,
933            item.description,
934            item.subtitle,
935            item.qty.to_string(),
936            item.unit,
937            item.unit_price.0,
938            item.tax_rate.to_string(),
939            item.product_id,
940            item.discount_rate.as_ref().map(|d| d.to_string()),
941            item.discount_fixed.as_ref().map(|m| m.0),
942        ],
943    )?;
944    Ok(conn.last_insert_rowid())
945}
946
947/// Remove the item at `position` from a draft invoice; re-compacts trailing
948/// positions so there are no holes.
949pub fn invoice_item_remove(conn: &mut Connection, invoice_id: i64, position: i64) -> Result<()> {
950    require_draft(conn, invoice_id)?;
951    let tx = conn.transaction()?;
952    let affected = tx.execute(
953        "DELETE FROM invoice_items WHERE invoice_id = ?1 AND position = ?2",
954        params![invoice_id, position],
955    )?;
956    if affected == 0 {
957        return Err(AppError::NotFound(format!(
958            "item at position {position} of invoice id {invoice_id}"
959        )));
960    }
961    tx.execute(
962        "UPDATE invoice_items SET position = position - 1
963           WHERE invoice_id = ?1 AND position > ?2",
964        params![invoice_id, position],
965    )?;
966    tx.commit()?;
967    Ok(())
968}
969
970/// Replace the item at `position` with `item`'s fields. Draft-only.
971pub fn invoice_item_edit(
972    conn: &Connection,
973    invoice_id: i64,
974    position: i64,
975    item: &InvoiceItem,
976) -> Result<()> {
977    require_draft(conn, invoice_id)?;
978    let affected = conn.execute(
979        "UPDATE invoice_items SET
980             description = ?1, subtitle = ?2, qty = ?3, unit = ?4,
981             unit_price_minor = ?5, tax_rate = ?6, product_id = ?7,
982             discount_rate = ?8, discount_fixed_minor = ?9
983         WHERE invoice_id = ?10 AND position = ?11",
984        params![
985            item.description,
986            item.subtitle,
987            item.qty.to_string(),
988            item.unit,
989            item.unit_price.0,
990            item.tax_rate.to_string(),
991            item.product_id,
992            item.discount_rate.as_ref().map(|d| d.to_string()),
993            item.discount_fixed.as_ref().map(|m| m.0),
994            invoice_id,
995            position,
996        ],
997    )?;
998    if affected == 0 {
999        return Err(AppError::NotFound(format!(
1000            "item at position {position} of invoice id {invoice_id}"
1001        )));
1002    }
1003    Ok(())
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::{apply_sequence_format, invoice_discount_minor, total_minor_from_bases};
1009    use crate::money::MinorUnits;
1010    use rust_decimal::Decimal;
1011    use std::collections::BTreeMap;
1012    use std::str::FromStr;
1013
1014    #[test]
1015    fn applies_variable_width_sequence_tokens() {
1016        assert_eq!(
1017            apply_sequence_format("199-AP-{year}-{seq:03}", 2),
1018            "199-AP-{year}-002"
1019        );
1020        assert_eq!(apply_sequence_format("{year}-{seq:04}", 42), "{year}-0042");
1021        assert_eq!(apply_sequence_format("{year}-{seq}", 7), "{year}-7");
1022    }
1023
1024    #[test]
1025    fn list_total_scales_tax_base_after_invoice_discount() {
1026        let mut bases = BTreeMap::new();
1027        bases.insert("20".to_string(), 10_000);
1028        let discount = invoice_discount_minor(10_000, Some(Decimal::from_str("50").unwrap()), None);
1029        let total = total_minor_from_bases(10_000, &bases, discount, |base, rate| {
1030            crate::money::tax_amount(MinorUnits(base), rate).0
1031        });
1032        assert_eq!(total, 6_000);
1033    }
1034}