Skip to main content

stateset_embedded/
accounts_payable.rs

1//! Accounts Payable operations
2//!
3//! Comprehensive AP management supporting:
4//! - Supplier bill entry and tracking
5//! - Payment scheduling and processing
6//! - Payment run (batch payment) management
7//! - Aging analysis and reports
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use stateset_embedded::{Commerce, CreateBill, CreateBillItem, PaymentMethodAP};
13//! use rust_decimal_macros::dec;
14//! use chrono::{Utc, Duration};
15//! use uuid::Uuid;
16//!
17//! let commerce = Commerce::new("./store.db")?;
18//!
19//! // Create a bill from a supplier
20//! let bill = commerce.accounts_payable().create_bill(CreateBill {
21//!     supplier_id: Uuid::new_v4(),
22//!     due_date: Utc::now() + Duration::days(30),
23//!     items: vec![CreateBillItem {
24//!         description: "Office supplies".into(),
25//!         quantity: dec!(1),
26//!         unit_price: dec!(150.00),
27//!         ..Default::default()
28//!     }],
29//!     ..Default::default()
30//! })?;
31//!
32//! println!("Created bill {}", bill.bill_number);
33//! # Ok::<(), stateset_embedded::CommerceError>(())
34//! ```
35
36use rust_decimal::Decimal;
37use stateset_core::{
38    ApAgingSummary, BatchResult, Bill, BillFilter, BillItem, BillPayment, BillPaymentFilter,
39    BillStatus, CreateBill, CreateBillItem, CreateBillPayment, CreatePaymentRun, PaymentAllocation,
40    PaymentRun, PaymentRunFilter, Result, SupplierApSummary, UpdateBill,
41};
42use stateset_db::Database;
43use std::sync::Arc;
44use uuid::Uuid;
45
46#[cfg(feature = "events")]
47use crate::events::EventSystem;
48#[cfg(feature = "events")]
49use chrono::Utc;
50#[cfg(feature = "events")]
51use stateset_core::CommerceEvent;
52
53/// Accounts Payable management interface.
54pub struct AccountsPayable {
55    db: Arc<dyn Database>,
56    #[cfg(feature = "events")]
57    event_system: Arc<EventSystem>,
58}
59
60impl std::fmt::Debug for AccountsPayable {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("AccountsPayable").finish_non_exhaustive()
63    }
64}
65
66impl AccountsPayable {
67    #[cfg(feature = "events")]
68    pub(crate) fn new(db: Arc<dyn Database>, event_system: Arc<EventSystem>) -> Self {
69        Self { db, event_system }
70    }
71
72    #[cfg(not(feature = "events"))]
73    pub(crate) fn new(db: Arc<dyn Database>) -> Self {
74        Self { db }
75    }
76
77    #[cfg(feature = "events")]
78    fn emit(&self, event: CommerceEvent) {
79        self.event_system.emit(event);
80    }
81
82    // ========================================================================
83    // Bill Operations
84    // ========================================================================
85
86    /// Create a new bill (supplier invoice).
87    ///
88    /// # Example
89    ///
90    /// ```rust,ignore
91    /// use stateset_embedded::{Commerce, CreateBill, CreateBillItem};
92    /// use rust_decimal_macros::dec;
93    /// use chrono::{Utc, Duration};
94    /// use uuid::Uuid;
95    ///
96    /// let commerce = Commerce::new(":memory:")?;
97    ///
98    /// let bill = commerce.accounts_payable().create_bill(CreateBill {
99    ///     supplier_id: Uuid::new_v4(),
100    ///     due_date: Utc::now() + Duration::days(30),
101    ///     payment_terms: Some("Net 30".into()),
102    ///     reference_number: Some("INV-12345".into()),
103    ///     items: vec![
104    ///         CreateBillItem {
105    ///             description: "Raw materials".into(),
106    ///             quantity: dec!(100),
107    ///             unit_price: dec!(10.00),
108    ///             account_code: Some("5010".into()),
109    ///             ..Default::default()
110    ///         },
111    ///         CreateBillItem {
112    ///             description: "Shipping".into(),
113    ///             quantity: dec!(1),
114    ///             unit_price: dec!(50.00),
115    ///             account_code: Some("5020".into()),
116    ///             ..Default::default()
117    ///         },
118    ///     ],
119    ///     ..Default::default()
120    /// })?;
121    /// # Ok::<(), stateset_embedded::CommerceError>(())
122    /// ```
123    pub fn create_bill(&self, input: CreateBill) -> Result<Bill> {
124        self.db.accounts_payable().create_bill(input)
125    }
126
127    /// Get a bill by ID.
128    pub fn get_bill(&self, id: Uuid) -> Result<Option<Bill>> {
129        self.db.accounts_payable().get_bill(id)
130    }
131
132    /// Get a bill by bill number.
133    pub fn get_bill_by_number(&self, number: &str) -> Result<Option<Bill>> {
134        self.db.accounts_payable().get_bill_by_number(number)
135    }
136
137    /// Update a bill.
138    pub fn update_bill(&self, id: Uuid, input: UpdateBill) -> Result<Bill> {
139        self.db.accounts_payable().update_bill(id, input)
140    }
141
142    /// List bills with optional filtering.
143    ///
144    /// # Example
145    ///
146    /// ```rust,ignore
147    /// use stateset_embedded::{Commerce, BillFilter, BillStatus};
148    /// use uuid::Uuid;
149    ///
150    /// let commerce = Commerce::new(":memory:")?;
151    ///
152    /// // Get all overdue bills for a supplier
153    /// let bills = commerce.accounts_payable().list_bills(BillFilter {
154    ///     supplier_id: Some(Uuid::new_v4()),
155    ///     overdue_only: Some(true),
156    ///     limit: Some(50),
157    ///     ..Default::default()
158    /// })?;
159    /// # Ok::<(), stateset_embedded::CommerceError>(())
160    /// ```
161    pub fn list_bills(&self, filter: BillFilter) -> Result<Vec<Bill>> {
162        self.db.accounts_payable().list_bills(filter)
163    }
164
165    /// Delete a bill (only if draft).
166    pub fn delete_bill(&self, id: Uuid) -> Result<()> {
167        self.db.accounts_payable().delete_bill(id)
168    }
169
170    /// Approve a bill for payment.
171    ///
172    /// Transitions bill from draft/pending to approved status.
173    pub fn approve_bill(&self, id: Uuid) -> Result<Bill> {
174        self.db.accounts_payable().approve_bill(id)
175    }
176
177    /// Cancel a bill.
178    pub fn cancel_bill(&self, id: Uuid) -> Result<Bill> {
179        self.db.accounts_payable().cancel_bill(id)
180    }
181
182    /// Mark a bill as disputed.
183    pub fn dispute_bill(&self, id: Uuid) -> Result<Bill> {
184        self.db.accounts_payable().dispute_bill(id)
185    }
186
187    /// Get all line items for a bill.
188    pub fn get_bill_items(&self, bill_id: Uuid) -> Result<Vec<BillItem>> {
189        self.db.accounts_payable().get_bill_items(bill_id)
190    }
191
192    /// Add an item to a bill.
193    pub fn add_bill_item(&self, bill_id: Uuid, item: CreateBillItem) -> Result<BillItem> {
194        self.db.accounts_payable().add_bill_item(bill_id, item)
195    }
196
197    /// Remove an item from a bill.
198    pub fn remove_bill_item(&self, item_id: Uuid) -> Result<()> {
199        self.db.accounts_payable().remove_bill_item(item_id)
200    }
201
202    /// Count bills matching the filter.
203    pub fn count_bills(&self, filter: BillFilter) -> Result<u64> {
204        self.db.accounts_payable().count_bills(filter)
205    }
206
207    /// Get all overdue bills.
208    ///
209    /// Returns bills past their due date that haven't been paid.
210    pub fn get_overdue_bills(&self) -> Result<Vec<Bill>> {
211        self.db.accounts_payable().get_overdue_bills()
212    }
213
214    /// Get bills due soon (within specified days).
215    ///
216    /// # Example
217    ///
218    /// ```rust,ignore
219    /// use stateset_embedded::Commerce;
220    ///
221    /// let commerce = Commerce::new(":memory:")?;
222    ///
223    /// // Get bills due in the next 7 days
224    /// let bills = commerce.accounts_payable().get_bills_due_soon(7)?;
225    /// for bill in bills {
226    ///     println!("Bill {} due on {}: ${}", bill.bill_number, bill.due_date, bill.amount_due);
227    /// }
228    /// # Ok::<(), stateset_embedded::CommerceError>(())
229    /// ```
230    pub fn get_bills_due_soon(&self, days: i32) -> Result<Vec<Bill>> {
231        self.db.accounts_payable().get_bills_due_soon(days)
232    }
233
234    // ========================================================================
235    // Three-Way Match
236    // ========================================================================
237
238    /// Perform a three-way match (purchase order vs receipts vs bill) for a bill.
239    ///
240    /// Loads the bill's linked purchase order lines and every non-cancelled
241    /// receipt recorded against that PO, then compares ordered quantity/cost,
242    /// received quantity, and billed quantity/cost line by line. The result is
243    /// computed on read and never persisted.
244    ///
245    /// `tolerance_percent` is a relative tolerance (e.g. `dec!(5)` allows 5%
246    /// deviation); `None` means exact matching.
247    ///
248    /// Returns [`stateset_core::MatchStatus::NotRequired`] when the bill has no
249    /// purchase order, and an error if the bill does not exist.
250    ///
251    /// # Example
252    ///
253    /// ```rust,ignore
254    /// use stateset_embedded::Commerce;
255    /// use rust_decimal_macros::dec;
256    /// use uuid::Uuid;
257    ///
258    /// let commerce = Commerce::new(":memory:")?;
259    /// let result = commerce.accounts_payable().three_way_match(Uuid::new_v4(), Some(dec!(5)))?;
260    /// println!("match status: {:?}", result.match_status);
261    /// # Ok::<(), stateset_embedded::CommerceError>(())
262    /// ```
263    pub fn three_way_match(
264        &self,
265        bill_id: Uuid,
266        tolerance_percent: Option<Decimal>,
267    ) -> Result<stateset_core::ThreeWayMatchResult> {
268        let bill = self
269            .db
270            .accounts_payable()
271            .get_bill(bill_id)?
272            .ok_or(stateset_core::CommerceError::NotFound)?;
273
274        let Some(po_id) = bill.purchase_order_id else {
275            return Ok(stateset_core::ThreeWayMatchResult::not_required());
276        };
277
278        let bill_lines = self.db.accounts_payable().get_bill_items(bill_id)?;
279        let po_items = self.db.purchase_orders().get_items(po_id.into())?;
280
281        let receipts = self.db.receiving().list_receipts(stateset_core::ReceiptFilter {
282            reference_id: Some(po_id),
283            ..Default::default()
284        })?;
285        let mut receipt_items = Vec::new();
286        for receipt in receipts {
287            if receipt.status == stateset_core::ReceiptStatus::Cancelled {
288                continue;
289            }
290            receipt_items.extend(self.db.receiving().get_receipt_items(receipt.id)?);
291        }
292
293        let result = stateset_core::perform_three_way_match(
294            &po_items,
295            &receipt_items,
296            &bill_lines,
297            tolerance_percent.unwrap_or(Decimal::ZERO),
298        );
299        #[cfg(feature = "events")]
300        if let stateset_core::MatchStatus::Variance { variance_line_count } = result.match_status {
301            self.emit(CommerceEvent::ThreeWayMatchVarianceDetected {
302                bill_id,
303                purchase_order_id: po_id,
304                variance_line_count,
305                tolerance_percent: result.tolerance_percent,
306                timestamp: Utc::now(),
307            });
308        }
309        Ok(result)
310    }
311
312    // ========================================================================
313    // Payment Operations
314    // ========================================================================
315
316    /// Create a payment to a supplier.
317    ///
318    /// # Example
319    ///
320    /// ```rust,ignore
321    /// use stateset_embedded::{Commerce, CreateBillPayment, PaymentMethodAP, PaymentAllocationInput};
322    /// use rust_decimal_macros::dec;
323    /// use uuid::Uuid;
324    ///
325    /// let commerce = Commerce::new(":memory:")?;
326    ///
327    /// let payment = commerce.accounts_payable().create_payment(CreateBillPayment {
328    ///     supplier_id: Uuid::new_v4(),
329    ///     payment_method: PaymentMethodAP::Check,
330    ///     amount: dec!(1000.00),
331    ///     check_number: Some("10234".into()),
332    ///     allocations: vec![
333    ///         PaymentAllocationInput {
334    ///             bill_id: Uuid::new_v4(), // bill ID
335    ///             amount: dec!(500.00),
336    ///         },
337    ///         PaymentAllocationInput {
338    ///             bill_id: Uuid::new_v4(), // another bill ID
339    ///             amount: dec!(500.00),
340    ///         },
341    ///     ],
342    ///     ..Default::default()
343    /// })?;
344    /// # Ok::<(), stateset_embedded::CommerceError>(())
345    /// ```
346    pub fn create_payment(&self, input: CreateBillPayment) -> Result<BillPayment> {
347        self.db.accounts_payable().create_payment(input)
348    }
349
350    /// Get a payment by ID.
351    pub fn get_payment(&self, id: Uuid) -> Result<Option<BillPayment>> {
352        self.db.accounts_payable().get_payment(id)
353    }
354
355    /// Get a payment by payment number.
356    pub fn get_payment_by_number(&self, number: &str) -> Result<Option<BillPayment>> {
357        self.db.accounts_payable().get_payment_by_number(number)
358    }
359
360    /// List payments with optional filtering.
361    pub fn list_payments(&self, filter: BillPaymentFilter) -> Result<Vec<BillPayment>> {
362        self.db.accounts_payable().list_payments(filter)
363    }
364
365    /// Void a payment.
366    ///
367    /// Reverses the effect of the payment on associated bills.
368    pub fn void_payment(&self, id: Uuid) -> Result<BillPayment> {
369        self.db.accounts_payable().void_payment(id)
370    }
371
372    /// Mark a payment as cleared (e.g., check cleared the bank).
373    pub fn clear_payment(&self, id: Uuid) -> Result<BillPayment> {
374        self.db.accounts_payable().clear_payment(id)
375    }
376
377    /// Get allocations for a payment.
378    pub fn get_payment_allocations(&self, payment_id: Uuid) -> Result<Vec<PaymentAllocation>> {
379        self.db.accounts_payable().get_payment_allocations(payment_id)
380    }
381
382    /// Get all payments for a specific bill.
383    pub fn get_payments_for_bill(&self, bill_id: Uuid) -> Result<Vec<BillPayment>> {
384        self.db.accounts_payable().get_payments_for_bill(bill_id)
385    }
386
387    /// Count payments matching the filter.
388    pub fn count_payments(&self, filter: BillPaymentFilter) -> Result<u64> {
389        self.db.accounts_payable().count_payments(filter)
390    }
391
392    /// Pay a bill directly with a single payment.
393    ///
394    /// Convenience method that creates a payment and allocates it fully to the specified bill.
395    ///
396    /// # Example
397    ///
398    /// ```rust,ignore
399    /// use stateset_embedded::{Commerce, PayBill, PaymentMethodAP};
400    /// use rust_decimal_macros::dec;
401    /// use uuid::Uuid;
402    ///
403    /// let commerce = Commerce::new(":memory:")?;
404    ///
405    /// // Pay a bill
406    /// let payment = commerce.accounts_payable().pay_bill(
407    ///     Uuid::new_v4(), // bill_id
408    ///     stateset_core::PayBill {
409    ///         amount: dec!(500.00),
410    ///         payment_method: PaymentMethodAP::Check,
411    ///         ..Default::default()
412    ///     },
413    /// )?;
414    /// # Ok::<(), stateset_embedded::CommerceError>(())
415    /// ```
416    pub fn pay_bill(&self, bill_id: Uuid, input: stateset_core::PayBill) -> Result<Bill> {
417        // Get the bill to find the supplier
418        let bill = self
419            .db
420            .accounts_payable()
421            .get_bill(bill_id)?
422            .ok_or(stateset_core::CommerceError::NotFound)?;
423
424        if input.amount <= Decimal::ZERO {
425            return Err(stateset_core::CommerceError::ValidationError(
426                "Payment amount must be greater than zero".to_string(),
427            ));
428        }
429
430        if !matches!(
431            bill.status,
432            BillStatus::Approved | BillStatus::PartiallyPaid | BillStatus::Overdue
433        ) {
434            return Err(stateset_core::CommerceError::ValidationError(
435                "Bill is not in a payable status".to_string(),
436            ));
437        }
438
439        if input.amount > bill.amount_due {
440            return Err(stateset_core::CommerceError::ValidationError(
441                "Payment amount exceeds bill amount due".to_string(),
442            ));
443        }
444
445        let mut fallback_bill = bill.clone();
446        fallback_bill.amount_paid += input.amount;
447        fallback_bill.amount_due -= input.amount;
448        fallback_bill.status = if fallback_bill.amount_due <= Decimal::ZERO {
449            BillStatus::Paid
450        } else {
451            BillStatus::PartiallyPaid
452        };
453
454        // Create a payment for this bill
455        let payment_input = CreateBillPayment {
456            supplier_id: bill.supplier_id,
457            payment_date: input.payment_date,
458            payment_method: input.payment_method,
459            amount: input.amount,
460            currency: Some(bill.currency),
461            reference_number: input.reference_number,
462            bank_account: None,
463            check_number: None,
464            memo: input.memo,
465            allocations: vec![stateset_core::PaymentAllocationInput {
466                bill_id,
467                amount: input.amount,
468            }],
469        };
470
471        self.db.accounts_payable().create_payment(payment_input)?;
472
473        // Return the updated bill; fallback to a deterministic in-memory update if re-read fails.
474        Ok(self.db.accounts_payable().get_bill(bill_id)?.unwrap_or(fallback_bill))
475    }
476
477    // ========================================================================
478    // Payment Run Operations
479    // ========================================================================
480
481    /// Create a payment run (batch payment).
482    ///
483    /// Groups multiple bills together for a scheduled payment batch.
484    ///
485    /// # Example
486    ///
487    /// ```rust,ignore
488    /// use stateset_embedded::{Commerce, CreatePaymentRun, PaymentMethodAP};
489    /// use chrono::{Utc, Duration};
490    /// use uuid::Uuid;
491    ///
492    /// let commerce = Commerce::new(":memory:")?;
493    ///
494    /// let run = commerce.accounts_payable().create_payment_run(CreatePaymentRun {
495    ///     payment_date: Utc::now() + Duration::days(7),
496    ///     payment_method: PaymentMethodAP::Ach,
497    ///     bill_ids: vec![
498    ///         Uuid::new_v4(), // approved bill 1
499    ///         Uuid::new_v4(), // approved bill 2
500    ///     ],
501    ///     created_by: Some("finance_user".into()),
502    ///     notes: Some("Weekly ACH run".into()),
503    /// })?;
504    ///
505    /// println!("Created payment run {}", run.run_number);
506    /// # Ok::<(), stateset_embedded::CommerceError>(())
507    /// ```
508    pub fn create_payment_run(&self, input: CreatePaymentRun) -> Result<PaymentRun> {
509        self.db.accounts_payable().create_payment_run(input)
510    }
511
512    /// Get a payment run by ID.
513    pub fn get_payment_run(&self, id: Uuid) -> Result<Option<PaymentRun>> {
514        self.db.accounts_payable().get_payment_run(id)
515    }
516
517    /// List payment runs with optional filtering.
518    pub fn list_payment_runs(&self, filter: PaymentRunFilter) -> Result<Vec<PaymentRun>> {
519        self.db.accounts_payable().list_payment_runs(filter)
520    }
521
522    /// Approve a payment run.
523    ///
524    /// Requires approval before processing.
525    pub fn approve_payment_run(&self, id: Uuid, approved_by: &str) -> Result<PaymentRun> {
526        self.db.accounts_payable().approve_payment_run(id, approved_by)
527    }
528
529    /// Process a payment run.
530    ///
531    /// Creates individual payments for each bill and updates their status.
532    pub fn process_payment_run(&self, id: Uuid) -> Result<PaymentRun> {
533        self.db.accounts_payable().process_payment_run(id)
534    }
535
536    /// Cancel a payment run.
537    pub fn cancel_payment_run(&self, id: Uuid) -> Result<PaymentRun> {
538        self.db.accounts_payable().cancel_payment_run(id)
539    }
540
541    /// Get bills included in a payment run.
542    pub fn get_payment_run_bills(&self, run_id: Uuid) -> Result<Vec<Bill>> {
543        self.db.accounts_payable().get_payment_run_bills(run_id)
544    }
545
546    // ========================================================================
547    // Analytics & Reports
548    // ========================================================================
549
550    /// Get AP aging summary.
551    ///
552    /// Returns outstanding amounts bucketed by age (current, 1-30, 31-60, etc.).
553    ///
554    /// # Example
555    ///
556    /// ```rust,ignore
557    /// use stateset_embedded::Commerce;
558    ///
559    /// let commerce = Commerce::new(":memory:")?;
560    ///
561    /// let aging = commerce.accounts_payable().get_aging_summary()?;
562    /// println!("AP Aging Summary:");
563    /// println!("  Current: ${}", aging.current);
564    /// println!("  1-30 days: ${}", aging.days_1_30);
565    /// println!("  31-60 days: ${}", aging.days_31_60);
566    /// println!("  61-90 days: ${}", aging.days_61_90);
567    /// println!("  Over 90 days: ${}", aging.days_over_90);
568    /// println!("  Total: ${}", aging.total);
569    /// # Ok::<(), stateset_embedded::CommerceError>(())
570    /// ```
571    pub fn get_aging_summary(&self) -> Result<ApAgingSummary> {
572        self.db.accounts_payable().get_aging_summary()
573    }
574
575    /// Get AP summary for a specific supplier.
576    pub fn get_supplier_summary(&self, supplier_id: Uuid) -> Result<Option<SupplierApSummary>> {
577        self.db.accounts_payable().get_supplier_summary(supplier_id)
578    }
579
580    /// Get total AP outstanding across all suppliers.
581    pub fn get_total_outstanding(&self) -> Result<Decimal> {
582        self.db.accounts_payable().get_total_outstanding()
583    }
584
585    // ========================================================================
586    // Batch Operations
587    // ========================================================================
588
589    /// Create multiple bills in a batch.
590    pub fn create_bills_batch(&self, inputs: Vec<CreateBill>) -> Result<BatchResult<Bill>> {
591        self.db.accounts_payable().create_bills_batch(inputs)
592    }
593
594    /// Get multiple bills by ID.
595    pub fn get_bills_batch(&self, ids: Vec<Uuid>) -> Result<Vec<Bill>> {
596        self.db.accounts_payable().get_bills_batch(ids)
597    }
598}