1use 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#[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 pub default_issuer_slug: Option<String>,
46 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 pub pay_link: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub issued_at: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub paid_at: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
90 pub total_minor: Option<i64>,
91 pub kind: String,
95 pub credits_invoice_id: Option<i64>,
96 pub discount_rate: Option<Decimal>,
99 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 pub discount_rate: Option<Decimal>,
119 pub discount_fixed: Option<MinorUnits>,
121}
122
123fn 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
132pub 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 for i in issuer_list(conn)? {
209 if i.slug == slug {
210 return Ok(i);
211 }
212 }
213 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
241pub 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
279pub 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 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
361pub 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
387pub 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
460pub 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
483pub 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 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
806pub 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
837pub 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
858pub 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
915pub 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
947pub 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
970pub 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}