1use 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 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 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 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
348fn round_ap(value: Decimal, scale: u32) -> String {
353 value.round_dp_with_strategy(scale, RoundingStrategy::MidpointAwayFromZero).to_string()
354}
355
356fn ap_date_rfc3339(dt: DateTime<Utc>) -> String {
362 dt.date_naive().and_time(NaiveTime::MIN).and_utc().to_rfc3339()
363}
364
365fn 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 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 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 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 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 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 with_immediate_transaction(&self.pool, |tx| {
878 let to_rusqlite =
879 |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
880
881 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 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 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 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 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 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 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 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 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)); 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 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 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 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 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 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}