Skip to main content

stateset_db/sqlite/
accounts_payable.rs

1//! SQLite implementation for Accounts Payable
2
3use crate::sqlite::{
4    map_db_error, parse_datetime, parse_datetime_opt_row, parse_datetime_row,
5    parse_decimal_opt_row, parse_decimal_row, parse_decimal_strict, parse_enum_row, parse_uuid,
6    parse_uuid_opt_row, parse_uuid_row, sum_decimal_query, with_immediate_transaction,
7};
8use chrono::{DateTime, NaiveTime, Utc};
9use r2d2::Pool;
10use r2d2_sqlite::SqliteConnectionManager;
11use rusqlite::{OptionalExtension, params};
12use rust_decimal::{Decimal, RoundingStrategy};
13use std::collections::HashMap;
14use uuid::Uuid;
15
16use stateset_core::{
17    AccountsPayableRepository, ApAgingSummary, BatchResult, Bill, BillFilter, BillItem,
18    BillPayment, BillPaymentFilter, BillStatus, CommerceError, CreateBill, CreateBillItem,
19    CreateBillPayment, CreatePaymentRun, PaymentAllocation, PaymentRun, PaymentRunFilter,
20    PaymentRunStatus, PaymentStatusAP, Result, SupplierApSummary, UpdateBill,
21    generate_ap_payment_number, generate_bill_number, generate_payment_run_number,
22};
23
24#[derive(Debug)]
25pub struct SqliteAccountsPayableRepository {
26    pool: Pool<SqliteConnectionManager>,
27}
28
29impl SqliteAccountsPayableRepository {
30    #[must_use]
31    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
32        Self { pool }
33    }
34
35    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
36        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
37    }
38
39    fn row_to_bill(row: &rusqlite::Row<'_>) -> rusqlite::Result<Bill> {
40        Ok(Bill {
41            id: parse_uuid_row(&row.get::<_, String>("id")?, "bill", "id")?,
42            bill_number: row.get("bill_number")?,
43            supplier_id: parse_uuid_row(
44                &row.get::<_, String>("supplier_id")?,
45                "bill",
46                "supplier_id",
47            )?,
48            supplier_name: row.get("supplier_name")?,
49            purchase_order_id: parse_uuid_opt_row(
50                row.get("purchase_order_id")?,
51                "bill",
52                "purchase_order_id",
53            )?,
54            status: parse_enum_row(&row.get::<_, String>("status")?, "bill", "status")?,
55            bill_date: parse_datetime_row(
56                &row.get::<_, String>("bill_date")?,
57                "bill",
58                "bill_date",
59            )?,
60            due_date: parse_datetime_row(&row.get::<_, String>("due_date")?, "bill", "due_date")?,
61            payment_terms: row.get("payment_terms")?,
62            subtotal: parse_decimal_row(&row.get::<_, String>("subtotal")?, "bill", "subtotal")?,
63            tax_amount: parse_decimal_row(
64                &row.get::<_, String>("tax_amount")?,
65                "bill",
66                "tax_amount",
67            )?,
68            shipping_amount: parse_decimal_row(
69                &row.get::<_, String>("shipping_amount")?,
70                "bill",
71                "shipping_amount",
72            )?,
73            discount_amount: parse_decimal_row(
74                &row.get::<_, String>("discount_amount")?,
75                "bill",
76                "discount_amount",
77            )?,
78            total_amount: parse_decimal_row(
79                &row.get::<_, String>("total_amount")?,
80                "bill",
81                "total_amount",
82            )?,
83            amount_paid: parse_decimal_row(
84                &row.get::<_, String>("amount_paid")?,
85                "bill",
86                "amount_paid",
87            )?,
88            amount_due: parse_decimal_row(
89                &row.get::<_, String>("amount_due")?,
90                "bill",
91                "amount_due",
92            )?,
93            currency: row.get("currency")?,
94            reference_number: row.get("reference_number")?,
95            memo: row.get("memo")?,
96            created_at: parse_datetime_row(
97                &row.get::<_, String>("created_at")?,
98                "bill",
99                "created_at",
100            )?,
101            updated_at: parse_datetime_row(
102                &row.get::<_, String>("updated_at")?,
103                "bill",
104                "updated_at",
105            )?,
106        })
107    }
108
109    fn row_to_bill_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<BillItem> {
110        Ok(BillItem {
111            id: parse_uuid_row(&row.get::<_, String>("id")?, "bill_item", "id")?,
112            bill_id: parse_uuid_row(&row.get::<_, String>("bill_id")?, "bill_item", "bill_id")?,
113            line_number: row.get("line_number")?,
114            description: row.get("description")?,
115            account_code: row.get("account_code")?,
116            quantity: parse_decimal_row(
117                &row.get::<_, String>("quantity")?,
118                "bill_item",
119                "quantity",
120            )?,
121            unit_price: parse_decimal_row(
122                &row.get::<_, String>("unit_price")?,
123                "bill_item",
124                "unit_price",
125            )?,
126            amount: parse_decimal_row(&row.get::<_, String>("amount")?, "bill_item", "amount")?,
127            tax_rate: parse_decimal_opt_row(row.get("tax_rate")?, "bill_item", "tax_rate")?,
128            tax_amount: parse_decimal_row(
129                &row.get::<_, String>("tax_amount")?,
130                "bill_item",
131                "tax_amount",
132            )?,
133            po_line_id: parse_uuid_opt_row(row.get("po_line_id")?, "bill_item", "po_line_id")?,
134            created_at: parse_datetime_row(
135                &row.get::<_, String>("created_at")?,
136                "bill_item",
137                "created_at",
138            )?,
139        })
140    }
141
142    fn row_to_payment(row: &rusqlite::Row<'_>) -> rusqlite::Result<BillPayment> {
143        Ok(BillPayment {
144            id: parse_uuid_row(&row.get::<_, String>("id")?, "bill_payment", "id")?,
145            payment_number: row.get("payment_number")?,
146            supplier_id: parse_uuid_row(
147                &row.get::<_, String>("supplier_id")?,
148                "bill_payment",
149                "supplier_id",
150            )?,
151            payment_date: parse_datetime_row(
152                &row.get::<_, String>("payment_date")?,
153                "bill_payment",
154                "payment_date",
155            )?,
156            payment_method: parse_enum_row(
157                &row.get::<_, String>("payment_method")?,
158                "bill_payment",
159                "payment_method",
160            )?,
161            amount: parse_decimal_row(&row.get::<_, String>("amount")?, "bill_payment", "amount")?,
162            currency: row.get("currency")?,
163            reference_number: row.get("reference_number")?,
164            bank_account: row.get("bank_account")?,
165            check_number: row.get("check_number")?,
166            memo: row.get("memo")?,
167            status: parse_enum_row(&row.get::<_, String>("status")?, "bill_payment", "status")?,
168            created_at: parse_datetime_row(
169                &row.get::<_, String>("created_at")?,
170                "bill_payment",
171                "created_at",
172            )?,
173            updated_at: parse_datetime_row(
174                &row.get::<_, String>("updated_at")?,
175                "bill_payment",
176                "updated_at",
177            )?,
178        })
179    }
180
181    fn row_to_payment_run(row: &rusqlite::Row<'_>) -> rusqlite::Result<PaymentRun> {
182        Ok(PaymentRun {
183            id: parse_uuid_row(&row.get::<_, String>("id")?, "payment_run", "id")?,
184            run_number: row.get("run_number")?,
185            status: parse_enum_row(&row.get::<_, String>("status")?, "payment_run", "status")?,
186            payment_date: parse_datetime_row(
187                &row.get::<_, String>("payment_date")?,
188                "payment_run",
189                "payment_date",
190            )?,
191            payment_method: parse_enum_row(
192                &row.get::<_, String>("payment_method")?,
193                "payment_run",
194                "payment_method",
195            )?,
196            total_amount: parse_decimal_row(
197                &row.get::<_, String>("total_amount")?,
198                "payment_run",
199                "total_amount",
200            )?,
201            payment_count: row.get("payment_count")?,
202            notes: row.get("notes")?,
203            created_by: row.get("created_by")?,
204            approved_by: row.get("approved_by")?,
205            approved_at: parse_datetime_opt_row(
206                row.get("approved_at")?,
207                "payment_run",
208                "approved_at",
209            )?,
210            processed_at: parse_datetime_opt_row(
211                row.get("processed_at")?,
212                "payment_run",
213                "processed_at",
214            )?,
215            created_at: parse_datetime_row(
216                &row.get::<_, String>("created_at")?,
217                "payment_run",
218                "created_at",
219            )?,
220            updated_at: parse_datetime_row(
221                &row.get::<_, String>("updated_at")?,
222                "payment_run",
223                "updated_at",
224            )?,
225        })
226    }
227
228    fn recalculate_bill_with_conn(conn: &rusqlite::Connection, bill_id: Uuid) -> Result<()> {
229        let bill_id_param = bill_id.to_string();
230        let mut stmt = conn
231            .prepare("SELECT amount, tax_amount FROM ap_bill_items WHERE bill_id = ?1")
232            .map_err(map_db_error)?;
233        let mut rows = stmt.query(params![&bill_id_param]).map_err(map_db_error)?;
234        let mut subtotal_dec = Decimal::ZERO;
235        let mut tax_dec = Decimal::ZERO;
236
237        while let Some(row) = rows.next().map_err(map_db_error)? {
238            let amount_str: String = row.get(0).map_err(map_db_error)?;
239            let tax_str: String = row.get(1).map_err(map_db_error)?;
240            subtotal_dec += parse_decimal_strict(&amount_str, "ap_bill_items", "amount")?;
241            tax_dec += parse_decimal_strict(&tax_str, "ap_bill_items", "tax_amount")?;
242        }
243        let total = subtotal_dec + tax_dec;
244
245        let payment_params: [&dyn rusqlite::ToSql; 1] = [&bill_id_param];
246        let paid = sum_decimal_query(
247            conn,
248            "SELECT a.amount
249             FROM ap_payment_allocations a
250             JOIN ap_payments p ON p.id = a.payment_id
251             WHERE a.bill_id = ?1
252               AND p.status NOT IN ('voided', 'failed')",
253            &payment_params,
254            "ap_payment_allocations",
255            "amount",
256        )?;
257        let due = total - paid;
258
259        conn.execute(
260            "UPDATE ap_bills SET subtotal = ?1, tax_amount = ?2, total_amount = ?3, amount_paid = ?4, amount_due = ?5 WHERE id = ?6",
261            params![
262                round_ap(subtotal_dec, 4),
263                round_ap(tax_dec, 4),
264                round_ap(total, 4),
265                round_ap(paid, 4),
266                round_ap(due, 4),
267                bill_id.to_string()
268            ],
269        ).map_err(map_db_error)?;
270
271        Ok(())
272    }
273
274    fn recalculate_bill(&self, bill_id: Uuid) -> Result<()> {
275        let conn = self.conn()?;
276        Self::recalculate_bill_with_conn(&conn, bill_id)
277    }
278
279    /// Returns all `ap_bills` matching `filter` (`supplier_id`, `status`,
280    /// `purchase_order_id`, `overdue_only`, and the Rust-side min/max amount
281    /// thresholds), ordered by `due_date`, WITHOUT pagination. Shared by `list_bills`
282    /// (which then paginates) and `count_bills` (which counts the result) so a
283    /// filtered count always matches the filtered list, both agreeing with Postgres.
284    /// `from_date`/`to_date` are deferred (entangled with the AP date-storage
285    /// divergence, as with the other AP list filters).
286    fn matched_bills(&self, filter: &BillFilter) -> Result<Vec<Bill>> {
287        let conn = self.conn()?;
288        let mut sql = "SELECT * FROM ap_bills WHERE 1=1".to_string();
289        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
290
291        if let Some(supplier_id) = filter.supplier_id {
292            sql.push_str(" AND supplier_id = ?");
293            params_vec.push(Box::new(supplier_id.to_string()));
294        }
295        if let Some(status) = filter.status {
296            sql.push_str(" AND status = ?");
297            params_vec.push(Box::new(status.to_string()));
298        }
299        if let Some(purchase_order_id) = filter.purchase_order_id {
300            sql.push_str(" AND purchase_order_id = ?");
301            params_vec.push(Box::new(purchase_order_id.to_string()));
302        }
303        if filter.overdue_only == Some(true) {
304            sql.push_str(" AND due_date < datetime('now') AND status NOT IN ('paid', 'cancelled')");
305        }
306        // `bill_date` is stored truncated to midnight UTC (like Postgres's `DATE`),
307        // and the bound value is likewise midnight UTC, so the RFC3339 string
308        // comparison is a well-defined date comparison — matching Postgres, which
309        // filters `bill_date >= from_date` / `bill_date <= to_date`.
310        if let Some(from_date) = filter.from_date {
311            sql.push_str(" AND bill_date >= ?");
312            params_vec.push(Box::new(ap_date_rfc3339(from_date)));
313        }
314        if let Some(to_date) = filter.to_date {
315            sql.push_str(" AND bill_date <= ?");
316            params_vec.push(Box::new(ap_date_rfc3339(to_date)));
317        }
318
319        sql.push_str(" ORDER BY due_date");
320
321        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
322        let params_refs: Vec<&dyn rusqlite::ToSql> =
323            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
324        let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
325
326        // The min/max_amount thresholds compare the TEXT `total_amount` column,
327        // which SQLite cannot compare numerically without a lossy `CAST` — filter
328        // them exactly in Rust (already parsed to `Decimal`).
329        let mut matched = Vec::new();
330        while let Some(row) = rows.next().map_err(map_db_error)? {
331            let bill = Self::row_to_bill(row).map_err(map_db_error)?;
332            if let Some(min) = filter.min_amount {
333                if bill.total_amount < min {
334                    continue;
335                }
336            }
337            if let Some(max) = filter.max_amount {
338                if bill.total_amount > max {
339                    continue;
340                }
341            }
342            matched.push(bill);
343        }
344        Ok(matched)
345    }
346}
347
348/// Round an accounts-payable money value to the scale of the Postgres
349/// `NUMERIC(12, s)` columns before storing it as TEXT, using `MidpointAwayFromZero`
350/// (Postgres numeric rounding) so the two backends store and read back identical
351/// values. Money columns use scale 4; `tax_rate` uses scale 6.
352fn round_ap(value: Decimal, scale: u32) -> String {
353    value.round_dp_with_strategy(scale, RoundingStrategy::MidpointAwayFromZero).to_string()
354}
355
356/// Format an accounts-payable date column (`bill_date`, `due_date`, `payment_date`)
357/// for storage. Postgres holds these as `DATE`, which drops the time-of-day and
358/// reads back at midnight UTC; this truncates to midnight UTC so SQLite reads back
359/// the same value, while keeping the RFC3339 format so every existing reader (and
360/// the auto-posting date parsers) continues to work unchanged.
361fn ap_date_rfc3339(dt: DateTime<Utc>) -> String {
362    dt.date_naive().and_time(NaiveTime::MIN).and_utc().to_rfc3339()
363}
364
365/// Appends the shared `ap_payments` filter predicates (`supplier_id`, `status`,
366/// `payment_method`) used by both `list_payments` and `count_payments`, so a
367/// filtered count always matches the filtered list and both agree with Postgres.
368/// `from_date`/`to_date` are intentionally omitted (deferred — entangled with the AP
369/// date-storage divergence, as with `list_bills`).
370fn push_payment_filters(
371    sql: &mut String,
372    params_vec: &mut Vec<Box<dyn rusqlite::ToSql>>,
373    filter: &BillPaymentFilter,
374) {
375    if let Some(supplier_id) = filter.supplier_id {
376        sql.push_str(" AND supplier_id = ?");
377        params_vec.push(Box::new(supplier_id.to_string()));
378    }
379    if let Some(status) = filter.status {
380        sql.push_str(" AND status = ?");
381        params_vec.push(Box::new(status.to_string()));
382    }
383    if let Some(method) = filter.payment_method {
384        sql.push_str(" AND payment_method = ?");
385        params_vec.push(Box::new(method.to_string()));
386    }
387}
388
389impl AccountsPayableRepository for SqliteAccountsPayableRepository {
390    fn create_bill(&self, input: CreateBill) -> Result<Bill> {
391        let now = Utc::now();
392        let id = Uuid::new_v4();
393        let CreateBill {
394            bill_number,
395            supplier_id,
396            purchase_order_id,
397            bill_date,
398            due_date,
399            payment_terms,
400            currency,
401            reference_number,
402            memo,
403            items,
404            ..
405        } = input;
406        let bill_number = bill_number.unwrap_or_else(generate_bill_number);
407
408        {
409            let conn = self.conn()?;
410            conn.execute(
411                "INSERT INTO ap_bills (id, bill_number, supplier_id, purchase_order_id, status, bill_date, due_date,
412                 payment_terms, currency, reference_number, memo, created_at, updated_at)
413                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)",
414                params![
415                    id.to_string(),
416                    bill_number,
417                    supplier_id.to_string(),
418                    purchase_order_id.map(|id| id.to_string()),
419                    BillStatus::Draft.to_string(),
420                    ap_date_rfc3339(bill_date.unwrap_or(now)),
421                    ap_date_rfc3339(due_date),
422                    payment_terms,
423                    currency.unwrap_or_default(),
424                    reference_number,
425                    memo,
426                    now.to_rfc3339(),
427                ],
428            ).map_err(map_db_error)?;
429        }
430
431        for item in &items {
432            self.add_bill_item(
433                id,
434                CreateBillItem {
435                    description: item.description.clone(),
436                    account_code: item.account_code.clone(),
437                    quantity: item.quantity,
438                    unit_price: item.unit_price,
439                    tax_rate: item.tax_rate,
440                    po_line_id: item.po_line_id,
441                },
442            )?;
443        }
444
445        self.recalculate_bill(id)?;
446        self.get_bill(id)?
447            .ok_or_else(|| CommerceError::DatabaseError("Failed to create bill".into()))
448    }
449
450    fn get_bill(&self, id: Uuid) -> Result<Option<Bill>> {
451        let conn = self.conn()?;
452        let mut stmt =
453            conn.prepare("SELECT * FROM ap_bills WHERE id = ?1").map_err(map_db_error)?;
454        let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
455
456        if let Some(row) = rows.next().map_err(map_db_error)? {
457            Ok(Some(Self::row_to_bill(row).map_err(map_db_error)?))
458        } else {
459            Ok(None)
460        }
461    }
462
463    fn get_bill_by_number(&self, number: &str) -> Result<Option<Bill>> {
464        let conn = self.conn()?;
465        let mut stmt =
466            conn.prepare("SELECT * FROM ap_bills WHERE bill_number = ?1").map_err(map_db_error)?;
467        let mut rows = stmt.query(params![number]).map_err(map_db_error)?;
468
469        if let Some(row) = rows.next().map_err(map_db_error)? {
470            Ok(Some(Self::row_to_bill(row).map_err(map_db_error)?))
471        } else {
472            Ok(None)
473        }
474    }
475
476    fn update_bill(&self, id: Uuid, input: UpdateBill) -> Result<Bill> {
477        let conn = self.conn()?;
478        let existing = self.get_bill(id)?.ok_or(CommerceError::NotFound)?;
479
480        conn.execute(
481            "UPDATE ap_bills SET due_date = ?1, payment_terms = ?2, reference_number = ?3, memo = ?4 WHERE id = ?5",
482            params![
483                ap_date_rfc3339(input.due_date.unwrap_or(existing.due_date)),
484                input.payment_terms.or(existing.payment_terms),
485                input.reference_number.or(existing.reference_number),
486                input.memo.or(existing.memo),
487                id.to_string(),
488            ],
489        ).map_err(map_db_error)?;
490
491        self.get_bill(id)?
492            .ok_or_else(|| CommerceError::DatabaseError("Failed to update bill".into()))
493    }
494
495    fn list_bills(&self, filter: BillFilter) -> Result<Vec<Bill>> {
496        // Shared matching logic (WHERE + Rust min/max) with `count_bills`, then
497        // paginate — matching the Postgres order (WHERE then LIMIT/OFFSET).
498        let mut matched = self.matched_bills(&filter)?;
499
500        let offset = filter.offset.unwrap_or(0) as usize;
501        let mut page = if offset >= matched.len() { Vec::new() } else { matched.split_off(offset) };
502        if let Some(limit) = filter.limit {
503            page.truncate(limit as usize);
504        }
505        Ok(page)
506    }
507
508    fn delete_bill(&self, id: Uuid) -> Result<()> {
509        let conn = self.conn()?;
510        let bill = self.get_bill(id)?.ok_or(CommerceError::NotFound)?;
511
512        if bill.status != BillStatus::Draft {
513            return Err(CommerceError::ValidationError("Can only delete draft bills".into()));
514        }
515
516        conn.execute("DELETE FROM ap_bills WHERE id = ?1", params![id.to_string()])
517            .map_err(map_db_error)?;
518        Ok(())
519    }
520
521    fn approve_bill(&self, id: Uuid) -> Result<Bill> {
522        let conn = self.conn()?;
523        conn.execute(
524            "UPDATE ap_bills SET status = ?1 WHERE id = ?2 AND status IN ('draft', 'pending')",
525            params![BillStatus::Approved.to_string(), id.to_string()],
526        )
527        .map_err(map_db_error)?;
528
529        self.get_bill(id)?
530            .ok_or_else(|| CommerceError::DatabaseError("Failed to approve bill".into()))
531    }
532
533    fn cancel_bill(&self, id: Uuid) -> Result<Bill> {
534        let conn = self.conn()?;
535        conn.execute(
536            "UPDATE ap_bills SET status = ?1 WHERE id = ?2",
537            params![BillStatus::Cancelled.to_string(), id.to_string()],
538        )
539        .map_err(map_db_error)?;
540
541        self.get_bill(id)?
542            .ok_or_else(|| CommerceError::DatabaseError("Failed to cancel bill".into()))
543    }
544
545    fn dispute_bill(&self, id: Uuid) -> Result<Bill> {
546        let conn = self.conn()?;
547        conn.execute(
548            "UPDATE ap_bills SET status = ?1 WHERE id = ?2",
549            params![BillStatus::Disputed.to_string(), id.to_string()],
550        )
551        .map_err(map_db_error)?;
552
553        self.get_bill(id)?
554            .ok_or_else(|| CommerceError::DatabaseError("Failed to dispute bill".into()))
555    }
556
557    fn get_bill_items(&self, bill_id: Uuid) -> Result<Vec<BillItem>> {
558        let conn = self.conn()?;
559        let mut stmt = conn
560            .prepare("SELECT * FROM ap_bill_items WHERE bill_id = ?1 ORDER BY line_number")
561            .map_err(map_db_error)?;
562        let mut rows = stmt.query(params![bill_id.to_string()]).map_err(map_db_error)?;
563
564        let mut items = Vec::new();
565        while let Some(row) = rows.next().map_err(map_db_error)? {
566            items.push(Self::row_to_bill_item(row).map_err(map_db_error)?);
567        }
568        Ok(items)
569    }
570
571    fn add_bill_item(&self, bill_id: Uuid, item: CreateBillItem) -> Result<BillItem> {
572        let now = Utc::now().to_rfc3339();
573        let id = Uuid::new_v4();
574        let created = {
575            let conn = self.conn()?;
576            let line_number: i32 = conn.query_row(
577                "SELECT COALESCE(MAX(line_number), 0) + 1 FROM ap_bill_items WHERE bill_id = ?1",
578                params![bill_id.to_string()],
579                |row| row.get(0),
580            ).map_err(map_db_error)?;
581
582            let amount = item.quantity * item.unit_price;
583            let tax_amount =
584                item.tax_rate.map_or(Decimal::ZERO, |r| amount * r / Decimal::from(100));
585
586            conn.execute(
587                "INSERT INTO ap_bill_items (id, bill_id, line_number, description, account_code, quantity, unit_price, amount, tax_rate, tax_amount, po_line_id, created_at)
588                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
589                params![
590                    id.to_string(),
591                    bill_id.to_string(),
592                    line_number,
593                    item.description,
594                    item.account_code,
595                    round_ap(item.quantity, 4),
596                    round_ap(item.unit_price, 4),
597                    round_ap(amount, 4),
598                    item.tax_rate.map(|r| round_ap(r, 6)),
599                    round_ap(tax_amount, 4),
600                    item.po_line_id.map(|id| id.to_string()),
601                    now,
602                ],
603            ).map_err(map_db_error)?;
604
605            let mut stmt =
606                conn.prepare("SELECT * FROM ap_bill_items WHERE id = ?1").map_err(map_db_error)?;
607            let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
608
609            if let Some(row) = rows.next().map_err(map_db_error)? {
610                Self::row_to_bill_item(row).map_err(map_db_error)?
611            } else {
612                return Err(CommerceError::DatabaseError("Failed to create bill item".into()));
613            }
614        };
615
616        self.recalculate_bill(bill_id)?;
617        Ok(created)
618    }
619
620    fn remove_bill_item(&self, item_id: Uuid) -> Result<()> {
621        let bill_id: String = {
622            let conn = self.conn()?;
623            let bill_id: String = conn
624                .query_row(
625                    "SELECT bill_id FROM ap_bill_items WHERE id = ?1",
626                    params![item_id.to_string()],
627                    |row| row.get(0),
628                )
629                .map_err(map_db_error)?;
630
631            conn.execute("DELETE FROM ap_bill_items WHERE id = ?1", params![item_id.to_string()])
632                .map_err(map_db_error)?;
633            bill_id
634        };
635
636        self.recalculate_bill(parse_uuid(&bill_id, "bill_item", "bill_id")?)?;
637        Ok(())
638    }
639
640    fn count_bills(&self, filter: BillFilter) -> Result<u64> {
641        // Share `list_bills`' matching logic (all filters except pagination) so a
642        // filtered count always equals the length of the filtered list.
643        Ok(self.matched_bills(&filter)?.len() as u64)
644    }
645
646    fn get_overdue_bills(&self) -> Result<Vec<Bill>> {
647        self.list_bills(BillFilter { overdue_only: Some(true), ..Default::default() })
648    }
649
650    fn get_bills_due_soon(&self, days: i32) -> Result<Vec<Bill>> {
651        let conn = self.conn()?;
652        let mut stmt = conn.prepare(
653            "SELECT * FROM ap_bills WHERE due_date <= datetime('now', '+' || ?1 || ' days') AND status NOT IN ('paid', 'cancelled') ORDER BY due_date"
654        ).map_err(map_db_error)?;
655
656        let mut rows = stmt.query(params![days]).map_err(map_db_error)?;
657        let mut bills = Vec::new();
658        while let Some(row) = rows.next().map_err(map_db_error)? {
659            bills.push(Self::row_to_bill(row).map_err(map_db_error)?);
660        }
661        Ok(bills)
662    }
663
664    fn create_payment(&self, input: CreateBillPayment) -> Result<BillPayment> {
665        if input.amount <= Decimal::ZERO {
666            return Err(CommerceError::ValidationError(
667                "Payment amount must be greater than zero".into(),
668            ));
669        }
670        if input.allocations.is_empty() {
671            return Err(CommerceError::ValidationError(
672                "At least one payment allocation is required".into(),
673            ));
674        }
675
676        let now = Utc::now();
677        let id = Uuid::new_v4();
678        let payment_number = generate_ap_payment_number();
679
680        let mut allocation_total = Decimal::ZERO;
681        let mut allocation_by_bill: HashMap<Uuid, Decimal> = HashMap::new();
682        for alloc in &input.allocations {
683            if alloc.amount <= Decimal::ZERO {
684                return Err(CommerceError::ValidationError(
685                    "Allocation amount must be greater than zero".into(),
686                ));
687            }
688            allocation_total += alloc.amount;
689            *allocation_by_bill.entry(alloc.bill_id).or_insert(Decimal::ZERO) += alloc.amount;
690        }
691
692        if allocation_total != input.amount {
693            return Err(CommerceError::ValidationError(
694                "Payment amount must equal allocation total".into(),
695            ));
696        }
697
698        // Immediate (write-locked) transaction so concurrent payments against the
699        // same bill serialize: each re-reads amount_due under the lock, so a
700        // second payment cannot over-pay a supplier past the bill balance. Was a
701        // deferred `super::begin_immediate(&mut conn)` with no BUSY retry.
702        with_immediate_transaction(&self.pool, |tx| {
703            let to_rusqlite =
704                |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
705
706            for (bill_id, allocated_amount) in &allocation_by_bill {
707                let (supplier_id, status, amount_due): (String, String, String) = tx.query_row(
708                    "SELECT supplier_id, status, amount_due FROM ap_bills WHERE id = ?1",
709                    params![bill_id.to_string()],
710                    |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
711                )?;
712
713                let parsed_supplier_id =
714                    parse_uuid(&supplier_id, "bill", "supplier_id").map_err(to_rusqlite)?;
715                if parsed_supplier_id != input.supplier_id {
716                    return Err(to_rusqlite(CommerceError::ValidationError(
717                        "Allocation bill supplier does not match payment supplier".into(),
718                    )));
719                }
720
721                let bill_status: BillStatus = status.parse().map_err(|e| {
722                    to_rusqlite(CommerceError::DatabaseError(format!(
723                        "Invalid bill status '{status}' while creating payment: {e}"
724                    )))
725                })?;
726                if !matches!(
727                    bill_status,
728                    BillStatus::Approved | BillStatus::PartiallyPaid | BillStatus::Overdue
729                ) {
730                    return Err(to_rusqlite(CommerceError::ValidationError(
731                        "Bill is not in a payable status".into(),
732                    )));
733                }
734
735                let amount_due =
736                    parse_decimal_strict(&amount_due, "bill", "amount_due").map_err(to_rusqlite)?;
737                if *allocated_amount > amount_due {
738                    return Err(to_rusqlite(CommerceError::ValidationError(
739                        "Allocation amount exceeds bill amount due".into(),
740                    )));
741                }
742            }
743
744            tx.execute(
745                "INSERT INTO ap_payments (id, payment_number, supplier_id, payment_date, payment_method, amount, currency, reference_number, bank_account, check_number, memo, status, created_at, updated_at)
746                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?13)",
747                params![
748                    id.to_string(),
749                    payment_number,
750                    input.supplier_id.to_string(),
751                    ap_date_rfc3339(input.payment_date.unwrap_or(now)),
752                    input.payment_method.to_string(),
753                    input.amount.to_string(),
754                    input.currency.unwrap_or_default(),
755                    &input.reference_number,
756                    &input.bank_account,
757                    &input.check_number,
758                    &input.memo,
759                    PaymentStatusAP::Pending.to_string(),
760                    now.to_rfc3339(),
761                ],
762            )?;
763
764            for alloc in &input.allocations {
765                let alloc_id = Uuid::new_v4();
766                tx.execute(
767                    "INSERT INTO ap_payment_allocations (id, payment_id, bill_id, amount, created_at)
768                     VALUES (?1, ?2, ?3, ?4, ?5)",
769                    params![
770                        alloc_id.to_string(),
771                        id.to_string(),
772                        alloc.bill_id.to_string(),
773                        alloc.amount.to_string(),
774                        now.to_rfc3339()
775                    ],
776                )?;
777
778                Self::recalculate_bill_with_conn(tx, alloc.bill_id).map_err(to_rusqlite)?;
779
780                let bill = tx.query_row(
781                    "SELECT * FROM ap_bills WHERE id = ?1",
782                    params![alloc.bill_id.to_string()],
783                    Self::row_to_bill,
784                )?;
785                let new_status = if bill.amount_due <= Decimal::ZERO {
786                    BillStatus::Paid
787                } else if bill.amount_paid > Decimal::ZERO {
788                    BillStatus::PartiallyPaid
789                } else {
790                    bill.status
791                };
792
793                tx.execute(
794                    "UPDATE ap_bills SET status = ?1 WHERE id = ?2",
795                    params![new_status.to_string(), alloc.bill_id.to_string()],
796                )?;
797            }
798
799            Ok(())
800        })?;
801
802        self.get_payment(id)?
803            .ok_or_else(|| CommerceError::DatabaseError("Failed to create payment".into()))
804    }
805
806    fn get_payment(&self, id: Uuid) -> Result<Option<BillPayment>> {
807        let conn = self.conn()?;
808        let mut stmt =
809            conn.prepare("SELECT * FROM ap_payments WHERE id = ?1").map_err(map_db_error)?;
810        let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
811
812        if let Some(row) = rows.next().map_err(map_db_error)? {
813            Ok(Some(Self::row_to_payment(row).map_err(map_db_error)?))
814        } else {
815            Ok(None)
816        }
817    }
818
819    fn get_payment_by_number(&self, number: &str) -> Result<Option<BillPayment>> {
820        let conn = self.conn()?;
821        let mut stmt = conn
822            .prepare("SELECT * FROM ap_payments WHERE payment_number = ?1")
823            .map_err(map_db_error)?;
824        let mut rows = stmt.query(params![number]).map_err(map_db_error)?;
825
826        if let Some(row) = rows.next().map_err(map_db_error)? {
827            Ok(Some(Self::row_to_payment(row).map_err(map_db_error)?))
828        } else {
829            Ok(None)
830        }
831    }
832
833    fn list_payments(&self, filter: BillPaymentFilter) -> Result<Vec<BillPayment>> {
834        let conn = self.conn()?;
835        let mut sql = "SELECT * FROM ap_payments WHERE 1=1".to_string();
836        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
837
838        push_payment_filters(&mut sql, &mut params_vec, &filter);
839        // `payment_date` is stored/bound at midnight UTC (like Postgres's `DATE`), so
840        // the RFC3339 comparison is a date comparison — matching Postgres.
841        if let Some(from_date) = filter.from_date {
842            sql.push_str(" AND payment_date >= ?");
843            params_vec.push(Box::new(ap_date_rfc3339(from_date)));
844        }
845        if let Some(to_date) = filter.to_date {
846            sql.push_str(" AND payment_date <= ?");
847            params_vec.push(Box::new(ap_date_rfc3339(to_date)));
848        }
849
850        sql.push_str(" ORDER BY payment_date DESC");
851
852        // SQLite rejects OFFSET without LIMIT, so use `LIMIT -1` (unbounded) when
853        // only an offset is set.
854        match (filter.limit, filter.offset) {
855            (Some(limit), Some(offset)) => sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}")),
856            (Some(limit), None) => sql.push_str(&format!(" LIMIT {limit}")),
857            (None, Some(offset)) => sql.push_str(&format!(" LIMIT -1 OFFSET {offset}")),
858            (None, None) => {}
859        }
860
861        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
862        let params_refs: Vec<&dyn rusqlite::ToSql> =
863            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
864        let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
865
866        let mut payments = Vec::new();
867        while let Some(row) = rows.next().map_err(map_db_error)? {
868            payments.push(Self::row_to_payment(row).map_err(map_db_error)?);
869        }
870        Ok(payments)
871    }
872
873    fn void_payment(&self, id: Uuid) -> Result<BillPayment> {
874        // Immediate (write-locked) transaction with a status guard so a
875        // duplicate/concurrent void cannot reverse the same payment twice, and so
876        // it serializes (with BUSY retry) against create_payment on the same bill.
877        with_immediate_transaction(&self.pool, |tx| {
878            let to_rusqlite =
879                |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
880
881            // Guard: only a not-yet-voided payment can be voided.
882            let rows = tx.execute(
883                "UPDATE ap_payments SET status = ?1 WHERE id = ?2 AND status != ?1",
884                params![PaymentStatusAP::Voided.to_string(), id.to_string()],
885            )?;
886            if rows == 0 {
887                return Err(to_rusqlite(CommerceError::Conflict(
888                    "Payment not found or already voided".into(),
889                )));
890            }
891
892            let allocations: Vec<PaymentAllocation> = {
893                let mut stmt = tx.prepare("SELECT id, payment_id, bill_id, amount, created_at FROM ap_payment_allocations WHERE payment_id = ?1")?;
894                let mut rows = stmt.query(params![id.to_string()])?;
895                let mut values = Vec::new();
896                while let Some(row) = rows.next()? {
897                    values.push(PaymentAllocation {
898                        id: parse_uuid(&row.get::<_, String>(0)?, "payment_allocation", "id")
899                            .map_err(to_rusqlite)?,
900                        payment_id: parse_uuid(
901                            &row.get::<_, String>(1)?,
902                            "payment_allocation",
903                            "payment_id",
904                        )
905                        .map_err(to_rusqlite)?,
906                        bill_id: parse_uuid(
907                            &row.get::<_, String>(2)?,
908                            "payment_allocation",
909                            "bill_id",
910                        )
911                        .map_err(to_rusqlite)?,
912                        amount: parse_decimal_strict(
913                            &row.get::<_, String>(3)?,
914                            "payment_allocation",
915                            "amount",
916                        )
917                        .map_err(to_rusqlite)?,
918                        created_at: parse_datetime(
919                            &row.get::<_, String>(4)?,
920                            "payment_allocation",
921                            "created_at",
922                        )
923                        .map_err(to_rusqlite)?,
924                    });
925                }
926                values
927            };
928
929            tx.execute(
930                "DELETE FROM ap_payment_allocations WHERE payment_id = ?1",
931                params![id.to_string()],
932            )?;
933
934            for alloc in allocations {
935                Self::recalculate_bill_with_conn(tx, alloc.bill_id).map_err(to_rusqlite)?;
936
937                let bill = tx.query_row(
938                    "SELECT * FROM ap_bills WHERE id = ?1",
939                    params![alloc.bill_id.to_string()],
940                    Self::row_to_bill,
941                )?;
942                let new_status = if bill.amount_due <= Decimal::ZERO {
943                    BillStatus::Paid
944                } else if bill.amount_paid > Decimal::ZERO {
945                    BillStatus::PartiallyPaid
946                } else if bill.status == BillStatus::Overdue {
947                    BillStatus::Overdue
948                } else {
949                    BillStatus::Approved
950                };
951
952                tx.execute(
953                    "UPDATE ap_bills SET status = ?1 WHERE id = ?2",
954                    params![new_status.to_string(), bill.id.to_string()],
955                )?;
956            }
957
958            Ok(())
959        })?;
960
961        self.get_payment(id)?
962            .ok_or_else(|| CommerceError::DatabaseError("Failed to void payment".into()))
963    }
964
965    fn clear_payment(&self, id: Uuid) -> Result<BillPayment> {
966        let conn = self.conn()?;
967        conn.execute(
968            "UPDATE ap_payments SET status = ?1 WHERE id = ?2",
969            params![PaymentStatusAP::Cleared.to_string(), id.to_string()],
970        )
971        .map_err(map_db_error)?;
972
973        self.get_payment(id)?
974            .ok_or_else(|| CommerceError::DatabaseError("Failed to clear payment".into()))
975    }
976
977    fn get_payment_allocations(&self, payment_id: Uuid) -> Result<Vec<PaymentAllocation>> {
978        use crate::sqlite::parse_datetime;
979        use crate::sqlite::parse_helpers::parse_decimal;
980
981        let conn = self.conn()?;
982        let mut stmt = conn
983            .prepare("SELECT * FROM ap_payment_allocations WHERE payment_id = ?1")
984            .map_err(map_db_error)?;
985        let mut rows = stmt.query(params![payment_id.to_string()]).map_err(map_db_error)?;
986
987        let mut allocations = Vec::new();
988        while let Some(row) = rows.next().map_err(map_db_error)? {
989            let id_str: String = row.get("id").map_err(map_db_error)?;
990            let payment_id_str: String = row.get("payment_id").map_err(map_db_error)?;
991            let bill_id_str: String = row.get("bill_id").map_err(map_db_error)?;
992            let amount_str: String = row.get("amount").map_err(map_db_error)?;
993            let created_at_str: String = row.get("created_at").map_err(map_db_error)?;
994
995            allocations.push(PaymentAllocation {
996                id: parse_uuid(&id_str, "payment_allocation", "id")?,
997                payment_id: parse_uuid(&payment_id_str, "payment_allocation", "payment_id")?,
998                bill_id: parse_uuid(&bill_id_str, "payment_allocation", "bill_id")?,
999                amount: parse_decimal(&amount_str, "payment_allocation", "amount")?,
1000                created_at: parse_datetime(&created_at_str, "payment_allocation", "created_at")?,
1001            });
1002        }
1003        Ok(allocations)
1004    }
1005
1006    fn get_payments_for_bill(&self, bill_id: Uuid) -> Result<Vec<BillPayment>> {
1007        let conn = self.conn()?;
1008        let mut stmt = conn
1009            .prepare(
1010                "SELECT p.* FROM ap_payments p
1011             JOIN ap_payment_allocations a ON p.id = a.payment_id
1012             WHERE a.bill_id = ?1",
1013            )
1014            .map_err(map_db_error)?;
1015
1016        let mut rows = stmt.query(params![bill_id.to_string()]).map_err(map_db_error)?;
1017        let mut payments = Vec::new();
1018        while let Some(row) = rows.next().map_err(map_db_error)? {
1019            payments.push(Self::row_to_payment(row).map_err(map_db_error)?);
1020        }
1021        Ok(payments)
1022    }
1023
1024    fn count_payments(&self, filter: BillPaymentFilter) -> Result<u64> {
1025        let conn = self.conn()?;
1026        let mut sql = "SELECT COUNT(*) FROM ap_payments WHERE 1=1".to_string();
1027        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1028
1029        push_payment_filters(&mut sql, &mut params_vec, &filter);
1030
1031        let params_refs: Vec<&dyn rusqlite::ToSql> =
1032            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1033        let count: i64 =
1034            conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
1035        Ok(count as u64)
1036    }
1037
1038    fn create_payment_run(&self, input: CreatePaymentRun) -> Result<PaymentRun> {
1039        let conn = self.conn()?;
1040        let now = Utc::now().to_rfc3339();
1041        let id = Uuid::new_v4();
1042        let run_number = generate_payment_run_number();
1043
1044        // Calculate total
1045        let mut total = Decimal::ZERO;
1046        for bill_id in &input.bill_ids {
1047            if let Some(bill) = self.get_bill(*bill_id)? {
1048                total += bill.amount_due;
1049            }
1050        }
1051
1052        conn.execute(
1053            "INSERT INTO ap_payment_runs (id, run_number, status, payment_date, payment_method, total_amount, payment_count, notes, created_by, created_at, updated_at)
1054             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)",
1055            params![
1056                id.to_string(),
1057                run_number,
1058                PaymentRunStatus::Draft.to_string(),
1059                ap_date_rfc3339(input.payment_date),
1060                input.payment_method.to_string(),
1061                total.to_string(),
1062                input.bill_ids.len() as i32,
1063                input.notes,
1064                input.created_by,
1065                now,
1066            ],
1067        ).map_err(map_db_error)?;
1068
1069        // Add bills to run
1070        for bill_id in input.bill_ids {
1071            conn.execute(
1072                "INSERT INTO ap_payment_run_bills (run_id, bill_id) VALUES (?1, ?2)",
1073                params![id.to_string(), bill_id.to_string()],
1074            )
1075            .map_err(map_db_error)?;
1076        }
1077
1078        self.get_payment_run(id)?
1079            .ok_or_else(|| CommerceError::DatabaseError("Failed to create payment run".into()))
1080    }
1081
1082    fn get_payment_run(&self, id: Uuid) -> Result<Option<PaymentRun>> {
1083        let conn = self.conn()?;
1084        let mut stmt =
1085            conn.prepare("SELECT * FROM ap_payment_runs WHERE id = ?1").map_err(map_db_error)?;
1086        let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
1087
1088        if let Some(row) = rows.next().map_err(map_db_error)? {
1089            Ok(Some(Self::row_to_payment_run(row).map_err(map_db_error)?))
1090        } else {
1091            Ok(None)
1092        }
1093    }
1094
1095    fn list_payment_runs(&self, filter: PaymentRunFilter) -> Result<Vec<PaymentRun>> {
1096        let conn = self.conn()?;
1097        let mut sql = "SELECT * FROM ap_payment_runs WHERE 1=1".to_string();
1098        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1099
1100        if let Some(status) = filter.status {
1101            sql.push_str(" AND status = ?");
1102            params_vec.push(Box::new(status.to_string()));
1103        }
1104        // `payment_date` is stored/bound at midnight UTC (like Postgres's `DATE`), so
1105        // the RFC3339 comparison is a date comparison — matching Postgres.
1106        if let Some(from_date) = filter.from_date {
1107            sql.push_str(" AND payment_date >= ?");
1108            params_vec.push(Box::new(ap_date_rfc3339(from_date)));
1109        }
1110        if let Some(to_date) = filter.to_date {
1111            sql.push_str(" AND payment_date <= ?");
1112            params_vec.push(Box::new(ap_date_rfc3339(to_date)));
1113        }
1114
1115        sql.push_str(" ORDER BY created_at DESC");
1116
1117        // SQLite rejects OFFSET without LIMIT, so use `LIMIT -1` (unbounded) when
1118        // only an offset is set.
1119        match (filter.limit, filter.offset) {
1120            (Some(limit), Some(offset)) => sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}")),
1121            (Some(limit), None) => sql.push_str(&format!(" LIMIT {limit}")),
1122            (None, Some(offset)) => sql.push_str(&format!(" LIMIT -1 OFFSET {offset}")),
1123            (None, None) => {}
1124        }
1125
1126        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1127        let params_refs: Vec<&dyn rusqlite::ToSql> =
1128            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1129        let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
1130
1131        let mut runs = Vec::new();
1132        while let Some(row) = rows.next().map_err(map_db_error)? {
1133            runs.push(Self::row_to_payment_run(row).map_err(map_db_error)?);
1134        }
1135        Ok(runs)
1136    }
1137
1138    fn approve_payment_run(&self, id: Uuid, approved_by: &str) -> Result<PaymentRun> {
1139        let conn = self.conn()?;
1140        let now = Utc::now().to_rfc3339();
1141
1142        conn.execute(
1143            "UPDATE ap_payment_runs SET status = ?1, approved_by = ?2, approved_at = ?3 WHERE id = ?4",
1144            params![PaymentRunStatus::Approved.to_string(), approved_by, now, id.to_string()],
1145        ).map_err(map_db_error)?;
1146
1147        self.get_payment_run(id)?
1148            .ok_or_else(|| CommerceError::DatabaseError("Failed to approve run".into()))
1149    }
1150
1151    fn process_payment_run(&self, id: Uuid) -> Result<PaymentRun> {
1152        let conn = self.conn()?;
1153        let now = Utc::now().to_rfc3339();
1154
1155        conn.execute(
1156            "UPDATE ap_payment_runs SET status = ?1, processed_at = ?2 WHERE id = ?3",
1157            params![PaymentRunStatus::Completed.to_string(), now, id.to_string()],
1158        )
1159        .map_err(map_db_error)?;
1160
1161        self.get_payment_run(id)?
1162            .ok_or_else(|| CommerceError::DatabaseError("Failed to process run".into()))
1163    }
1164
1165    fn cancel_payment_run(&self, id: Uuid) -> Result<PaymentRun> {
1166        let conn = self.conn()?;
1167
1168        conn.execute(
1169            "UPDATE ap_payment_runs SET status = ?1 WHERE id = ?2",
1170            params![PaymentRunStatus::Cancelled.to_string(), id.to_string()],
1171        )
1172        .map_err(map_db_error)?;
1173
1174        self.get_payment_run(id)?
1175            .ok_or_else(|| CommerceError::DatabaseError("Failed to cancel run".into()))
1176    }
1177
1178    fn get_payment_run_bills(&self, run_id: Uuid) -> Result<Vec<Bill>> {
1179        let conn = self.conn()?;
1180        let mut stmt = conn.prepare(
1181            "SELECT b.* FROM ap_bills b JOIN ap_payment_run_bills rb ON b.id = rb.bill_id WHERE rb.run_id = ?1"
1182        ).map_err(map_db_error)?;
1183
1184        let mut rows = stmt.query(params![run_id.to_string()]).map_err(map_db_error)?;
1185        let mut bills = Vec::new();
1186        while let Some(row) = rows.next().map_err(map_db_error)? {
1187            bills.push(Self::row_to_bill(row).map_err(map_db_error)?);
1188        }
1189        Ok(bills)
1190    }
1191
1192    fn get_aging_summary(&self) -> Result<ApAgingSummary> {
1193        let conn = self.conn()?;
1194        let now = Utc::now();
1195        let cutoff_30 = now - chrono::Duration::days(30);
1196        let cutoff_60 = now - chrono::Duration::days(60);
1197        let cutoff_90 = now - chrono::Duration::days(90);
1198
1199        let mut current = Decimal::ZERO;
1200        let mut days_1_30 = Decimal::ZERO;
1201        let mut days_31_60 = Decimal::ZERO;
1202        let mut days_61_90 = Decimal::ZERO;
1203        let mut days_over_90 = Decimal::ZERO;
1204
1205        let mut stmt = conn.prepare(
1206            "SELECT due_date, amount_due FROM ap_bills WHERE status NOT IN ('paid', 'cancelled')",
1207        ).map_err(map_db_error)?;
1208        let mut rows = stmt.query([]).map_err(map_db_error)?;
1209
1210        while let Some(row) = rows.next().map_err(map_db_error)? {
1211            let due_date_str: String = row.get(0).map_err(map_db_error)?;
1212            let due_date = parse_datetime(&due_date_str, "ap_bill", "due_date")?;
1213            let amount_str: String = row.get(1).map_err(map_db_error)?;
1214            let amount = parse_decimal_strict(&amount_str, "ap_bills", "amount_due")?;
1215
1216            if due_date >= now {
1217                current += amount;
1218            } else if due_date >= cutoff_30 {
1219                days_1_30 += amount;
1220            } else if due_date >= cutoff_60 {
1221                days_31_60 += amount;
1222            } else if due_date >= cutoff_90 {
1223                days_61_90 += amount;
1224            } else {
1225                days_over_90 += amount;
1226            }
1227        }
1228
1229        let total = current + days_1_30 + days_31_60 + days_61_90 + days_over_90;
1230
1231        Ok(ApAgingSummary { current, days_1_30, days_31_60, days_61_90, days_over_90, total })
1232    }
1233
1234    fn get_supplier_summary(&self, supplier_id: Uuid) -> Result<Option<SupplierApSummary>> {
1235        let conn = self.conn()?;
1236        let now = Utc::now();
1237        let supplier_id_param = supplier_id.to_string();
1238
1239        let supplier_exists: Option<String> = conn
1240            .query_row(
1241                "SELECT id FROM suppliers WHERE id = ?1",
1242                params![&supplier_id_param],
1243                |row| row.get(0),
1244            )
1245            .optional()
1246            .map_err(map_db_error)?;
1247
1248        if supplier_exists.is_none() {
1249            return Ok(None);
1250        }
1251
1252        let mut stmt = conn.prepare(
1253            "SELECT due_date, amount_due FROM ap_bills WHERE supplier_id = ?1 AND status NOT IN ('paid', 'cancelled')",
1254        ).map_err(map_db_error)?;
1255        let mut rows = stmt.query(params![&supplier_id_param]).map_err(map_db_error)?;
1256        let mut outstanding = Decimal::ZERO;
1257        let mut overdue = Decimal::ZERO;
1258        let mut count: i32 = 0;
1259
1260        while let Some(row) = rows.next().map_err(map_db_error)? {
1261            count += 1;
1262            let due_date_str: String = row.get(0).map_err(map_db_error)?;
1263            let due_date = parse_datetime(&due_date_str, "ap_bill", "due_date")?;
1264            let amount_str: String = row.get(1).map_err(map_db_error)?;
1265            let amount = parse_decimal_strict(&amount_str, "ap_bills", "amount_due")?;
1266            outstanding += amount;
1267            if due_date < now {
1268                overdue += amount;
1269            }
1270        }
1271
1272        Ok(Some(SupplierApSummary {
1273            supplier_id,
1274            supplier_name: None,
1275            total_outstanding: outstanding,
1276            total_overdue: overdue,
1277            bill_count: count,
1278        }))
1279    }
1280
1281    fn get_total_outstanding(&self) -> Result<Decimal> {
1282        let conn = self.conn()?;
1283        let total = sum_decimal_query(
1284            &conn,
1285            "SELECT amount_due FROM ap_bills WHERE status NOT IN ('paid', 'cancelled')",
1286            &[],
1287            "ap_bills",
1288            "amount_due",
1289        )?;
1290
1291        Ok(total)
1292    }
1293
1294    fn create_bills_batch(&self, inputs: Vec<CreateBill>) -> Result<BatchResult<Bill>> {
1295        let mut result = BatchResult::new();
1296        for (index, input) in inputs.into_iter().enumerate() {
1297            match self.create_bill(input) {
1298                Ok(bill) => result.record_success(bill),
1299                Err(e) => result.record_failure(index, None, &e),
1300            }
1301        }
1302        Ok(result)
1303    }
1304
1305    fn get_bills_batch(&self, ids: Vec<Uuid>) -> Result<Vec<Bill>> {
1306        let mut bills = Vec::new();
1307        for id in ids {
1308            if let Some(bill) = self.get_bill(id)? {
1309                bills.push(bill);
1310            }
1311        }
1312        Ok(bills)
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319    use crate::SqliteDatabase;
1320    use chrono::Duration;
1321    use rust_decimal_macros::dec;
1322    use stateset_core::{
1323        AccountsPayableRepository, BillFilter, BillPaymentFilter, BillStatus, CreateBill,
1324        CreateBillItem, CreateBillPayment, PaymentAllocationInput, PaymentMethodAP,
1325    };
1326
1327    fn fresh_repo() -> SqliteAccountsPayableRepository {
1328        SqliteDatabase::in_memory().expect("in-memory").accounts_payable()
1329    }
1330
1331    fn make_bill(
1332        repo: &SqliteAccountsPayableRepository,
1333        supplier: Uuid,
1334        qty: Decimal,
1335        price: Decimal,
1336    ) -> Bill {
1337        repo.create_bill(CreateBill {
1338            bill_number: None,
1339            supplier_id: supplier,
1340            purchase_order_id: None,
1341            bill_date: None,
1342            due_date: Utc::now() + Duration::days(30),
1343            payment_terms: Some("NET30".into()),
1344            currency: None,
1345            reference_number: None,
1346            memo: Some("test bill".into()),
1347            items: vec![CreateBillItem {
1348                description: "Widget".into(),
1349                account_code: Some("5000".into()),
1350                quantity: qty,
1351                unit_price: price,
1352                tax_rate: None,
1353                po_line_id: None,
1354            }],
1355        })
1356        .expect("create bill")
1357    }
1358
1359    #[test]
1360    fn list_bills_honors_po_amount_and_offset_filters() {
1361        let repo = fresh_repo();
1362        let supplier = Uuid::new_v4();
1363        let po = Uuid::new_v4();
1364
1365        make_bill(&repo, supplier, dec!(1), dec!(20));
1366        let big = make_bill(&repo, supplier, dec!(1), dec!(200));
1367        let with_po = repo
1368            .create_bill(CreateBill {
1369                bill_number: None,
1370                supplier_id: supplier,
1371                purchase_order_id: Some(po),
1372                bill_date: None,
1373                due_date: Utc::now() + Duration::days(30),
1374                payment_terms: None,
1375                currency: None,
1376                reference_number: None,
1377                memo: None,
1378                items: vec![CreateBillItem {
1379                    description: "X".into(),
1380                    account_code: None,
1381                    quantity: dec!(1),
1382                    unit_price: dec!(50),
1383                    tax_rate: None,
1384                    po_line_id: None,
1385                }],
1386            })
1387            .expect("po bill");
1388
1389        // min_amount: only bills with total_amount >= 100.
1390        let over = repo
1391            .list_bills(BillFilter { min_amount: Some(dec!(100)), ..Default::default() })
1392            .expect("min_amount");
1393        assert_eq!(over.len(), 1, "min_amount must filter: {over:?}");
1394        assert_eq!(over[0].id, big.id);
1395
1396        // max_amount: bills with total_amount <= 100 (excludes the $200 bill).
1397        let under = repo
1398            .list_bills(BillFilter { max_amount: Some(dec!(100)), ..Default::default() })
1399            .expect("max_amount");
1400        assert!(!under.iter().any(|b| b.id == big.id), "max_amount must exclude the $200 bill");
1401        assert!(under.iter().any(|b| b.id == with_po.id));
1402
1403        // purchase_order_id: only the PO-tagged bill.
1404        let by_po = repo
1405            .list_bills(BillFilter { purchase_order_id: Some(po), ..Default::default() })
1406            .expect("po");
1407        assert_eq!(by_po.len(), 1, "purchase_order_id must filter: {by_po:?}");
1408        assert_eq!(by_po[0].id, with_po.id);
1409
1410        // offset skips rows.
1411        assert_eq!(repo.list_bills(BillFilter::default()).expect("all").len(), 3);
1412        let offset1 =
1413            repo.list_bills(BillFilter { offset: Some(1), ..Default::default() }).expect("offset");
1414        assert_eq!(offset1.len(), 2, "offset must skip rows");
1415    }
1416
1417    #[test]
1418    fn create_bill_starts_in_draft_with_items() {
1419        let repo = fresh_repo();
1420        let supplier = Uuid::new_v4();
1421        let bill = make_bill(&repo, supplier, dec!(10), dec!(5));
1422        assert_eq!(bill.supplier_id, supplier);
1423        assert_eq!(bill.status, BillStatus::Draft);
1424        assert!(bill.bill_number.starts_with("BILL-"));
1425
1426        let items = repo.get_bill_items(bill.id).expect("items");
1427        assert_eq!(items.len(), 1);
1428        assert_eq!(items[0].quantity, dec!(10));
1429    }
1430
1431    #[test]
1432    fn get_bill_and_get_bill_by_number_round_trip() {
1433        let repo = fresh_repo();
1434        let bill = make_bill(&repo, Uuid::new_v4(), dec!(1), dec!(1));
1435        let by_id = repo.get_bill(bill.id).expect("ok").expect("found");
1436        assert_eq!(by_id.id, bill.id);
1437        let by_num = repo.get_bill_by_number(&bill.bill_number).expect("ok").expect("found");
1438        assert_eq!(by_num.id, bill.id);
1439        assert!(repo.get_bill_by_number("missing").expect("ok").is_none());
1440    }
1441
1442    #[test]
1443    fn approve_bill_transitions_status() {
1444        let repo = fresh_repo();
1445        let bill = make_bill(&repo, Uuid::new_v4(), dec!(1), dec!(50));
1446        let approved = repo.approve_bill(bill.id).expect("approve");
1447        assert_eq!(approved.status, BillStatus::Approved);
1448    }
1449
1450    #[test]
1451    fn list_bills_filters_by_supplier() {
1452        let repo = fresh_repo();
1453        let s_a = Uuid::new_v4();
1454        let s_b = Uuid::new_v4();
1455        make_bill(&repo, s_a, dec!(1), dec!(1));
1456        make_bill(&repo, s_a, dec!(2), dec!(2));
1457        make_bill(&repo, s_b, dec!(3), dec!(3));
1458
1459        let for_a = repo
1460            .list_bills(BillFilter { supplier_id: Some(s_a), ..Default::default() })
1461            .expect("list");
1462        assert_eq!(for_a.len(), 2);
1463        assert!(for_a.iter().all(|b| b.supplier_id == s_a));
1464    }
1465
1466    #[test]
1467    fn list_bills_filters_by_status() {
1468        let repo = fresh_repo();
1469        let supplier = Uuid::new_v4();
1470        let draft = make_bill(&repo, supplier, dec!(1), dec!(1));
1471        let to_approve = make_bill(&repo, supplier, dec!(1), dec!(1));
1472        repo.approve_bill(to_approve.id).expect("approve");
1473
1474        let drafts = repo
1475            .list_bills(BillFilter { status: Some(BillStatus::Draft), ..Default::default() })
1476            .expect("drafts");
1477        let approved = repo
1478            .list_bills(BillFilter { status: Some(BillStatus::Approved), ..Default::default() })
1479            .expect("approved");
1480        assert!(drafts.iter().any(|b| b.id == draft.id));
1481        assert!(approved.iter().any(|b| b.id == to_approve.id));
1482    }
1483
1484    #[test]
1485    fn empty_db_returns_zero_aging_and_outstanding() {
1486        let repo = fresh_repo();
1487        let aging = repo.get_aging_summary().expect("aging");
1488        assert_eq!(aging.total, dec!(0));
1489        let outstanding = repo.get_total_outstanding().expect("outstanding");
1490        assert_eq!(outstanding, dec!(0));
1491    }
1492
1493    #[test]
1494    fn empty_db_returns_no_overdue_or_due_soon() {
1495        let repo = fresh_repo();
1496        assert!(repo.get_overdue_bills().expect("overdue").is_empty());
1497        assert!(repo.get_bills_due_soon(7).expect("due soon").is_empty());
1498    }
1499
1500    #[test]
1501    fn get_supplier_summary_for_unknown_returns_none() {
1502        let repo = fresh_repo();
1503        assert!(repo.get_supplier_summary(Uuid::new_v4()).expect("ok").is_none());
1504    }
1505
1506    #[test]
1507    fn create_payment_with_allocation_links_to_bill() {
1508        let repo = fresh_repo();
1509        let supplier = Uuid::new_v4();
1510        let bill = make_bill(&repo, supplier, dec!(2), dec!(50));
1511        repo.approve_bill(bill.id).expect("approve");
1512
1513        let payment = repo
1514            .create_payment(CreateBillPayment {
1515                supplier_id: supplier,
1516                payment_date: None,
1517                payment_method: PaymentMethodAP::Ach,
1518                amount: dec!(100),
1519                currency: None,
1520                reference_number: Some("ACH-1".into()),
1521                bank_account: None,
1522                check_number: None,
1523                memo: None,
1524                allocations: vec![PaymentAllocationInput { bill_id: bill.id, amount: dec!(100) }],
1525            })
1526            .expect("create payment");
1527        assert_eq!(payment.supplier_id, supplier);
1528        assert_eq!(payment.amount, dec!(100));
1529
1530        let allocations = repo.get_payment_allocations(payment.id).expect("allocations");
1531        assert_eq!(allocations.len(), 1);
1532        assert_eq!(allocations[0].bill_id, bill.id);
1533
1534        let payments_for_bill = repo.get_payments_for_bill(bill.id).expect("payments");
1535        assert!(payments_for_bill.iter().any(|p| p.id == payment.id));
1536    }
1537
1538    #[test]
1539    fn void_payment_is_guarded_against_double_void() {
1540        let repo = fresh_repo();
1541        let supplier = Uuid::new_v4();
1542        let bill = make_bill(&repo, supplier, dec!(10), dec!(10)); // amount_due = 100
1543        repo.approve_bill(bill.id).expect("approve");
1544
1545        let payment = repo
1546            .create_payment(CreateBillPayment {
1547                supplier_id: supplier,
1548                payment_date: None,
1549                payment_method: PaymentMethodAP::Ach,
1550                amount: dec!(100),
1551                currency: None,
1552                reference_number: None,
1553                bank_account: None,
1554                check_number: None,
1555                memo: None,
1556                allocations: vec![PaymentAllocationInput { bill_id: bill.id, amount: dec!(100) }],
1557            })
1558            .expect("pay");
1559
1560        repo.void_payment(payment.id).expect("first void succeeds");
1561        // Bill balance is restored by the void.
1562        let after_void = repo.get_bill(bill.id).expect("get").expect("bill");
1563        assert_eq!(after_void.amount_paid, dec!(0), "void reverses the payment");
1564        assert_eq!(after_void.amount_due, dec!(100));
1565
1566        // A second void must be rejected, not silently reverse again.
1567        let second = repo.void_payment(payment.id);
1568        assert!(second.is_err(), "double-void must be rejected");
1569        let after_second = repo.get_bill(bill.id).expect("get").expect("bill");
1570        assert_eq!(after_second.amount_due, dec!(100), "balance unchanged by the rejected void");
1571    }
1572
1573    #[test]
1574    fn create_payment_is_atomic_under_concurrency() {
1575        use std::sync::{Arc, Barrier};
1576
1577        let repo = fresh_repo();
1578        let supplier = Uuid::new_v4();
1579        // Bill with amount_due = 10 * 10 = 100.
1580        let bill = make_bill(&repo, supplier, dec!(10), dec!(10));
1581        repo.approve_bill(bill.id).expect("approve");
1582
1583        const THREADS: usize = 10;
1584        let barrier = Arc::new(Barrier::new(THREADS));
1585
1586        // Each thread pays the full 100 against the same bill; only one may win.
1587        let successes = std::thread::scope(|s| {
1588            let handles: Vec<_> = (0..THREADS)
1589                .map(|_| {
1590                    let barrier = Arc::clone(&barrier);
1591                    let repo = &repo;
1592                    let bill_id = bill.id;
1593                    s.spawn(move || {
1594                        barrier.wait();
1595                        repo.create_payment(CreateBillPayment {
1596                            supplier_id: supplier,
1597                            payment_date: None,
1598                            payment_method: PaymentMethodAP::Ach,
1599                            amount: dec!(100),
1600                            currency: None,
1601                            reference_number: None,
1602                            bank_account: None,
1603                            check_number: None,
1604                            memo: None,
1605                            allocations: vec![PaymentAllocationInput {
1606                                bill_id,
1607                                amount: dec!(100),
1608                            }],
1609                        })
1610                        .is_ok()
1611                    })
1612                })
1613                .collect();
1614            handles.into_iter().map(|h| h.join().unwrap()).filter(|&ok| ok).count()
1615        });
1616
1617        // Safety invariant: the 100 bill can be paid AT MOST once (never
1618        // double-paid). Under extreme lock contention the sole winner can fail
1619        // transiently (retryable), so zero successes is acceptable; two or more
1620        // is the double-pay bug this guards against.
1621        assert!(successes <= 1, "the bill was paid more than once across {THREADS} threads");
1622        let after = repo.get_bill(bill.id).expect("get").expect("bill");
1623        assert_eq!(
1624            after.amount_paid,
1625            dec!(100) * Decimal::from(successes as u64),
1626            "amount_paid must reflect exactly the successful payment"
1627        );
1628        assert_eq!(after.amount_due, dec!(100) - after.amount_paid);
1629    }
1630
1631    #[test]
1632    fn list_payments_filters_by_supplier() {
1633        let repo = fresh_repo();
1634        let supplier_a = Uuid::new_v4();
1635        let supplier_b = Uuid::new_v4();
1636        let bill_a = make_bill(&repo, supplier_a, dec!(1), dec!(10));
1637        let bill_b = make_bill(&repo, supplier_b, dec!(1), dec!(10));
1638        repo.approve_bill(bill_a.id).expect("approve a");
1639        repo.approve_bill(bill_b.id).expect("approve b");
1640
1641        repo.create_payment(CreateBillPayment {
1642            supplier_id: supplier_a,
1643            payment_date: None,
1644            payment_method: PaymentMethodAP::Check,
1645            amount: dec!(10),
1646            currency: None,
1647            reference_number: None,
1648            bank_account: None,
1649            check_number: None,
1650            memo: None,
1651            allocations: vec![PaymentAllocationInput { bill_id: bill_a.id, amount: dec!(10) }],
1652        })
1653        .expect("pay a");
1654        repo.create_payment(CreateBillPayment {
1655            supplier_id: supplier_b,
1656            payment_date: None,
1657            payment_method: PaymentMethodAP::Check,
1658            amount: dec!(10),
1659            currency: None,
1660            reference_number: None,
1661            bank_account: None,
1662            check_number: None,
1663            memo: None,
1664            allocations: vec![PaymentAllocationInput { bill_id: bill_b.id, amount: dec!(10) }],
1665        })
1666        .expect("pay b");
1667
1668        let payments_a = repo
1669            .list_payments(BillPaymentFilter {
1670                supplier_id: Some(supplier_a),
1671                ..Default::default()
1672            })
1673            .expect("list");
1674        assert_eq!(payments_a.len(), 1);
1675        assert_eq!(payments_a[0].supplier_id, supplier_a);
1676    }
1677
1678    #[test]
1679    fn create_bills_batch_returns_per_input_results() {
1680        let repo = fresh_repo();
1681        let supplier = Uuid::new_v4();
1682        let result = repo
1683            .create_bills_batch(vec![
1684                CreateBill {
1685                    supplier_id: supplier,
1686                    due_date: Utc::now() + Duration::days(30),
1687                    items: vec![CreateBillItem {
1688                        description: "A".into(),
1689                        account_code: None,
1690                        quantity: dec!(1),
1691                        unit_price: dec!(1),
1692                        tax_rate: None,
1693                        po_line_id: None,
1694                    }],
1695                    ..Default::default()
1696                },
1697                CreateBill {
1698                    supplier_id: supplier,
1699                    due_date: Utc::now() + Duration::days(30),
1700                    items: vec![CreateBillItem {
1701                        description: "B".into(),
1702                        account_code: None,
1703                        quantity: dec!(2),
1704                        unit_price: dec!(2),
1705                        tax_rate: None,
1706                        po_line_id: None,
1707                    }],
1708                    ..Default::default()
1709                },
1710            ])
1711            .expect("batch");
1712        assert_eq!(result.success_count, 2);
1713        assert_eq!(result.failure_count, 0);
1714    }
1715
1716    #[test]
1717    fn get_bill_unknown_returns_none() {
1718        let repo = fresh_repo();
1719        assert!(repo.get_bill(Uuid::new_v4()).expect("ok").is_none());
1720    }
1721}