Skip to main content

invoice_cli/
db.rs

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