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.query_row(
495 "INSERT INTO number_series (issuer_id, year, kind, next_seq)
496 VALUES (?1, ?2, ?3, 2)
497 ON CONFLICT(issuer_id, year, kind) DO UPDATE SET next_seq = next_seq + 1
498 RETURNING next_seq - 1",
499 params![issuer.id, year, kind],
500 |r| r.get(0),
501 )?;
502
503 let out = format_document_number(issuer, year, seq, kind);
504 ensure_globally_unique_number(conn, issuer, &out)
505}
506
507fn format_document_number(issuer: &Issuer, year: i32, seq: i64, kind: &str) -> String {
508 let mut out = issuer.number_format.clone();
509 out = out.replace("{issuer}", &issuer.slug);
510 out = out.replace("{year}", &year.to_string());
511 out = apply_sequence_format(&out, seq);
512 if kind == "credit_note" {
513 out = format!("CN-{out}");
514 }
515 out
516}
517
518fn ensure_globally_unique_number(
519 conn: &Connection,
520 issuer: &Issuer,
521 candidate: &str,
522) -> Result<String> {
523 if !invoice_number_exists(conn, candidate)? {
524 return Ok(candidate.to_string());
525 }
526
527 let prefixed = issuer_prefixed_number(&issuer.slug, candidate);
528 if !invoice_number_exists(conn, &prefixed)? {
529 return Ok(prefixed);
530 }
531
532 Err(AppError::InvalidInput(format!(
533 "generated invoice number '{candidate}' already exists globally. Set a unique issuer number format, e.g. `invoice issuer edit {} --number-format \"{}-{{year}}-{{seq:04}}\"`",
534 issuer.slug, issuer.slug
535 )))
536}
537
538fn invoice_number_exists(conn: &Connection, number: &str) -> Result<bool> {
539 Ok(conn
540 .query_row(
541 "SELECT 1 FROM invoices WHERE number = ?1 LIMIT 1",
542 params![number],
543 |_| Ok(()),
544 )
545 .optional()?
546 .is_some())
547}
548
549fn issuer_prefixed_number(issuer_slug: &str, candidate: &str) -> String {
550 if let Some(rest) = candidate.strip_prefix("CN-") {
551 format!("CN-{issuer_slug}-{rest}")
552 } else {
553 format!("{issuer_slug}-{candidate}")
554 }
555}
556
557fn apply_sequence_format(format: &str, seq: i64) -> String {
558 if let Some(start) = format.find("{seq:") {
559 let width_start = start + "{seq:".len();
560 if let Some(relative_end) = format[width_start..].find('}') {
561 let end = width_start + relative_end;
562 if let Ok(width) = format[width_start..end].parse::<usize>() {
563 let token = &format[start..=end];
564 return format.replace(token, &format!("{:0width$}", seq, width = width));
565 }
566 }
567 }
568
569 format.replace("{seq}", &seq.to_string())
570}
571
572pub fn invoice_create(conn: &mut Connection, inv: &Invoice) -> Result<i64> {
573 let tx = conn.transaction()?;
574 tx.execute(
575 "INSERT INTO invoices (number, issuer_id, client_id, issue_date, due_date,
576 terms, currency, symbol, tax_label, status, notes,
577 reverse_charge, pay_link, issued_at, paid_at,
578 kind, credits_invoice_id, discount_rate, discount_fixed_minor)
579 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
580 params![
581 inv.number,
582 inv.issuer_id,
583 inv.client_id,
584 inv.issue_date,
585 inv.due_date,
586 inv.terms,
587 inv.currency,
588 inv.symbol,
589 inv.tax_label,
590 inv.status,
591 inv.notes,
592 inv.reverse_charge as i32,
593 inv.pay_link,
594 inv.issued_at,
595 inv.paid_at,
596 inv.kind,
597 inv.credits_invoice_id,
598 inv.discount_rate.as_ref().map(|d| d.to_string()),
599 inv.discount_fixed.as_ref().map(|m| m.0),
600 ],
601 )?;
602 let id = tx.last_insert_rowid();
603 for (pos, item) in inv.items.iter().enumerate() {
604 tx.execute(
605 "INSERT INTO invoice_items (invoice_id, position, description, subtitle,
606 qty, unit, unit_price_minor, tax_rate, product_id,
607 discount_rate, discount_fixed_minor)
608 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
609 params![
610 id,
611 pos as i64,
612 item.description,
613 item.subtitle,
614 item.qty.to_string(),
615 item.unit,
616 item.unit_price.0,
617 item.tax_rate.to_string(),
618 item.product_id,
619 item.discount_rate.as_ref().map(|d| d.to_string()),
620 item.discount_fixed.as_ref().map(|m| m.0),
621 ],
622 )?;
623 }
624 tx.commit()?;
625 Ok(id)
626}
627
628pub fn invoice_get(conn: &Connection, number: &str) -> Result<Invoice> {
629 let mut inv: Invoice = conn.query_row(
630 "SELECT id, number, issuer_id, client_id, issue_date, due_date, terms,
631 currency, symbol, tax_label, status, notes, reverse_charge, pay_link,
632 issued_at, paid_at, kind, credits_invoice_id,
633 discount_rate, discount_fixed_minor
634 FROM invoices WHERE number = ?1",
635 params![number],
636 |row| {
637 Ok(Invoice {
638 id: row.get(0)?,
639 number: row.get(1)?,
640 issuer_id: row.get(2)?,
641 client_id: row.get(3)?,
642 issue_date: row.get(4)?,
643 due_date: row.get(5)?,
644 terms: row.get(6)?,
645 currency: row.get(7)?,
646 symbol: row.get(8)?,
647 tax_label: row.get(9)?,
648 status: row.get(10)?,
649 notes: row.get(11)?,
650 reverse_charge: row.get::<_, i32>(12)? != 0,
651 pay_link: row.get(13)?,
652 issued_at: row.get(14)?,
653 paid_at: row.get(15)?,
654 kind: row.get(16)?,
655 credits_invoice_id: row.get(17)?,
656 discount_rate: row
657 .get::<_, Option<String>>(18)?
658 .and_then(|s| Decimal::from_str(&s).ok()),
659 discount_fixed: row.get::<_, Option<i64>>(19)?.map(MinorUnits),
660 total_minor: None,
661 items: vec![],
662 })
663 },
664 )?;
665
666 let mut stmt = conn.prepare(
667 "SELECT id, invoice_id, position, description, subtitle, qty, unit,
668 unit_price_minor, tax_rate, product_id, discount_rate, discount_fixed_minor
669 FROM invoice_items WHERE invoice_id = ?1 ORDER BY position",
670 )?;
671 let items = stmt
672 .query_map(params![inv.id], |row| {
673 Ok(InvoiceItem {
674 id: row.get(0)?,
675 invoice_id: row.get(1)?,
676 position: row.get(2)?,
677 description: row.get(3)?,
678 subtitle: row.get(4)?,
679 qty: Decimal::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
680 unit: row.get(6)?,
681 unit_price: MinorUnits(row.get::<_, i64>(7)?),
682 tax_rate: Decimal::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
683 product_id: row.get(9)?,
684 discount_rate: row
685 .get::<_, Option<String>>(10)?
686 .and_then(|s| Decimal::from_str(&s).ok()),
687 discount_fixed: row.get::<_, Option<i64>>(11)?.map(MinorUnits),
688 })
689 })?
690 .collect::<std::result::Result<Vec<_>, _>>()?;
691 inv.items = items;
692 Ok(inv)
693}
694
695pub fn invoice_list(
696 conn: &Connection,
697 status: Option<&str>,
698 issuer_slug: Option<&str>,
699) -> Result<Vec<Invoice>> {
700 let mut query = String::from(
701 "SELECT i.id, i.number, i.issuer_id, i.client_id, i.issue_date, i.due_date,
702 i.terms, i.currency, i.symbol, i.tax_label, i.status, i.notes,
703 i.reverse_charge, i.pay_link, i.issued_at, i.paid_at,
704 i.kind, i.credits_invoice_id, i.discount_rate, i.discount_fixed_minor
705 FROM invoices i JOIN issuers s ON s.id = i.issuer_id WHERE 1=1",
706 );
707 let mut p: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
708 if let Some(st) = status {
709 query.push_str(" AND i.status = ?");
710 p.push(Box::new(st.to_string()));
711 }
712 if let Some(sl) = issuer_slug {
713 query.push_str(" AND s.slug = ?");
714 p.push(Box::new(sl.to_string()));
715 }
716 query.push_str(" ORDER BY i.issue_date DESC");
717 let mut stmt = conn.prepare(&query)?;
718 let mut rows: Vec<Invoice> = stmt
719 .query_map(
720 rusqlite::params_from_iter(p.iter().map(|b| b.as_ref())),
721 |row| {
722 Ok(Invoice {
723 id: row.get(0)?,
724 number: row.get(1)?,
725 issuer_id: row.get(2)?,
726 client_id: row.get(3)?,
727 issue_date: row.get(4)?,
728 due_date: row.get(5)?,
729 terms: row.get(6)?,
730 currency: row.get(7)?,
731 symbol: row.get(8)?,
732 tax_label: row.get(9)?,
733 status: row.get(10)?,
734 notes: row.get(11)?,
735 reverse_charge: row.get::<_, i32>(12)? != 0,
736 pay_link: row.get(13)?,
737 issued_at: row.get(14)?,
738 paid_at: row.get(15)?,
739 kind: row.get(16)?,
740 credits_invoice_id: row.get(17)?,
741 discount_rate: row
742 .get::<_, Option<String>>(18)?
743 .and_then(|s| Decimal::from_str(&s).ok()),
744 discount_fixed: row.get::<_, Option<i64>>(19)?.map(MinorUnits),
745 total_minor: None,
746 items: vec![],
747 })
748 },
749 )?
750 .collect::<std::result::Result<Vec<_>, _>>()?;
751
752 if !rows.is_empty() {
756 use crate::money::{line_total_discounted, tax_amount, MinorUnits};
757 let mut items_stmt = conn.prepare(
758 "SELECT invoice_id, qty, unit_price_minor, tax_rate,
759 discount_rate, discount_fixed_minor
760 FROM invoice_items
761 WHERE invoice_id IN (SELECT id FROM invoices)",
762 )?;
763 #[derive(Default)]
764 struct Acc {
765 subtotal: i64,
766 by_rate: std::collections::BTreeMap<String, i64>,
767 }
768 let mut acc: std::collections::HashMap<i64, Acc> = std::collections::HashMap::new();
769 let item_rows = items_stmt.query_map([], |row| {
770 Ok((
771 row.get::<_, i64>(0)?,
772 row.get::<_, String>(1)?,
773 row.get::<_, i64>(2)?,
774 row.get::<_, String>(3)?,
775 row.get::<_, Option<String>>(4)?,
776 row.get::<_, Option<i64>>(5)?,
777 ))
778 })?;
779 for r in item_rows {
780 let (iid, qty_s, up_minor, rate_s, disc_rate, disc_fixed) = r?;
781 let qty = Decimal::from_str(&qty_s).unwrap_or_default();
782 let rate = Decimal::from_str(&rate_s).unwrap_or_default();
783 let dr = disc_rate.and_then(|s| Decimal::from_str(&s).ok());
784 let df = disc_fixed.map(MinorUnits);
785 let line = line_total_discounted(qty, MinorUnits(up_minor), dr, df);
786 let e = acc.entry(iid).or_default();
787 e.subtotal += line.0;
788 *e.by_rate.entry(rate.to_string()).or_insert(0) += line.0;
789 }
790 for inv in rows.iter_mut() {
791 if let Some(a) = acc.get(&inv.id) {
792 let discount =
793 invoice_discount_minor(a.subtotal, inv.discount_rate, inv.discount_fixed);
794 inv.total_minor = Some(total_minor_from_bases(
795 a.subtotal,
796 &a.by_rate,
797 discount,
798 |base, rate| tax_amount(MinorUnits(base), rate).0,
799 ));
800 }
801 }
802 }
803 Ok(rows)
804}
805
806fn invoice_discount_minor(
807 subtotal: i64,
808 discount_rate: Option<Decimal>,
809 discount_fixed: Option<MinorUnits>,
810) -> i64 {
811 match (discount_rate, discount_fixed) {
812 (Some(rate), _) => crate::money::apply_rate(MinorUnits(subtotal), rate)
813 .0
814 .clamp(0, subtotal),
815 (None, Some(fixed)) => fixed.0.clamp(0, subtotal),
816 _ => 0,
817 }
818}
819
820fn total_minor_from_bases<F>(
821 subtotal: i64,
822 by_rate: &std::collections::BTreeMap<String, i64>,
823 discount: i64,
824 tax_fn: F,
825) -> i64
826where
827 F: Fn(i64, Decimal) -> i64,
828{
829 let subtotal_after_discount = subtotal - discount;
830 let mut tax_total = 0;
831 for (rate_str, base) in by_rate {
832 let rate = Decimal::from_str(rate_str).unwrap_or_default();
833 let scaled_base = if subtotal > 0 && discount > 0 {
834 ((*base as i128) * (subtotal_after_discount as i128) / (subtotal as i128)) as i64
835 } else {
836 *base
837 };
838 tax_total += tax_fn(scaled_base, rate);
839 }
840 subtotal_after_discount + tax_total
841}
842
843pub fn invoice_set_status(conn: &Connection, number: &str, status: &str) -> Result<()> {
847 let now = chrono::Utc::now().to_rfc3339();
848 let affected = match status {
849 "issued" => conn.execute(
850 "UPDATE invoices
851 SET status = ?1,
852 issued_at = COALESCE(issued_at, ?2)
853 WHERE number = ?3",
854 params![status, now, number],
855 )?,
856 "paid" => conn.execute(
857 "UPDATE invoices
858 SET status = ?1,
859 paid_at = COALESCE(paid_at, ?2)
860 WHERE number = ?3",
861 params![status, now, number],
862 )?,
863 _ => conn.execute(
864 "UPDATE invoices SET status = ?1 WHERE number = ?2",
865 params![status, number],
866 )?,
867 };
868 if affected == 0 {
869 return Err(AppError::NotFound(format!("invoice '{number}'")));
870 }
871 Ok(())
872}
873
874pub fn invoice_delete(conn: &Connection, number: &str, force: bool) -> Result<()> {
878 let status: Option<String> = conn
879 .query_row(
880 "SELECT status FROM invoices WHERE number = ?1",
881 params![number],
882 |r| r.get(0),
883 )
884 .optional()?;
885 let status = status.ok_or_else(|| AppError::NotFound(format!("invoice '{number}'")))?;
886 if status != "draft" && !force {
887 return Err(AppError::InvalidInput(format!(
888 "refusing to delete non-draft invoice '{number}' (status='{status}') — pass --force to override. Prefer voiding or issuing a credit note."
889 )));
890 }
891 conn.execute("DELETE FROM invoices WHERE number = ?1", params![number])?;
892 Ok(())
893}
894
895pub fn invoice_update_draft(conn: &Connection, inv: &Invoice) -> Result<()> {
898 let status: Option<String> = conn
899 .query_row(
900 "SELECT status FROM invoices WHERE number = ?1",
901 params![inv.number],
902 |r| r.get(0),
903 )
904 .optional()?;
905 let status = status.ok_or_else(|| AppError::NotFound(format!("invoice '{}'", inv.number)))?;
906 if status != "draft" {
907 return Err(AppError::InvalidInput(format!(
908 "invoice '{}' is {status}, not draft — issued invoices are immutable. Use a credit note to correct.",
909 inv.number
910 )));
911 }
912 conn.execute(
913 "UPDATE invoices SET
914 client_id = ?1, issue_date = ?2, due_date = ?3, terms = ?4,
915 currency = ?5, symbol = ?6, tax_label = ?7, notes = ?8,
916 reverse_charge = ?9, pay_link = ?10,
917 discount_rate = ?11, discount_fixed_minor = ?12
918 WHERE number = ?13",
919 params![
920 inv.client_id,
921 inv.issue_date,
922 inv.due_date,
923 inv.terms,
924 inv.currency,
925 inv.symbol,
926 inv.tax_label,
927 inv.notes,
928 inv.reverse_charge as i32,
929 inv.pay_link,
930 inv.discount_rate.as_ref().map(|d| d.to_string()),
931 inv.discount_fixed.as_ref().map(|m| m.0),
932 inv.number,
933 ],
934 )?;
935 Ok(())
936}
937
938fn require_draft(conn: &Connection, invoice_id: i64) -> Result<()> {
939 let status: String = conn.query_row(
940 "SELECT status FROM invoices WHERE id = ?1",
941 params![invoice_id],
942 |r| r.get(0),
943 )?;
944 if status != "draft" {
945 return Err(AppError::InvalidInput(format!(
946 "invoice is {status}, not draft — items cannot be modified. Use a credit note to correct."
947 )));
948 }
949 Ok(())
950}
951
952pub fn invoice_item_add(conn: &Connection, invoice_id: i64, item: &InvoiceItem) -> Result<i64> {
954 require_draft(conn, invoice_id)?;
955 let next_pos: i64 = conn
956 .query_row(
957 "SELECT COALESCE(MAX(position) + 1, 0) FROM invoice_items WHERE invoice_id = ?1",
958 params![invoice_id],
959 |r| r.get(0),
960 )
961 .unwrap_or(0);
962 conn.execute(
963 "INSERT INTO invoice_items (invoice_id, position, description, subtitle,
964 qty, unit, unit_price_minor, tax_rate, product_id,
965 discount_rate, discount_fixed_minor)
966 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
967 params![
968 invoice_id,
969 next_pos,
970 item.description,
971 item.subtitle,
972 item.qty.to_string(),
973 item.unit,
974 item.unit_price.0,
975 item.tax_rate.to_string(),
976 item.product_id,
977 item.discount_rate.as_ref().map(|d| d.to_string()),
978 item.discount_fixed.as_ref().map(|m| m.0),
979 ],
980 )?;
981 Ok(conn.last_insert_rowid())
982}
983
984pub fn invoice_item_remove(conn: &mut Connection, invoice_id: i64, position: i64) -> Result<()> {
987 require_draft(conn, invoice_id)?;
988 let tx = conn.transaction()?;
989 let affected = tx.execute(
990 "DELETE FROM invoice_items WHERE invoice_id = ?1 AND position = ?2",
991 params![invoice_id, position],
992 )?;
993 if affected == 0 {
994 return Err(AppError::NotFound(format!(
995 "item at position {position} of invoice id {invoice_id}"
996 )));
997 }
998 tx.execute(
999 "UPDATE invoice_items SET position = position - 1
1000 WHERE invoice_id = ?1 AND position > ?2",
1001 params![invoice_id, position],
1002 )?;
1003 tx.commit()?;
1004 Ok(())
1005}
1006
1007pub fn invoice_item_edit(
1009 conn: &Connection,
1010 invoice_id: i64,
1011 position: i64,
1012 item: &InvoiceItem,
1013) -> Result<()> {
1014 require_draft(conn, invoice_id)?;
1015 let affected = conn.execute(
1016 "UPDATE invoice_items SET
1017 description = ?1, subtitle = ?2, qty = ?3, unit = ?4,
1018 unit_price_minor = ?5, tax_rate = ?6, product_id = ?7,
1019 discount_rate = ?8, discount_fixed_minor = ?9
1020 WHERE invoice_id = ?10 AND position = ?11",
1021 params![
1022 item.description,
1023 item.subtitle,
1024 item.qty.to_string(),
1025 item.unit,
1026 item.unit_price.0,
1027 item.tax_rate.to_string(),
1028 item.product_id,
1029 item.discount_rate.as_ref().map(|d| d.to_string()),
1030 item.discount_fixed.as_ref().map(|m| m.0),
1031 invoice_id,
1032 position,
1033 ],
1034 )?;
1035 if affected == 0 {
1036 return Err(AppError::NotFound(format!(
1037 "item at position {position} of invoice id {invoice_id}"
1038 )));
1039 }
1040 Ok(())
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046 use crate::money::MinorUnits;
1047 use rust_decimal::Decimal;
1048 use std::collections::BTreeMap;
1049 use std::str::FromStr;
1050
1051 #[test]
1052 fn applies_variable_width_sequence_tokens() {
1053 assert_eq!(
1054 apply_sequence_format("199-AP-{year}-{seq:03}", 2),
1055 "199-AP-{year}-002"
1056 );
1057 assert_eq!(apply_sequence_format("{year}-{seq:04}", 42), "{year}-0042");
1058 assert_eq!(apply_sequence_format("{year}-{seq}", 7), "{year}-7");
1059 }
1060
1061 #[test]
1062 fn supports_issuer_token_in_number_format() {
1063 let issuer = test_issuer("paperfoot", "{issuer}-{year}-{seq:03}");
1064 assert_eq!(
1065 format_document_number(&issuer, 2026, 12, "invoice"),
1066 "paperfoot-2026-012"
1067 );
1068 }
1069
1070 #[test]
1071 fn prefixes_legacy_colliding_number_for_second_issuer() {
1072 let tmp = tempfile::NamedTempFile::new().unwrap();
1073 let mut conn = open_at(tmp.path()).unwrap();
1074
1075 let mut alpha = test_issuer("alpha", "{year}-{seq:04}");
1076 alpha.id = issuer_create(&conn, &alpha).unwrap();
1077 let mut beta = test_issuer("beta", "{year}-{seq:04}");
1078 beta.id = issuer_create(&conn, &beta).unwrap();
1079
1080 let client_id = client_create(&conn, &test_client()).unwrap();
1081 let first = next_invoice_number(&conn, &alpha, 2026, "invoice").unwrap();
1082 assert_eq!(first, "2026-0001");
1083 let inv = test_invoice(first, alpha.id, client_id);
1084 invoice_create(&mut conn, &inv).unwrap();
1085
1086 let second = next_invoice_number(&conn, &beta, 2026, "invoice").unwrap();
1087 assert_eq!(second, "beta-2026-0001");
1088 }
1089
1090 #[test]
1091 fn list_total_scales_tax_base_after_invoice_discount() {
1092 let mut bases = BTreeMap::new();
1093 bases.insert("20".to_string(), 10_000);
1094 let discount = invoice_discount_minor(10_000, Some(Decimal::from_str("50").unwrap()), None);
1095 let total = total_minor_from_bases(10_000, &bases, discount, |base, rate| {
1096 crate::money::tax_amount(MinorUnits(base), rate).0
1097 });
1098 assert_eq!(total, 6_000);
1099 }
1100
1101 fn test_issuer(slug: &str, number_format: &str) -> Issuer {
1102 Issuer {
1103 id: 0,
1104 slug: slug.to_string(),
1105 name: slug.to_string(),
1106 legal_name: None,
1107 jurisdiction: Jurisdiction::Uk,
1108 tax_registered: false,
1109 tax_id: None,
1110 company_no: None,
1111 tagline: None,
1112 address: vec!["1 Test Street".into()],
1113 email: None,
1114 phone: None,
1115 bank_details: None,
1116 default_template: "vienna".into(),
1117 currency: Some("GBP".into()),
1118 symbol: Some("£".into()),
1119 number_format: number_format.into(),
1120 logo_path: None,
1121 default_output_dir: None,
1122 default_notes: None,
1123 }
1124 }
1125
1126 fn test_client() -> Client {
1127 Client {
1128 id: 0,
1129 slug: "client".into(),
1130 name: "Client".into(),
1131 attn: None,
1132 country: None,
1133 tax_id: None,
1134 address: vec!["1 Client Street".into()],
1135 email: None,
1136 notes: None,
1137 default_issuer_slug: None,
1138 default_template: None,
1139 }
1140 }
1141
1142 fn test_invoice(number: String, issuer_id: i64, client_id: i64) -> Invoice {
1143 Invoice {
1144 id: 0,
1145 number,
1146 issuer_id,
1147 client_id,
1148 issue_date: "2026-01-01".into(),
1149 due_date: "2026-01-08".into(),
1150 terms: "Pay in full".into(),
1151 currency: "GBP".into(),
1152 symbol: "£".into(),
1153 tax_label: "VAT".into(),
1154 status: "draft".into(),
1155 notes: None,
1156 reverse_charge: false,
1157 pay_link: None,
1158 issued_at: None,
1159 paid_at: None,
1160 total_minor: None,
1161 kind: "invoice".into(),
1162 credits_invoice_id: None,
1163 discount_rate: None,
1164 discount_fixed: None,
1165 items: vec![],
1166 }
1167 }
1168}