Skip to main content

stateset_db/sqlite/
invoices.rs

1//! SQLite implementation of invoice repository
2
3use super::parse_helpers::parse_decimal as parse_decimal_with_context;
4use super::{
5    build_in_clause, map_db_error, params_refs, parse_datetime_opt_row, parse_datetime_row,
6    parse_decimal_opt_row, parse_decimal_row, parse_enum, parse_enum_row, parse_uuid_opt_row,
7    parse_uuid_row, sum_decimal_query, uuid_params, with_immediate_transaction,
8};
9use r2d2::Pool;
10use r2d2_sqlite::SqliteConnectionManager;
11use rusqlite::{Row, params};
12use rust_decimal::{Decimal, RoundingStrategy};
13use stateset_core::{
14    BatchResult, CommerceError, CreateInvoice, CreateInvoiceItem, CustomerId, Invoice,
15    InvoiceFilter, InvoiceId, InvoiceItem, InvoiceRepository, InvoiceStatus, OrderId, OrderItemId,
16    ProductId, RecordInvoicePayment, Result, UpdateInvoice, generate_invoice_number,
17    validate_batch_size,
18};
19use uuid::Uuid;
20
21/// Round a money value to cents (2 dp, half away from zero).
22///
23/// The Postgres backend stores invoice money in `DECIMAL(12, 2)` columns, which
24/// round on write; SQLite stores money as TEXT and would otherwise keep
25/// sub-cent precision, so line totals, invoice totals, and balances could differ
26/// between backends (and flip an invoice between `Paid` and `PartiallyPaid`).
27/// Rounding here with the same half-away-from-zero strategy Postgres `NUMERIC`
28/// uses keeps the two backends penny-identical.
29fn money(amount: Decimal) -> Decimal {
30    amount.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
31}
32
33#[derive(Debug)]
34pub struct SqliteInvoiceRepository {
35    pool: Pool<SqliteConnectionManager>,
36}
37
38impl SqliteInvoiceRepository {
39    #[must_use]
40    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
41        Self { pool }
42    }
43
44    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
45        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
46    }
47
48    fn row_to_invoice(row: &Row<'_>) -> rusqlite::Result<Invoice> {
49        Ok(Invoice {
50            id: InvoiceId::from(parse_uuid_row(&row.get::<_, String>("id")?, "invoice", "id")?),
51            invoice_number: row.get("invoice_number")?,
52            customer_id: CustomerId::from(parse_uuid_row(
53                &row.get::<_, String>("customer_id")?,
54                "invoice",
55                "customer_id",
56            )?),
57            order_id: parse_uuid_opt_row(
58                row.get::<_, Option<String>>("order_id")?,
59                "invoice",
60                "order_id",
61            )?
62            .map(OrderId::from),
63            status: parse_enum_row(&row.get::<_, String>("status")?, "invoice", "status")?,
64            invoice_type: parse_enum_row(
65                &row.get::<_, String>("invoice_type")?,
66                "invoice",
67                "invoice_type",
68            )?,
69            invoice_date: parse_datetime_row(
70                &row.get::<_, String>("invoice_date")?,
71                "invoice",
72                "invoice_date",
73            )?,
74            due_date: parse_datetime_row(
75                &row.get::<_, String>("due_date")?,
76                "invoice",
77                "due_date",
78            )?,
79            payment_terms: row.get("payment_terms")?,
80            currency: row.get("currency")?,
81            billing_name: row.get("billing_name")?,
82            billing_email: row.get("billing_email")?,
83            billing_address: row.get("billing_address")?,
84            billing_city: row.get("billing_city")?,
85            billing_state: row.get("billing_state")?,
86            billing_postal_code: row.get("billing_postal_code")?,
87            billing_country: row.get("billing_country")?,
88            subtotal: parse_decimal_row(&row.get::<_, String>("subtotal")?, "invoice", "subtotal")?,
89            discount_amount: parse_decimal_row(
90                &row.get::<_, String>("discount_amount")?,
91                "invoice",
92                "discount_amount",
93            )?,
94            discount_percent: parse_decimal_opt_row(
95                row.get::<_, Option<String>>("discount_percent")?,
96                "invoice",
97                "discount_percent",
98            )?,
99            tax_amount: parse_decimal_row(
100                &row.get::<_, String>("tax_amount")?,
101                "invoice",
102                "tax_amount",
103            )?,
104            tax_rate: parse_decimal_opt_row(
105                row.get::<_, Option<String>>("tax_rate")?,
106                "invoice",
107                "tax_rate",
108            )?,
109            shipping_amount: parse_decimal_row(
110                &row.get::<_, String>("shipping_amount")?,
111                "invoice",
112                "shipping_amount",
113            )?,
114            total: parse_decimal_row(&row.get::<_, String>("total")?, "invoice", "total")?,
115            amount_paid: parse_decimal_row(
116                &row.get::<_, String>("amount_paid")?,
117                "invoice",
118                "amount_paid",
119            )?,
120            balance_due: parse_decimal_row(
121                &row.get::<_, String>("balance_due")?,
122                "invoice",
123                "balance_due",
124            )?,
125            po_number: row.get("po_number")?,
126            notes: row.get("notes")?,
127            terms: row.get("terms")?,
128            footer: row.get("footer")?,
129            sent_at: parse_datetime_opt_row(
130                row.get::<_, Option<String>>("sent_at")?,
131                "invoice",
132                "sent_at",
133            )?,
134            viewed_at: parse_datetime_opt_row(
135                row.get::<_, Option<String>>("viewed_at")?,
136                "invoice",
137                "viewed_at",
138            )?,
139            paid_at: parse_datetime_opt_row(
140                row.get::<_, Option<String>>("paid_at")?,
141                "invoice",
142                "paid_at",
143            )?,
144            voided_at: parse_datetime_opt_row(
145                row.get::<_, Option<String>>("voided_at")?,
146                "invoice",
147                "voided_at",
148            )?,
149            items: Vec::new(),
150            created_at: parse_datetime_row(
151                &row.get::<_, String>("created_at")?,
152                "invoice",
153                "created_at",
154            )?,
155            updated_at: parse_datetime_row(
156                &row.get::<_, String>("updated_at")?,
157                "invoice",
158                "updated_at",
159            )?,
160        })
161    }
162
163    fn row_to_invoice_item(row: &Row<'_>) -> rusqlite::Result<InvoiceItem> {
164        Ok(InvoiceItem {
165            id: parse_uuid_row(&row.get::<_, String>("id")?, "invoice_item", "id")?,
166            invoice_id: InvoiceId::from(parse_uuid_row(
167                &row.get::<_, String>("invoice_id")?,
168                "invoice_item",
169                "invoice_id",
170            )?),
171            order_item_id: parse_uuid_opt_row(
172                row.get::<_, Option<String>>("order_item_id")?,
173                "invoice_item",
174                "order_item_id",
175            )?
176            .map(OrderItemId::from),
177            product_id: parse_uuid_opt_row(
178                row.get::<_, Option<String>>("product_id")?,
179                "invoice_item",
180                "product_id",
181            )?
182            .map(ProductId::from),
183            sku: row.get("sku")?,
184            description: row.get("description")?,
185            quantity: parse_decimal_row(
186                &row.get::<_, String>("quantity")?,
187                "invoice_item",
188                "quantity",
189            )?,
190            unit_of_measure: row.get("unit_of_measure")?,
191            unit_price: parse_decimal_row(
192                &row.get::<_, String>("unit_price")?,
193                "invoice_item",
194                "unit_price",
195            )?,
196            discount_amount: parse_decimal_row(
197                &row.get::<_, String>("discount_amount")?,
198                "invoice_item",
199                "discount_amount",
200            )?,
201            tax_amount: parse_decimal_row(
202                &row.get::<_, String>("tax_amount")?,
203                "invoice_item",
204                "tax_amount",
205            )?,
206            line_total: parse_decimal_row(
207                &row.get::<_, String>("line_total")?,
208                "invoice_item",
209                "line_total",
210            )?,
211            sort_order: row.get("sort_order")?,
212            created_at: parse_datetime_row(
213                &row.get::<_, String>("created_at")?,
214                "invoice_item",
215                "created_at",
216            )?,
217            updated_at: parse_datetime_row(
218                &row.get::<_, String>("updated_at")?,
219                "invoice_item",
220                "updated_at",
221            )?,
222        })
223    }
224
225    fn get_invoice_items_with_conn(
226        conn: &rusqlite::Connection,
227        invoice_id: InvoiceId,
228    ) -> Result<Vec<InvoiceItem>> {
229        let mut stmt = conn
230            .prepare("SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order")
231            .map_err(map_db_error)?;
232        let rows = stmt
233            .query_map([invoice_id.to_string()], Self::row_to_invoice_item)
234            .map_err(map_db_error)?;
235
236        let mut items = Vec::new();
237        for row in rows {
238            items.push(row.map_err(map_db_error)?);
239        }
240        Ok(items)
241    }
242
243    fn get_invoice_items_batch(
244        conn: &rusqlite::Connection,
245        ids: &[InvoiceId],
246    ) -> Result<std::collections::HashMap<String, Vec<InvoiceItem>>> {
247        let mut map: std::collections::HashMap<String, Vec<InvoiceItem>> =
248            std::collections::HashMap::with_capacity(ids.len());
249        let id_strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
250        for chunk in id_strs.chunks(500) {
251            let placeholders = build_in_clause(chunk.len());
252            let sql = format!(
253                "SELECT * FROM invoice_items WHERE invoice_id IN ({placeholders}) ORDER BY sort_order"
254            );
255            let param_refs: Vec<&dyn rusqlite::ToSql> =
256                chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
257            let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
258            let rows = stmt
259                .query_map(param_refs.as_slice(), |row| {
260                    let parent: String = row.get("invoice_id")?;
261                    Ok((parent, Self::row_to_invoice_item(row)?))
262                })
263                .map_err(map_db_error)?;
264            for row in rows {
265                let (parent, item) = row.map_err(map_db_error)?;
266                map.entry(parent).or_default().push(item);
267            }
268        }
269        Ok(map)
270    }
271
272    fn get_invoice_with_conn(
273        conn: &rusqlite::Connection,
274        id: InvoiceId,
275    ) -> Result<Option<Invoice>> {
276        let result = conn.query_row(
277            "SELECT * FROM invoices WHERE id = ?",
278            [id.to_string()],
279            Self::row_to_invoice,
280        );
281
282        match result {
283            Ok(mut invoice) => {
284                invoice.items = Self::get_invoice_items_with_conn(conn, id)?;
285                Ok(Some(invoice))
286            }
287            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
288            Err(e) => Err(map_db_error(e)),
289        }
290    }
291
292    fn recalculate_with_conn(conn: &rusqlite::Connection, id: InvoiceId) -> Result<()> {
293        // Calculate subtotal from items
294        let invoice_id_param = id.to_string();
295        let invoice_params: [&dyn rusqlite::ToSql; 1] = [&invoice_id_param];
296        let subtotal = sum_decimal_query(
297            conn,
298            "SELECT line_total FROM invoice_items WHERE invoice_id = ?",
299            &invoice_params,
300            "invoice_item",
301            "line_total",
302        )?;
303
304        let (discount_amount, tax_amount, shipping_amount, amount_paid): (String, String, String, String) = conn
305            .query_row(
306                "SELECT discount_amount, tax_amount, shipping_amount, amount_paid FROM invoices WHERE id = ?",
307                [id.to_string()],
308                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
309            )
310            .map_err(map_db_error)?;
311
312        let subtotal = money(subtotal);
313        let total = money(
314            subtotal - parse_decimal_with_context(&discount_amount, "invoice", "discount_amount")?
315                + parse_decimal_with_context(&tax_amount, "invoice", "tax_amount")?
316                + parse_decimal_with_context(&shipping_amount, "invoice", "shipping_amount")?,
317        );
318        let balance_due =
319            money(total - parse_decimal_with_context(&amount_paid, "invoice", "amount_paid")?);
320
321        conn.execute(
322            "UPDATE invoices SET subtotal = ?, total = ?, balance_due = ?, updated_at = ? WHERE id = ?",
323            params![
324                subtotal.to_string(),
325                total.to_string(),
326                balance_due.to_string(),
327                chrono::Utc::now().to_rfc3339(),
328                id.to_string()
329            ],
330        )
331        .map_err(map_db_error)?;
332
333        Ok(())
334    }
335
336    fn get_invoice_items(&self, invoice_id: InvoiceId) -> Result<Vec<InvoiceItem>> {
337        let conn = self.conn()?;
338        Self::get_invoice_items_with_conn(&conn, invoice_id)
339    }
340}
341
342impl InvoiceRepository for SqliteInvoiceRepository {
343    fn create(&self, input: CreateInvoice) -> Result<Invoice> {
344        let mut conn = self.conn()?;
345        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
346        let id = InvoiceId::new();
347        let now = chrono::Utc::now();
348        let invoice_number = generate_invoice_number();
349        let invoice_date = input.invoice_date.unwrap_or(now);
350        let due_date = input.due_date.unwrap_or_else(|| {
351            invoice_date + chrono::Duration::days(i64::from(input.days_until_due.unwrap_or(30)))
352        });
353
354        tx.execute(
355            "INSERT INTO invoices (id, invoice_number, customer_id, order_id, status, invoice_type,
356             invoice_date, due_date, payment_terms, currency, billing_name, billing_email,
357             billing_address, billing_city, billing_state, billing_postal_code, billing_country,
358             subtotal, discount_amount, discount_percent, tax_amount, tax_rate, shipping_amount,
359             total, amount_paid, balance_due, po_number, notes, terms, footer, created_at, updated_at)
360             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
361            params![
362                id.to_string(),
363                invoice_number,
364                input.customer_id.to_string(),
365                input.order_id.map(|id| id.to_string()),
366                InvoiceStatus::Draft.to_string(),
367                input.invoice_type.unwrap_or_default().to_string(),
368                invoice_date.to_rfc3339(),
369                due_date.to_rfc3339(),
370                input.payment_terms,
371                input.currency.unwrap_or_default(),
372                input.billing_name,
373                input.billing_email,
374                input.billing_address,
375                input.billing_city,
376                input.billing_state,
377                input.billing_postal_code,
378                input.billing_country,
379                "0",
380                money(input.discount_amount.unwrap_or_default()).to_string(),
381                input.discount_percent.map(|d| d.to_string()),
382                money(input.tax_amount.unwrap_or_default()).to_string(),
383                input.tax_rate.map(|d| d.to_string()),
384                money(input.shipping_amount.unwrap_or_default()).to_string(),
385                "0",
386                "0",
387                "0",
388                input.po_number,
389                input.notes,
390                input.terms,
391                input.footer,
392                now.to_rfc3339(),
393                now.to_rfc3339(),
394            ],
395        ).map_err(map_db_error)?;
396
397        // Add items
398        for (i, item) in input.items.into_iter().enumerate() {
399            let item_id = Uuid::new_v4();
400            let mut item_with_order = item;
401            if item_with_order.sort_order.is_none() {
402                item_with_order.sort_order = Some(i as i32);
403            }
404
405            let line_total = money(
406                item_with_order.quantity * item_with_order.unit_price
407                    - item_with_order.discount_amount.unwrap_or_default()
408                    + item_with_order.tax_amount.unwrap_or_default(),
409            );
410
411            tx.execute(
412                "INSERT INTO invoice_items (id, invoice_id, order_item_id, product_id, sku, description,
413                 quantity, unit_of_measure, unit_price, discount_amount, tax_amount, line_total,
414                 sort_order, created_at, updated_at)
415                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
416                params![
417                    item_id.to_string(),
418                    id.to_string(),
419                    item_with_order.order_item_id.map(|id| id.to_string()),
420                    item_with_order.product_id.map(|id| id.to_string()),
421                    item_with_order.sku,
422                    item_with_order.description,
423                    item_with_order.quantity.to_string(),
424                    item_with_order.unit_of_measure,
425                    item_with_order.unit_price.to_string(),
426                    money(item_with_order.discount_amount.unwrap_or_default()).to_string(),
427                    money(item_with_order.tax_amount.unwrap_or_default()).to_string(),
428                    line_total.to_string(),
429                    item_with_order.sort_order.unwrap_or(0),
430                    now.to_rfc3339(),
431                    now.to_rfc3339(),
432                ],
433            )
434            .map_err(map_db_error)?;
435        }
436
437        // Recalculate totals
438        Self::recalculate_with_conn(&tx, id)?;
439
440        tx.commit().map_err(map_db_error)?;
441
442        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
443    }
444
445    fn get(&self, id: InvoiceId) -> Result<Option<Invoice>> {
446        let conn = self.conn()?;
447        Self::get_invoice_with_conn(&conn, id)
448    }
449
450    fn get_by_number(&self, invoice_number: &str) -> Result<Option<Invoice>> {
451        let conn = self.conn()?;
452        let result = conn.query_row(
453            "SELECT * FROM invoices WHERE invoice_number = ?",
454            [invoice_number],
455            Self::row_to_invoice,
456        );
457
458        match result {
459            Ok(mut invoice) => {
460                invoice.items = Self::get_invoice_items_with_conn(&conn, invoice.id)?;
461                Ok(Some(invoice))
462            }
463            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
464            Err(e) => Err(map_db_error(e)),
465        }
466    }
467
468    fn update(&self, id: InvoiceId, input: UpdateInvoice) -> Result<Invoice> {
469        let mut conn = self.conn()?;
470        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
471        let now = chrono::Utc::now();
472        let invoice = tx
473            .query_row(
474                "SELECT * FROM invoices WHERE id = ?",
475                [id.to_string()],
476                Self::row_to_invoice,
477            )
478            .map_err(map_db_error)?;
479
480        tx.execute(
481            "UPDATE invoices SET due_date = ?, payment_terms = ?, billing_name = ?, billing_email = ?,
482             billing_address = ?, billing_city = ?, billing_state = ?, billing_postal_code = ?,
483             billing_country = ?, discount_amount = ?, discount_percent = ?, tax_amount = ?,
484             tax_rate = ?, shipping_amount = ?, po_number = ?, notes = ?, terms = ?, footer = ?,
485             updated_at = ? WHERE id = ?",
486            params![
487                input.due_date.map_or_else(|| invoice.due_date.to_rfc3339(), |d| d.to_rfc3339()),
488                input.payment_terms.or(invoice.payment_terms),
489                input.billing_name.or(invoice.billing_name),
490                input.billing_email.or(invoice.billing_email),
491                input.billing_address.or(invoice.billing_address),
492                input.billing_city.or(invoice.billing_city),
493                input.billing_state.or(invoice.billing_state),
494                input.billing_postal_code.or(invoice.billing_postal_code),
495                input.billing_country.or(invoice.billing_country),
496                input.discount_amount.unwrap_or(invoice.discount_amount).to_string(),
497                input.discount_percent.map(|d| d.to_string()).or(invoice.discount_percent.map(|d| d.to_string())),
498                input.tax_amount.unwrap_or(invoice.tax_amount).to_string(),
499                input.tax_rate.map(|d| d.to_string()).or(invoice.tax_rate.map(|d| d.to_string())),
500                input.shipping_amount.unwrap_or(invoice.shipping_amount).to_string(),
501                input.po_number.or(invoice.po_number),
502                input.notes.or(invoice.notes),
503                input.terms.or(invoice.terms),
504                input.footer.or(invoice.footer),
505                now.to_rfc3339(),
506                id.to_string(),
507            ],
508        ).map_err(map_db_error)?;
509
510        Self::recalculate_with_conn(&tx, id)?;
511        tx.commit().map_err(map_db_error)?;
512
513        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
514    }
515
516    fn list(&self, filter: InvoiceFilter) -> Result<Vec<Invoice>> {
517        let conn = self.conn()?;
518
519        let mut sql = "SELECT * FROM invoices WHERE 1=1".to_string();
520        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
521
522        if let Some(customer_id) = &filter.customer_id {
523            sql.push_str(" AND customer_id = ?");
524            params_vec.push(Box::new(customer_id.to_string()));
525        }
526        if let Some(order_id) = &filter.order_id {
527            sql.push_str(" AND order_id = ?");
528            params_vec.push(Box::new(order_id.to_string()));
529        }
530        if let Some(status) = &filter.status {
531            sql.push_str(" AND status = ?");
532            params_vec.push(Box::new(status.to_string()));
533        }
534        if let Some(invoice_type) = &filter.invoice_type {
535            sql.push_str(" AND invoice_type = ?");
536            params_vec.push(Box::new(invoice_type.to_string()));
537        }
538        if filter.overdue_only.unwrap_or(false) {
539            sql.push_str(" AND due_date < datetime('now') AND status NOT IN ('paid', 'voided')");
540        }
541        if let Some(from_date) = &filter.from_date {
542            sql.push_str(" AND invoice_date >= ?");
543            params_vec.push(Box::new(from_date.to_rfc3339()));
544        }
545        if let Some(to_date) = &filter.to_date {
546            sql.push_str(" AND invoice_date <= ?");
547            params_vec.push(Box::new(to_date.to_rfc3339()));
548        }
549        if let Some(due_from) = &filter.due_from {
550            sql.push_str(" AND due_date >= ?");
551            params_vec.push(Box::new(due_from.to_rfc3339()));
552        }
553        if let Some(due_to) = &filter.due_to {
554            sql.push_str(" AND due_date <= ?");
555            params_vec.push(Box::new(due_to.to_rfc3339()));
556        }
557        if let Some(invoice_number) = &filter.invoice_number {
558            // Case-insensitive substring match, mirroring Postgres `ILIKE '%..%'`
559            // (SQLite `LIKE` is case-insensitive for ASCII by default).
560            sql.push_str(" AND invoice_number LIKE ?");
561            params_vec.push(Box::new(format!("%{invoice_number}%")));
562        }
563
564        sql.push_str(" ORDER BY invoice_date DESC");
565
566        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
567        let params_refs: Vec<&dyn rusqlite::ToSql> =
568            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
569        let rows =
570            stmt.query_map(params_refs.as_slice(), Self::row_to_invoice).map_err(map_db_error)?;
571
572        // The money thresholds compare TEXT money columns, which SQLite cannot
573        // compare numerically without a lossy `CAST(... AS REAL)`. Filter them
574        // exactly in Rust (the fields are already parsed to `Decimal`), before
575        // pagination — matching the Postgres order (WHERE then LIMIT/OFFSET).
576        let mut matched: Vec<Invoice> = Vec::new();
577        for row in rows {
578            let invoice = row.map_err(map_db_error)?;
579            if let Some(min_total) = filter.min_total {
580                if invoice.total < min_total {
581                    continue;
582                }
583            }
584            if let Some(max_total) = filter.max_total {
585                if invoice.total > max_total {
586                    continue;
587                }
588            }
589            if let Some(min_balance) = filter.min_balance {
590                if invoice.balance_due < min_balance {
591                    continue;
592                }
593            }
594            matched.push(invoice);
595        }
596
597        let offset = filter.offset.unwrap_or(0) as usize;
598        let mut page = if offset >= matched.len() { Vec::new() } else { matched.split_off(offset) };
599        if let Some(limit) = filter.limit {
600            page.truncate(limit as usize);
601        }
602
603        // Load items only for the rows actually returned, in one batched query.
604        let ids: Vec<InvoiceId> = page.iter().map(|i| i.id).collect();
605        let mut items_by_id = Self::get_invoice_items_batch(&conn, &ids)?;
606        for invoice in &mut page {
607            invoice.items = items_by_id.remove(&invoice.id.to_string()).unwrap_or_default();
608        }
609        Ok(page)
610    }
611
612    fn for_customer(&self, customer_id: CustomerId) -> Result<Vec<Invoice>> {
613        self.list(InvoiceFilter { customer_id: Some(customer_id), ..Default::default() })
614    }
615
616    fn for_order(&self, order_id: OrderId) -> Result<Vec<Invoice>> {
617        self.list(InvoiceFilter { order_id: Some(order_id), ..Default::default() })
618    }
619
620    fn delete(&self, id: InvoiceId) -> Result<()> {
621        let mut conn = self.conn()?;
622        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
623
624        let status: String = tx
625            .query_row("SELECT status FROM invoices WHERE id = ?", [id.to_string()], |row| {
626                row.get(0)
627            })
628            .map_err(map_db_error)?;
629
630        if parse_enum::<InvoiceStatus>(&status, "invoice", "status")? != InvoiceStatus::Draft {
631            return Err(CommerceError::ValidationError(
632                "Can only delete draft invoices".to_string(),
633            ));
634        }
635
636        tx.execute("DELETE FROM invoice_items WHERE invoice_id = ?", [id.to_string()])
637            .map_err(map_db_error)?;
638        tx.execute("DELETE FROM invoices WHERE id = ?", [id.to_string()]).map_err(map_db_error)?;
639        tx.commit().map_err(map_db_error)?;
640        Ok(())
641    }
642
643    fn send(&self, id: InvoiceId) -> Result<Invoice> {
644        let conn = self.conn()?;
645        let now = chrono::Utc::now();
646
647        conn.execute(
648            "UPDATE invoices SET status = ?, sent_at = ?, updated_at = ? WHERE id = ?",
649            params![
650                InvoiceStatus::Sent.to_string(),
651                now.to_rfc3339(),
652                now.to_rfc3339(),
653                id.to_string()
654            ],
655        )
656        .map_err(map_db_error)?;
657
658        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
659    }
660
661    fn mark_viewed(&self, id: InvoiceId) -> Result<Invoice> {
662        let conn = self.conn()?;
663        let now = chrono::Utc::now();
664
665        conn.execute(
666            "UPDATE invoices SET status = CASE WHEN status = 'sent' THEN 'viewed' ELSE status END,
667             viewed_at = COALESCE(viewed_at, ?), updated_at = ? WHERE id = ?",
668            params![now.to_rfc3339(), now.to_rfc3339(), id.to_string()],
669        )
670        .map_err(map_db_error)?;
671
672        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
673    }
674
675    fn record_payment(&self, id: InvoiceId, payment: RecordInvoicePayment) -> Result<Invoice> {
676        if payment.amount <= Decimal::ZERO {
677            return Err(CommerceError::ValidationError(
678                "Invoice payment amount must be positive".to_string(),
679            ));
680        }
681
682        let now = chrono::Utc::now();
683        let id_str = id.to_string();
684
685        // Recording a payment is a read-modify-write of `amount_paid`. Run it in
686        // an IMMEDIATE transaction so concurrent payments on the same invoice are
687        // serialized (each sees the prior payment's committed total) rather than
688        // racing on a stale read and either losing a payment or failing with a
689        // "table is locked" error — the same hardening the gift-card, store-credit
690        // and refund paths use.
691        //
692        // The updated invoice is read back INSIDE the transaction and returned,
693        // so there is no separate post-commit read that could fail with "table
694        // is locked" *after* the payment already committed — which would surface
695        // an error for a payment that actually landed and tempt the caller to
696        // retry and double-pay. (`tx` deref-coerces to `&Connection`.)
697        with_immediate_transaction(&self.pool, |tx| {
698            let (total, amount_paid): (String, String) = tx.query_row(
699                "SELECT total, amount_paid FROM invoices WHERE id = ?",
700                [&id_str],
701                |row| Ok((row.get(0)?, row.get(1)?)),
702            )?;
703
704            let total_dec = parse_decimal_row(&total, "invoice", "total")?;
705            let amount_paid_dec = parse_decimal_row(&amount_paid, "invoice", "amount_paid")?;
706
707            let new_amount_paid = money(amount_paid_dec + payment.amount);
708            let new_balance = money(total_dec - new_amount_paid);
709
710            let new_status = if new_balance <= Decimal::ZERO {
711                InvoiceStatus::Paid
712            } else {
713                InvoiceStatus::PartiallyPaid
714            };
715
716            let paid_at = if new_status == InvoiceStatus::Paid { Some(now) } else { None };
717
718            tx.execute(
719                "UPDATE invoices SET amount_paid = ?, balance_due = ?, status = ?,
720                 paid_at = COALESCE(?, paid_at), updated_at = ? WHERE id = ?",
721                params![
722                    new_amount_paid.to_string(),
723                    new_balance.to_string(),
724                    new_status.to_string(),
725                    paid_at.map(|d| d.to_rfc3339()),
726                    now.to_rfc3339(),
727                    &id_str,
728                ],
729            )?;
730
731            Self::get_invoice_with_conn(tx, id)
732                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
733                .ok_or_else(|| {
734                    rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::NotFound))
735                })
736        })
737    }
738
739    fn void(&self, id: InvoiceId) -> Result<Invoice> {
740        let conn = self.conn()?;
741        let now = chrono::Utc::now();
742
743        conn.execute(
744            "UPDATE invoices SET status = ?, voided_at = ?, updated_at = ? WHERE id = ?",
745            params![
746                InvoiceStatus::Voided.to_string(),
747                now.to_rfc3339(),
748                now.to_rfc3339(),
749                id.to_string()
750            ],
751        )
752        .map_err(map_db_error)?;
753
754        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
755    }
756
757    fn write_off(&self, id: InvoiceId) -> Result<Invoice> {
758        let conn = self.conn()?;
759        let now = chrono::Utc::now();
760
761        conn.execute(
762            "UPDATE invoices SET status = ?, updated_at = ? WHERE id = ?",
763            params![InvoiceStatus::WrittenOff.to_string(), now.to_rfc3339(), id.to_string()],
764        )
765        .map_err(map_db_error)?;
766
767        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
768    }
769
770    fn dispute(&self, id: InvoiceId) -> Result<Invoice> {
771        let conn = self.conn()?;
772        let now = chrono::Utc::now();
773
774        conn.execute(
775            "UPDATE invoices SET status = ?, updated_at = ? WHERE id = ?",
776            params![InvoiceStatus::Disputed.to_string(), now.to_rfc3339(), id.to_string()],
777        )
778        .map_err(map_db_error)?;
779
780        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
781    }
782
783    fn add_item(&self, invoice_id: InvoiceId, item: CreateInvoiceItem) -> Result<InvoiceItem> {
784        let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
785        let id = Uuid::new_v4();
786        let now = chrono::Utc::now();
787        let line_total = item.quantity * item.unit_price - item.discount_amount.unwrap_or_default()
788            + item.tax_amount.unwrap_or_default();
789
790        conn.execute(
791            "INSERT INTO invoice_items (id, invoice_id, order_item_id, product_id, sku, description,
792             quantity, unit_of_measure, unit_price, discount_amount, tax_amount, line_total,
793             sort_order, created_at, updated_at)
794             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
795            params![
796                id.to_string(),
797                invoice_id.to_string(),
798                item.order_item_id.map(|id| id.to_string()),
799                item.product_id.map(|id| id.to_string()),
800                item.sku,
801                item.description,
802                item.quantity.to_string(),
803                item.unit_of_measure,
804                item.unit_price.to_string(),
805                item.discount_amount.unwrap_or_default().to_string(),
806                item.tax_amount.unwrap_or_default().to_string(),
807                line_total.to_string(),
808                item.sort_order.unwrap_or(0),
809                now.to_rfc3339(),
810                now.to_rfc3339(),
811            ],
812        ).map_err(map_db_error)?;
813
814        let mut stmt =
815            conn.prepare("SELECT * FROM invoice_items WHERE id = ?").map_err(map_db_error)?;
816        stmt.query_row([id.to_string()], Self::row_to_invoice_item).map_err(map_db_error)
817    }
818
819    fn update_item(&self, item_id: Uuid, item: CreateInvoiceItem) -> Result<InvoiceItem> {
820        let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
821        let now = chrono::Utc::now();
822        let line_total = item.quantity * item.unit_price - item.discount_amount.unwrap_or_default()
823            + item.tax_amount.unwrap_or_default();
824
825        conn.execute(
826            "UPDATE invoice_items SET sku = ?, description = ?, quantity = ?, unit_of_measure = ?,
827             unit_price = ?, discount_amount = ?, tax_amount = ?, line_total = ?, sort_order = ?,
828             updated_at = ? WHERE id = ?",
829            params![
830                item.sku,
831                item.description,
832                item.quantity.to_string(),
833                item.unit_of_measure,
834                item.unit_price.to_string(),
835                item.discount_amount.unwrap_or_default().to_string(),
836                item.tax_amount.unwrap_or_default().to_string(),
837                line_total.to_string(),
838                item.sort_order.unwrap_or(0),
839                now.to_rfc3339(),
840                item_id.to_string(),
841            ],
842        )
843        .map_err(map_db_error)?;
844
845        let mut stmt =
846            conn.prepare("SELECT * FROM invoice_items WHERE id = ?").map_err(map_db_error)?;
847        stmt.query_row([item_id.to_string()], Self::row_to_invoice_item).map_err(map_db_error)
848    }
849
850    fn remove_item(&self, item_id: Uuid) -> Result<()> {
851        let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
852        conn.execute("DELETE FROM invoice_items WHERE id = ?", [item_id.to_string()])
853            .map_err(map_db_error)?;
854        Ok(())
855    }
856
857    fn get_items(&self, invoice_id: InvoiceId) -> Result<Vec<InvoiceItem>> {
858        self.get_invoice_items(invoice_id)
859    }
860
861    fn recalculate(&self, id: InvoiceId) -> Result<Invoice> {
862        let mut conn = self.conn()?;
863        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
864
865        Self::recalculate_with_conn(&tx, id)?;
866        tx.commit().map_err(map_db_error)?;
867
868        Self::get_invoice_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
869    }
870
871    fn get_overdue(&self) -> Result<Vec<Invoice>> {
872        self.list(InvoiceFilter { overdue_only: Some(true), ..Default::default() })
873    }
874
875    fn count(&self, filter: InvoiceFilter) -> Result<u64> {
876        let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
877
878        let mut sql = "SELECT COUNT(*) FROM invoices WHERE 1=1".to_string();
879        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
880
881        if let Some(customer_id) = &filter.customer_id {
882            sql.push_str(" AND customer_id = ?");
883            params_vec.push(Box::new(customer_id.to_string()));
884        }
885        if let Some(status) = &filter.status {
886            sql.push_str(" AND status = ?");
887            params_vec.push(Box::new(status.to_string()));
888        }
889        if filter.overdue_only.unwrap_or(false) {
890            sql.push_str(" AND due_date < datetime('now') AND status NOT IN ('paid', 'voided')");
891        }
892
893        let params_refs: Vec<&dyn rusqlite::ToSql> =
894            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
895        let count: i64 =
896            conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
897        Ok(count as u64)
898    }
899
900    // === Batch Operations ===
901
902    fn create_batch(&self, inputs: Vec<CreateInvoice>) -> Result<BatchResult<Invoice>> {
903        validate_batch_size(&inputs)?;
904        let mut result = BatchResult::with_capacity(inputs.len());
905
906        for (index, input) in inputs.into_iter().enumerate() {
907            match self.create(input) {
908                Ok(invoice) => result.record_success(invoice),
909                Err(e) => result.record_failure(index, None, &e),
910            }
911        }
912
913        Ok(result)
914    }
915
916    fn create_batch_atomic(&self, inputs: Vec<CreateInvoice>) -> Result<Vec<Invoice>> {
917        validate_batch_size(&inputs)?;
918        if inputs.is_empty() {
919            return Ok(vec![]);
920        }
921
922        let mut conn = self.conn()?;
923        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
924        let mut results = Vec::with_capacity(inputs.len());
925
926        for input in inputs {
927            let id = InvoiceId::new();
928            let now = chrono::Utc::now();
929            let invoice_number = generate_invoice_number();
930            let invoice_date = input.invoice_date.unwrap_or(now);
931            let due_date = input.due_date.unwrap_or_else(|| {
932                invoice_date + chrono::Duration::days(i64::from(input.days_until_due.unwrap_or(30)))
933            });
934
935            tx.execute(
936                "INSERT INTO invoices (id, invoice_number, customer_id, order_id, status, invoice_type,
937                 invoice_date, due_date, payment_terms, currency, billing_name, billing_email,
938                 billing_address, billing_city, billing_state, billing_postal_code, billing_country,
939                 subtotal, discount_amount, discount_percent, tax_amount, tax_rate, shipping_amount,
940                 total, amount_paid, balance_due, po_number, notes, terms, footer, created_at, updated_at)
941                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
942                params![
943                    id.to_string(),
944                    invoice_number.clone(),
945                    input.customer_id.to_string(),
946                    input.order_id.map(|id| id.to_string()),
947                    InvoiceStatus::Draft.to_string(),
948                    input.invoice_type.unwrap_or_default().to_string(),
949                    invoice_date.to_rfc3339(),
950                    due_date.to_rfc3339(),
951                    input.payment_terms.clone(),
952                    input.currency.unwrap_or_default(),
953                    input.billing_name.clone(),
954                    input.billing_email.clone(),
955                    input.billing_address.clone(),
956                    input.billing_city.clone(),
957                    input.billing_state.clone(),
958                    input.billing_postal_code.clone(),
959                    input.billing_country.clone(),
960                    "0",
961                    input.discount_amount.unwrap_or_default().to_string(),
962                    input.discount_percent.map(|d| d.to_string()),
963                    input.tax_amount.unwrap_or_default().to_string(),
964                    input.tax_rate.map(|d| d.to_string()),
965                    input.shipping_amount.unwrap_or_default().to_string(),
966                    "0",
967                    "0",
968                    "0",
969                    input.po_number.clone(),
970                    input.notes.clone(),
971                    input.terms.clone(),
972                    input.footer.clone(),
973                    now.to_rfc3339(),
974                    now.to_rfc3339(),
975                ],
976            ).map_err(map_db_error)?;
977
978            // Add items
979            let mut items = Vec::with_capacity(input.items.len());
980            for (i, item) in input.items.into_iter().enumerate() {
981                let item_id = Uuid::new_v4();
982                let mut item_with_order = item;
983                if item_with_order.sort_order.is_none() {
984                    item_with_order.sort_order = Some(i as i32);
985                }
986
987                let line_total = item_with_order.quantity * item_with_order.unit_price
988                    - item_with_order.discount_amount.unwrap_or_default()
989                    + item_with_order.tax_amount.unwrap_or_default();
990
991                tx.execute(
992                    "INSERT INTO invoice_items (id, invoice_id, order_item_id, product_id, sku, description,
993                     quantity, unit_of_measure, unit_price, discount_amount, tax_amount, line_total,
994                     sort_order, created_at, updated_at)
995                     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
996                    params![
997                        item_id.to_string(),
998                        id.to_string(),
999                        item_with_order.order_item_id.map(|id| id.to_string()),
1000                        item_with_order.product_id.map(|id| id.to_string()),
1001                        item_with_order.sku.clone(),
1002                        item_with_order.description.clone(),
1003                        item_with_order.quantity.to_string(),
1004                        item_with_order.unit_of_measure.clone(),
1005                        item_with_order.unit_price.to_string(),
1006                        item_with_order.discount_amount.unwrap_or_default().to_string(),
1007                        item_with_order.tax_amount.unwrap_or_default().to_string(),
1008                        line_total.to_string(),
1009                        item_with_order.sort_order.unwrap_or(0),
1010                        now.to_rfc3339(),
1011                        now.to_rfc3339(),
1012                    ],
1013                )
1014                .map_err(map_db_error)?;
1015
1016                items.push(InvoiceItem {
1017                    id: item_id,
1018                    invoice_id: id,
1019                    order_item_id: item_with_order.order_item_id,
1020                    product_id: item_with_order.product_id,
1021                    sku: item_with_order.sku,
1022                    description: item_with_order.description,
1023                    quantity: item_with_order.quantity,
1024                    unit_of_measure: item_with_order.unit_of_measure,
1025                    unit_price: item_with_order.unit_price,
1026                    discount_amount: item_with_order.discount_amount.unwrap_or_default(),
1027                    tax_amount: item_with_order.tax_amount.unwrap_or_default(),
1028                    line_total,
1029                    sort_order: item_with_order.sort_order.unwrap_or(0),
1030                    created_at: now,
1031                    updated_at: now,
1032                });
1033            }
1034
1035            // Recalculate totals
1036            Self::recalculate_with_conn(&tx, id)?;
1037
1038            // Query for the computed invoice values
1039            let invoice = tx
1040                .query_row(
1041                    "SELECT * FROM invoices WHERE id = ?",
1042                    [id.to_string()],
1043                    Self::row_to_invoice,
1044                )
1045                .map_err(map_db_error)?;
1046
1047            results.push(Invoice {
1048                id,
1049                invoice_number,
1050                customer_id: input.customer_id,
1051                order_id: input.order_id,
1052                status: InvoiceStatus::Draft,
1053                invoice_type: input.invoice_type.unwrap_or_default(),
1054                invoice_date,
1055                due_date,
1056                payment_terms: input.payment_terms,
1057                currency: input.currency.unwrap_or_default(),
1058                billing_name: input.billing_name,
1059                billing_email: input.billing_email,
1060                billing_address: input.billing_address,
1061                billing_city: input.billing_city,
1062                billing_state: input.billing_state,
1063                billing_postal_code: input.billing_postal_code,
1064                billing_country: input.billing_country,
1065                subtotal: invoice.subtotal,
1066                discount_amount: input.discount_amount.unwrap_or_default(),
1067                discount_percent: input.discount_percent,
1068                tax_amount: input.tax_amount.unwrap_or_default(),
1069                tax_rate: input.tax_rate,
1070                shipping_amount: input.shipping_amount.unwrap_or_default(),
1071                total: invoice.total,
1072                amount_paid: Decimal::ZERO,
1073                balance_due: invoice.balance_due,
1074                po_number: input.po_number,
1075                notes: input.notes,
1076                terms: input.terms,
1077                footer: input.footer,
1078                sent_at: None,
1079                viewed_at: None,
1080                paid_at: None,
1081                voided_at: None,
1082                items,
1083                created_at: now,
1084                updated_at: now,
1085            });
1086        }
1087
1088        tx.commit().map_err(map_db_error)?;
1089        Ok(results)
1090    }
1091
1092    fn update_batch(
1093        &self,
1094        updates: Vec<(InvoiceId, UpdateInvoice)>,
1095    ) -> Result<BatchResult<Invoice>> {
1096        validate_batch_size(&updates)?;
1097        let mut result = BatchResult::with_capacity(updates.len());
1098
1099        for (index, (id, input)) in updates.into_iter().enumerate() {
1100            match self.update(id, input) {
1101                Ok(invoice) => result.record_success(invoice),
1102                Err(e) => result.record_failure(index, Some(id.to_string()), &e),
1103            }
1104        }
1105
1106        Ok(result)
1107    }
1108
1109    fn update_batch_atomic(
1110        &self,
1111        updates: Vec<(InvoiceId, UpdateInvoice)>,
1112    ) -> Result<Vec<Invoice>> {
1113        validate_batch_size(&updates)?;
1114        if updates.is_empty() {
1115            return Ok(vec![]);
1116        }
1117
1118        let mut conn = self.conn()?;
1119        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1120        let mut results = Vec::with_capacity(updates.len());
1121
1122        for (id, input) in updates {
1123            let now = chrono::Utc::now();
1124            let invoice = tx
1125                .query_row(
1126                    "SELECT * FROM invoices WHERE id = ?",
1127                    [id.to_string()],
1128                    Self::row_to_invoice,
1129                )
1130                .map_err(map_db_error)?;
1131
1132            tx.execute(
1133                "UPDATE invoices SET due_date = ?, payment_terms = ?, billing_name = ?, billing_email = ?,
1134                 billing_address = ?, billing_city = ?, billing_state = ?, billing_postal_code = ?,
1135                 billing_country = ?, discount_amount = ?, discount_percent = ?, tax_amount = ?,
1136                 tax_rate = ?, shipping_amount = ?, po_number = ?, notes = ?, terms = ?, footer = ?,
1137                 updated_at = ? WHERE id = ?",
1138                params![
1139                    input.due_date.map_or_else(|| invoice.due_date.to_rfc3339(), |d| d.to_rfc3339()),
1140                    input.payment_terms.or(invoice.payment_terms.clone()),
1141                    input.billing_name.or(invoice.billing_name.clone()),
1142                    input.billing_email.or(invoice.billing_email.clone()),
1143                    input.billing_address.or(invoice.billing_address.clone()),
1144                    input.billing_city.or(invoice.billing_city.clone()),
1145                    input.billing_state.or(invoice.billing_state.clone()),
1146                    input.billing_postal_code.or(invoice.billing_postal_code.clone()),
1147                    input.billing_country.or(invoice.billing_country.clone()),
1148                    input.discount_amount.unwrap_or(invoice.discount_amount).to_string(),
1149                    input.discount_percent.map(|d| d.to_string()).or(invoice.discount_percent.map(|d| d.to_string())),
1150                    input.tax_amount.unwrap_or(invoice.tax_amount).to_string(),
1151                    input.tax_rate.map(|d| d.to_string()).or(invoice.tax_rate.map(|d| d.to_string())),
1152                    input.shipping_amount.unwrap_or(invoice.shipping_amount).to_string(),
1153                    input.po_number.or(invoice.po_number.clone()),
1154                    input.notes.or(invoice.notes.clone()),
1155                    input.terms.or(invoice.terms.clone()),
1156                    input.footer.or(invoice.footer.clone()),
1157                    now.to_rfc3339(),
1158                    id.to_string(),
1159                ],
1160            ).map_err(map_db_error)?;
1161
1162            Self::recalculate_with_conn(&tx, id)?;
1163
1164            let updated_invoice = tx
1165                .query_row(
1166                    "SELECT * FROM invoices WHERE id = ?",
1167                    [id.to_string()],
1168                    Self::row_to_invoice,
1169                )
1170                .map_err(map_db_error)?;
1171
1172            results.push(updated_invoice);
1173        }
1174
1175        tx.commit().map_err(map_db_error)?;
1176
1177        // Load items for all invoices in one batched query
1178        let conn = self.conn()?;
1179        let ids: Vec<InvoiceId> = results.iter().map(|i| i.id).collect();
1180        let mut items_by_id = Self::get_invoice_items_batch(&conn, &ids)?;
1181        for invoice in &mut results {
1182            invoice.items = items_by_id.remove(&invoice.id.to_string()).unwrap_or_default();
1183        }
1184
1185        Ok(results)
1186    }
1187
1188    fn delete_batch(&self, ids: Vec<InvoiceId>) -> Result<BatchResult<Uuid>> {
1189        validate_batch_size(&ids)?;
1190        let mut result = BatchResult::with_capacity(ids.len());
1191
1192        for (index, id) in ids.into_iter().enumerate() {
1193            match self.delete(id) {
1194                Ok(()) => result.record_success(id.into_uuid()),
1195                Err(e) => result.record_failure(index, Some(id.to_string()), &e),
1196            }
1197        }
1198
1199        Ok(result)
1200    }
1201
1202    fn delete_batch_atomic(&self, ids: Vec<InvoiceId>) -> Result<()> {
1203        validate_batch_size(&ids)?;
1204        if ids.is_empty() {
1205            return Ok(());
1206        }
1207
1208        let mut conn = self.conn()?;
1209        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1210
1211        // Check that all invoices are in Draft status before deleting
1212        let placeholders = build_in_clause(ids.len());
1213        let raw_ids: Vec<Uuid> = ids.iter().map(|id| id.into_uuid()).collect();
1214        let params = uuid_params(&raw_ids);
1215        let params_refs = params_refs(&params);
1216
1217        {
1218            let sql = format!("SELECT id, status FROM invoices WHERE id IN ({placeholders})");
1219            let mut stmt = tx.prepare(&sql).map_err(map_db_error)?;
1220            let rows = stmt
1221                .query_map(params_refs.as_slice(), |row| {
1222                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1223                })
1224                .map_err(map_db_error)?;
1225
1226            for row in rows {
1227                let (id_str, status) = row.map_err(map_db_error)?;
1228                if parse_enum::<InvoiceStatus>(&status, "invoice", "status")?
1229                    != InvoiceStatus::Draft
1230                {
1231                    return Err(CommerceError::ValidationError(format!(
1232                        "Can only delete draft invoices. Invoice {id_str} has status {status}"
1233                    )));
1234                }
1235            }
1236        }
1237
1238        // Delete invoice items first
1239        let sql = format!("DELETE FROM invoice_items WHERE invoice_id IN ({placeholders})");
1240        tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1241
1242        // Delete invoices
1243        let sql = format!("DELETE FROM invoices WHERE id IN ({placeholders})");
1244        tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1245
1246        tx.commit().map_err(map_db_error)?;
1247        Ok(())
1248    }
1249
1250    fn get_batch(&self, ids: Vec<InvoiceId>) -> Result<Vec<Invoice>> {
1251        validate_batch_size(&ids)?;
1252        if ids.is_empty() {
1253            return Ok(vec![]);
1254        }
1255
1256        let conn = self.conn()?;
1257        let placeholders = build_in_clause(ids.len());
1258        let sql = format!("SELECT * FROM invoices WHERE id IN ({placeholders})");
1259
1260        let raw_ids: Vec<Uuid> = ids.iter().map(|id| id.into_uuid()).collect();
1261        let params = uuid_params(&raw_ids);
1262        let params_refs = params_refs(&params);
1263
1264        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1265        let invoices = stmt
1266            .query_map(params_refs.as_slice(), Self::row_to_invoice)
1267            .map_err(map_db_error)?
1268            .collect::<rusqlite::Result<Vec<_>>>()
1269            .map_err(map_db_error)?;
1270
1271        // Load items for all invoices in one batched query
1272        let ids: Vec<InvoiceId> = invoices.iter().map(|i| i.id).collect();
1273        let mut items_by_id = Self::get_invoice_items_batch(&conn, &ids)?;
1274        let mut result = vec![];
1275        for mut invoice in invoices {
1276            invoice.items = items_by_id.remove(&invoice.id.to_string()).unwrap_or_default();
1277            result.push(invoice);
1278        }
1279
1280        Ok(result)
1281    }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287    use crate::{DatabaseConfig, SqliteDatabase};
1288    use rust_decimal_macros::dec;
1289    use stateset_core::{
1290        CreateInvoice, CreateInvoiceItem, CustomerId, InvoiceFilter, InvoiceRepository,
1291        InvoiceStatus, RecordInvoicePayment,
1292    };
1293
1294    fn fresh_repo() -> SqliteInvoiceRepository {
1295        SqliteDatabase::in_memory().expect("in-memory").invoices()
1296    }
1297
1298    fn make_invoice(repo: &SqliteInvoiceRepository, customer: CustomerId) -> Invoice {
1299        repo.create(CreateInvoice {
1300            customer_id: customer,
1301            order_id: None,
1302            invoice_type: None,
1303            invoice_date: None,
1304            due_date: None,
1305            days_until_due: Some(30),
1306            payment_terms: Some("NET30".into()),
1307            currency: None,
1308            billing_name: Some("Ada Lovelace".into()),
1309            billing_email: Some("ada@example.com".into()),
1310            billing_address: None,
1311            billing_city: None,
1312            billing_state: None,
1313            billing_postal_code: None,
1314            billing_country: None,
1315            discount_amount: None,
1316            discount_percent: None,
1317            tax_amount: None,
1318            tax_rate: None,
1319            shipping_amount: None,
1320            po_number: None,
1321            notes: None,
1322            terms: None,
1323            footer: None,
1324            items: vec![CreateInvoiceItem {
1325                description: "Widget".into(),
1326                quantity: dec!(2),
1327                unit_price: dec!(50),
1328                ..Default::default()
1329            }],
1330        })
1331        .expect("create invoice")
1332    }
1333
1334    #[test]
1335    fn concurrent_payments_are_not_lost() {
1336        // Ten partial payments of $10 land simultaneously on a $100 invoice.
1337        // Each is a read-modify-write of amount_paid; without serialization the
1338        // reads race and payments are silently overwritten (lost updates). All
1339        // ten must be recorded, leaving the invoice fully paid.
1340        use std::sync::{Arc, Barrier};
1341        use std::thread;
1342
1343        let db = Arc::new(SqliteDatabase::new(&DatabaseConfig::in_memory()).unwrap());
1344        let repo = SqliteInvoiceRepository::new(db.pool().clone());
1345        let invoice = make_invoice(&repo, CustomerId::new()); // total $100
1346        assert_eq!(invoice.balance_due, dec!(100.00));
1347
1348        let thread_count = 10;
1349        let barrier = Arc::new(Barrier::new(thread_count));
1350        let mut handles = Vec::new();
1351        for _ in 0..thread_count {
1352            let db = Arc::clone(&db);
1353            let barrier = Arc::clone(&barrier);
1354            let invoice_id = invoice.id;
1355            handles.push(thread::spawn(move || {
1356                let repo = SqliteInvoiceRepository::new(db.pool().clone());
1357                barrier.wait();
1358                repo.record_payment(
1359                    invoice_id,
1360                    RecordInvoicePayment {
1361                        amount: dec!(10.00),
1362                        payment_id: None,
1363                        payment_method: None,
1364                        reference: None,
1365                        notes: None,
1366                    },
1367                )
1368            }));
1369        }
1370
1371        let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
1372        let successes = results.iter().filter(|r| r.is_ok()).count();
1373        // Every failure must be a transient lock (the caller retries), never a
1374        // lost/garbled update.
1375        let _ = thread_count;
1376        assert!(
1377            results
1378                .iter()
1379                .all(|r| r.is_ok() || format!("{:?}", r.as_ref().unwrap_err()).contains("locked")),
1380            "every failure must be a transient lock: {results:?}"
1381        );
1382        assert!(successes >= 1, "at least one payment must succeed: {results:?}");
1383
1384        let fetched = repo.get(invoice.id).expect("get").expect("found");
1385        // amount_paid reflects EXACTLY the successful $10 payments — no lost
1386        // updates and no phantom errors. record_payment reads the updated invoice
1387        // back inside its transaction, so a payment commits if and only if it
1388        // returns Ok: a payment can never quietly land while surfacing an error
1389        // (which previously happened when a post-commit read hit "table is
1390        // locked", and could tempt the caller to retry and double-pay).
1391        assert_eq!(
1392            fetched.amount_paid,
1393            dec!(10.00) * Decimal::from(successes as u64),
1394            "amount_paid must equal exactly the successful payments (Ok iff committed): {results:?}"
1395        );
1396        assert_eq!(fetched.balance_due, dec!(100.00) - fetched.amount_paid);
1397        let expected_status = if fetched.balance_due <= dec!(0) {
1398            InvoiceStatus::Paid
1399        } else {
1400            InvoiceStatus::PartiallyPaid
1401        };
1402        assert_eq!(fetched.status, expected_status);
1403    }
1404
1405    #[test]
1406    fn record_payment_rejects_nonpositive_amount() {
1407        // A zero or negative "payment" would drive amount_paid down and the
1408        // balance up — un-paying an invoice. It must be rejected, leaving the
1409        // invoice untouched, like every other money-in operation in the engine.
1410        let repo = fresh_repo();
1411        let invoice = make_invoice(&repo, CustomerId::new()); // total $100
1412
1413        let pay = |amount| RecordInvoicePayment {
1414            amount,
1415            payment_id: None,
1416            payment_method: None,
1417            reference: None,
1418            notes: None,
1419        };
1420        assert!(repo.record_payment(invoice.id, pay(dec!(0))).is_err(), "zero must be rejected");
1421        assert!(
1422            repo.record_payment(invoice.id, pay(dec!(-10))).is_err(),
1423            "negative must be rejected"
1424        );
1425
1426        let fetched = repo.get(invoice.id).expect("get").expect("found");
1427        assert_eq!(fetched.amount_paid, dec!(0), "rejected payment must not touch amount_paid");
1428        assert_eq!(fetched.balance_due, dec!(100.00));
1429    }
1430
1431    #[test]
1432    fn create_invoice_starts_in_draft_with_items() {
1433        let repo = fresh_repo();
1434        let cust = CustomerId::new();
1435        let inv = make_invoice(&repo, cust);
1436        assert_eq!(inv.customer_id, cust);
1437        assert_eq!(inv.status, InvoiceStatus::Draft);
1438        assert!(!inv.invoice_number.is_empty());
1439
1440        let items = repo.get_items(inv.id).expect("items");
1441        assert_eq!(items.len(), 1);
1442        assert_eq!(items[0].quantity, dec!(2));
1443    }
1444
1445    #[test]
1446    fn get_and_get_by_number_round_trip() {
1447        let repo = fresh_repo();
1448        let inv = make_invoice(&repo, CustomerId::new());
1449        let by_id = repo.get(inv.id).expect("ok").expect("found");
1450        assert_eq!(by_id.id, inv.id);
1451        let by_num = repo.get_by_number(&inv.invoice_number).expect("ok").expect("found");
1452        assert_eq!(by_num.id, inv.id);
1453        assert!(repo.get_by_number("missing").expect("ok").is_none());
1454    }
1455
1456    #[test]
1457    fn send_transitions_to_sent() {
1458        let repo = fresh_repo();
1459        let inv = make_invoice(&repo, CustomerId::new());
1460        let sent = repo.send(inv.id).expect("send");
1461        assert_eq!(sent.status, InvoiceStatus::Sent);
1462    }
1463
1464    #[test]
1465    fn void_transitions_to_voided() {
1466        let repo = fresh_repo();
1467        let inv = make_invoice(&repo, CustomerId::new());
1468        let voided = repo.void(inv.id).expect("void");
1469        assert_eq!(voided.status, InvoiceStatus::Voided);
1470    }
1471
1472    #[test]
1473    fn list_filters_by_customer() {
1474        let repo = fresh_repo();
1475        let cust_a = CustomerId::new();
1476        let cust_b = CustomerId::new();
1477        make_invoice(&repo, cust_a);
1478        make_invoice(&repo, cust_a);
1479        make_invoice(&repo, cust_b);
1480
1481        let for_a = repo
1482            .list(InvoiceFilter { customer_id: Some(cust_a), ..Default::default() })
1483            .expect("list");
1484        assert_eq!(for_a.len(), 2);
1485        assert!(for_a.iter().all(|i| i.customer_id == cust_a));
1486    }
1487
1488    #[test]
1489    fn list_filters_by_status() {
1490        let repo = fresh_repo();
1491        let cust = CustomerId::new();
1492        let draft = make_invoice(&repo, cust);
1493        let to_send = make_invoice(&repo, cust);
1494        repo.send(to_send.id).expect("send");
1495
1496        let drafts = repo
1497            .list(InvoiceFilter { status: Some(InvoiceStatus::Draft), ..Default::default() })
1498            .expect("drafts");
1499        let sents = repo
1500            .list(InvoiceFilter { status: Some(InvoiceStatus::Sent), ..Default::default() })
1501            .expect("sents");
1502        assert!(drafts.iter().any(|i| i.id == draft.id));
1503        assert!(sents.iter().any(|i| i.id == to_send.id));
1504    }
1505
1506    #[test]
1507    fn get_overdue_empty_on_fresh_db() {
1508        let repo = fresh_repo();
1509        assert!(repo.get_overdue().expect("ok").is_empty());
1510    }
1511
1512    #[test]
1513    fn create_batch_returns_per_input_results() {
1514        let repo = fresh_repo();
1515        let cust = CustomerId::new();
1516        let mk = |desc: &str| CreateInvoice {
1517            customer_id: cust,
1518            days_until_due: Some(30),
1519            items: vec![CreateInvoiceItem {
1520                description: desc.into(),
1521                quantity: dec!(1),
1522                unit_price: dec!(10),
1523                ..Default::default()
1524            }],
1525            ..Default::default()
1526        };
1527        let result = repo.create_batch(vec![mk("A"), mk("B"), mk("C")]).expect("batch");
1528        assert_eq!(result.success_count, 3);
1529        assert_eq!(result.failure_count, 0);
1530    }
1531
1532    #[test]
1533    fn get_unknown_invoice_returns_none() {
1534        let repo = fresh_repo();
1535        assert!(repo.get(stateset_core::InvoiceId::new()).expect("ok").is_none());
1536    }
1537
1538    #[test]
1539    fn get_batch_returns_only_existing() {
1540        let repo = fresh_repo();
1541        let cust = CustomerId::new();
1542        let i1 = make_invoice(&repo, cust);
1543        let i2 = make_invoice(&repo, cust);
1544        let stranger = stateset_core::InvoiceId::new();
1545        let fetched = repo.get_batch(vec![i1.id, i2.id, stranger]).expect("ok");
1546        assert_eq!(fetched.len(), 2);
1547    }
1548}