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}