1use 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#[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 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 pub default_issuer_slug: Option<String>,
85 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 pub pay_link: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub issued_at: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub paid_at: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
129 pub total_minor: Option<i64>,
130 pub kind: String,
134 pub credits_invoice_id: Option<i64>,
135 pub discount_rate: Option<Decimal>,
138 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 pub discount_rate: Option<Decimal>,
158 pub discount_fixed: Option<MinorUnits>,
160}
161
162fn 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
171pub 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 for i in issuer_list(conn)? {
246 if i.slug == slug {
247 return Ok(i);
248 }
249 }
250 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
278pub 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
316pub 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 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
398pub 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
424pub 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
493pub 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
516pub 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 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 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
798pub 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
829pub 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
850pub 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
908pub 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
940pub 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
963pub 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}