Skip to main content

stateset_core/models/
accounts_payable.rs

1//! Accounts Payable domain models
2//!
3//! Models for managing supplier bills, payment scheduling, and disbursements.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use stateset_primitives::CurrencyCode;
9use std::str::FromStr;
10use strum::{Display, EnumString};
11use uuid::Uuid;
12
13// ============================================================================
14// Core AP Types
15// ============================================================================
16
17/// A bill/invoice from a supplier.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Bill {
20    /// Unique identifier for this bill.
21    pub id: Uuid,
22    /// System-generated or supplier-provided bill reference (e.g. `"BILL-20240101-ABC123"`).
23    pub bill_number: String,
24    /// Supplier who issued this bill.
25    pub supplier_id: Uuid,
26    /// Denormalized supplier name for display.
27    pub supplier_name: Option<String>,
28    /// Purchase order this bill fulfils, if any.
29    pub purchase_order_id: Option<Uuid>,
30    /// Current payment lifecycle status.
31    pub status: BillStatus,
32    /// Date the supplier issued the bill.
33    pub bill_date: DateTime<Utc>,
34    /// Date by which payment must be made.
35    pub due_date: DateTime<Utc>,
36    /// Payment terms string (e.g. `"Net 30"`).
37    pub payment_terms: Option<String>,
38    /// Sum of line-item amounts before tax and adjustments.
39    pub subtotal: Decimal,
40    /// Total tax charged on the bill.
41    pub tax_amount: Decimal,
42    /// Freight or shipping charges billed separately.
43    pub shipping_amount: Decimal,
44    /// Any negotiated discount applied to the bill.
45    pub discount_amount: Decimal,
46    /// Final amount owed: `subtotal + tax + shipping - discount`.
47    pub total_amount: Decimal,
48    /// Amount already paid against this bill.
49    pub amount_paid: Decimal,
50    /// Remaining balance: `total_amount - amount_paid`.
51    pub amount_due: Decimal,
52    /// Currency of all monetary amounts.
53    pub currency: CurrencyCode,
54    /// Supplier's own invoice or reference number.
55    pub reference_number: Option<String>,
56    /// Free-text notes for internal use.
57    pub memo: Option<String>,
58    /// Timestamp of record creation.
59    pub created_at: DateTime<Utc>,
60    /// Timestamp of the last update.
61    pub updated_at: DateTime<Utc>,
62}
63
64/// A line item on a bill.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct BillItem {
67    pub id: Uuid,
68    pub bill_id: Uuid,
69    pub line_number: i32,
70    pub description: String,
71    pub account_code: Option<String>,
72    pub quantity: Decimal,
73    pub unit_price: Decimal,
74    pub amount: Decimal,
75    pub tax_rate: Option<Decimal>,
76    pub tax_amount: Decimal,
77    pub po_line_id: Option<Uuid>,
78    pub created_at: DateTime<Utc>,
79}
80
81/// A payment made to a supplier.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct BillPayment {
84    /// Unique identifier for this payment.
85    pub id: Uuid,
86    /// System-generated payment reference (e.g. `"APMT-20240101-ABC123"`).
87    pub payment_number: String,
88    /// Supplier receiving the payment.
89    pub supplier_id: Uuid,
90    /// Date the payment was or will be made.
91    pub payment_date: DateTime<Utc>,
92    /// Method used to disburse the funds.
93    pub payment_method: PaymentMethodAP,
94    /// Total amount disbursed.
95    pub amount: Decimal,
96    /// Currency of the payment.
97    pub currency: CurrencyCode,
98    /// External transaction or confirmation reference.
99    pub reference_number: Option<String>,
100    /// Bank account from which the payment was made.
101    pub bank_account: Option<String>,
102    /// Check number, if payment method is `Check`.
103    pub check_number: Option<String>,
104    /// Free-text notes for internal use.
105    pub memo: Option<String>,
106    /// Current processing status.
107    pub status: PaymentStatusAP,
108    /// Timestamp of record creation.
109    pub created_at: DateTime<Utc>,
110    /// Timestamp of the last update.
111    pub updated_at: DateTime<Utc>,
112}
113
114/// Allocation of a payment to specific bills.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct PaymentAllocation {
117    pub id: Uuid,
118    pub payment_id: Uuid,
119    pub bill_id: Uuid,
120    pub amount: Decimal,
121    pub created_at: DateTime<Utc>,
122}
123
124/// A scheduled payment batch.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct PaymentRun {
127    pub id: Uuid,
128    pub run_number: String,
129    pub status: PaymentRunStatus,
130    pub payment_date: DateTime<Utc>,
131    pub payment_method: PaymentMethodAP,
132    pub total_amount: Decimal,
133    pub payment_count: i32,
134    pub notes: Option<String>,
135    pub created_by: Option<String>,
136    pub approved_by: Option<String>,
137    pub approved_at: Option<DateTime<Utc>>,
138    pub processed_at: Option<DateTime<Utc>>,
139    pub created_at: DateTime<Utc>,
140    pub updated_at: DateTime<Utc>,
141}
142
143// ============================================================================
144// Enums
145// ============================================================================
146
147/// Status of a bill.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
149#[strum(serialize_all = "snake_case")]
150#[serde(rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum BillStatus {
153    /// Bill is being entered; not yet submitted for approval.
154    #[default]
155    Draft,
156    /// Bill has been submitted and is awaiting approval.
157    Pending,
158    /// Bill is approved and scheduled for payment.
159    Approved,
160    /// Bill has been partially paid; a balance remains.
161    PartiallyPaid,
162    /// Bill has been paid in full.
163    Paid,
164    /// Payment is past the due date.
165    Overdue,
166    /// Bill has been cancelled and will not be paid.
167    Cancelled,
168    /// Bill is under dispute with the supplier.
169    Disputed,
170}
171
172impl FromStr for BillStatus {
173    type Err = String;
174    fn from_str(s: &str) -> Result<Self, Self::Err> {
175        match s.trim().to_ascii_lowercase().as_str() {
176            "draft" => Ok(Self::Draft),
177            "pending" => Ok(Self::Pending),
178            "approved" => Ok(Self::Approved),
179            "partially_paid" | "partiallypaid" => Ok(Self::PartiallyPaid),
180            "paid" => Ok(Self::Paid),
181            "overdue" => Ok(Self::Overdue),
182            "cancelled" | "canceled" => Ok(Self::Cancelled),
183            "disputed" => Ok(Self::Disputed),
184            _ => Err(format!("Unknown bill status: {s}")),
185        }
186    }
187}
188
189/// AP payment method.
190#[derive(
191    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
192)]
193#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum PaymentMethodAP {
197    /// Paper check mailed or hand-delivered to the supplier.
198    #[default]
199    Check,
200    /// Automated Clearing House electronic transfer.
201    Ach,
202    /// Domestic or international wire transfer.
203    Wire,
204    /// Corporate credit or charge card payment.
205    #[strum(serialize = "credit_card", serialize = "creditcard")]
206    CreditCard,
207    /// Physical currency payment.
208    Cash,
209    /// Any payment method not covered by the other variants.
210    Other,
211}
212
213/// AP payment status.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
215#[strum(serialize_all = "snake_case")]
216#[serde(rename_all = "snake_case")]
217#[non_exhaustive]
218pub enum PaymentStatusAP {
219    /// Payment has been created but not yet submitted to the bank.
220    #[default]
221    Pending,
222    /// Payment has been submitted to the bank for processing.
223    Processed,
224    /// Bank has confirmed the funds have been debited.
225    Cleared,
226    /// Payment was cancelled before or after submission.
227    Voided,
228    /// Payment was rejected or returned by the bank.
229    Failed,
230}
231
232impl FromStr for PaymentStatusAP {
233    type Err = String;
234    fn from_str(s: &str) -> Result<Self, Self::Err> {
235        match s.trim().to_ascii_lowercase().as_str() {
236            "pending" => Ok(Self::Pending),
237            "processed" => Ok(Self::Processed),
238            "cleared" => Ok(Self::Cleared),
239            "voided" => Ok(Self::Voided),
240            "failed" => Ok(Self::Failed),
241            _ => Err(format!("Unknown payment status: {s}")),
242        }
243    }
244}
245
246/// Payment run status.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
248#[strum(serialize_all = "snake_case")]
249#[serde(rename_all = "snake_case")]
250#[non_exhaustive]
251pub enum PaymentRunStatus {
252    /// Run is being assembled; bills can still be added or removed.
253    #[default]
254    Draft,
255    /// Run has been submitted and is awaiting approval.
256    Pending,
257    /// Run is approved and queued for disbursement.
258    Approved,
259    /// Payments are actively being transmitted to the bank.
260    Processing,
261    /// All payments in the run have been submitted successfully.
262    Completed,
263    /// Run has been cancelled; no payments were made.
264    Cancelled,
265}
266
267impl FromStr for PaymentRunStatus {
268    type Err = String;
269    fn from_str(s: &str) -> Result<Self, Self::Err> {
270        match s.trim().to_ascii_lowercase().as_str() {
271            "draft" => Ok(Self::Draft),
272            "pending" => Ok(Self::Pending),
273            "approved" => Ok(Self::Approved),
274            "processing" | "in_progress" | "inprogress" => Ok(Self::Processing),
275            "completed" => Ok(Self::Completed),
276            "cancelled" | "canceled" => Ok(Self::Cancelled),
277            _ => Err(format!("Unknown payment run status: {s}")),
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::str::FromStr;
286
287    #[test]
288    fn bill_status_from_str() {
289        assert_eq!(BillStatus::from_str("partiallypaid").unwrap(), BillStatus::PartiallyPaid);
290        assert_eq!(BillStatus::from_str("canceled").unwrap(), BillStatus::Cancelled);
291        assert!(BillStatus::from_str("unknown").is_err());
292    }
293
294    #[test]
295    fn payment_method_from_str() {
296        assert_eq!(PaymentMethodAP::from_str("creditcard").unwrap(), PaymentMethodAP::CreditCard);
297        assert_eq!(PaymentMethodAP::from_str("other").unwrap(), PaymentMethodAP::Other);
298        assert!(PaymentMethodAP::from_str("wire_transfer").is_err());
299    }
300
301    #[test]
302    fn payment_status_from_str() {
303        assert_eq!(PaymentStatusAP::from_str("processed").unwrap(), PaymentStatusAP::Processed);
304        assert!(PaymentStatusAP::from_str("unknown").is_err());
305    }
306
307    #[test]
308    fn payment_run_status_from_str() {
309        assert_eq!(
310            PaymentRunStatus::from_str("in_progress").unwrap(),
311            PaymentRunStatus::Processing
312        );
313        assert_eq!(PaymentRunStatus::from_str("cancelled").unwrap(), PaymentRunStatus::Cancelled);
314        assert!(PaymentRunStatus::from_str("unknown").is_err());
315    }
316
317    mod three_way_match {
318        use super::super::*;
319        use crate::{PurchaseOrderItem, ReceiptItem, ReceiptItemStatus};
320        use rust_decimal_macros::dec;
321
322        fn po_item(id: Uuid, qty: Decimal, unit_cost: Decimal) -> PurchaseOrderItem {
323            let now = Utc::now();
324            PurchaseOrderItem {
325                id,
326                purchase_order_id: stateset_primitives::PurchaseOrderId::new(),
327                product_id: None,
328                sku: "SKU-1".into(),
329                name: "Widget".into(),
330                supplier_sku: None,
331                quantity_ordered: qty,
332                quantity_received: Decimal::ZERO,
333                unit_of_measure: None,
334                unit_cost,
335                line_total: qty * unit_cost,
336                tax_amount: Decimal::ZERO,
337                discount_amount: Decimal::ZERO,
338                expected_date: None,
339                notes: None,
340                created_at: now,
341                updated_at: now,
342            }
343        }
344
345        fn receipt_item(po_line_id: Uuid, received: Decimal) -> ReceiptItem {
346            let now = Utc::now();
347            ReceiptItem {
348                id: Uuid::new_v4(),
349                receipt_id: Uuid::new_v4(),
350                line_number: 1,
351                sku: "SKU-1".into(),
352                description: None,
353                po_line_id: Some(po_line_id),
354                expected_quantity: received,
355                received_quantity: received,
356                rejected_quantity: Decimal::ZERO,
357                unit_cost: None,
358                lot_number: None,
359                serial_numbers: None,
360                expiration_date: None,
361                status: ReceiptItemStatus::Received,
362                notes: None,
363                created_at: now,
364                updated_at: now,
365            }
366        }
367
368        fn bill_item(po_line_id: Option<Uuid>, qty: Decimal, unit_price: Decimal) -> BillItem {
369            BillItem {
370                id: Uuid::new_v4(),
371                bill_id: Uuid::new_v4(),
372                line_number: 1,
373                description: "Widget".into(),
374                account_code: None,
375                quantity: qty,
376                unit_price,
377                amount: qty * unit_price,
378                tax_rate: None,
379                tax_amount: Decimal::ZERO,
380                po_line_id,
381                created_at: Utc::now(),
382            }
383        }
384
385        #[test]
386        fn exact_match_is_matched() {
387            let line = Uuid::new_v4();
388            let result = perform_three_way_match(
389                &[po_item(line, dec!(10), dec!(5))],
390                &[receipt_item(line, dec!(10))],
391                &[bill_item(Some(line), dec!(10), dec!(5))],
392                Decimal::ZERO,
393            );
394            assert_eq!(result.match_status, MatchStatus::Matched);
395            assert_eq!(result.lines.len(), 1);
396            assert!(result.lines[0].matched);
397            assert_eq!(result.lines[0].quantity_variance, Decimal::ZERO);
398        }
399
400        #[test]
401        fn quantity_variance_within_tolerance_matches() {
402            let line = Uuid::new_v4();
403            // Billed 102 vs ordered/received 100 => 2% variance, 5% tolerance.
404            let result = perform_three_way_match(
405                &[po_item(line, dec!(100), dec!(5))],
406                &[receipt_item(line, dec!(100))],
407                &[bill_item(Some(line), dec!(102), dec!(5))],
408                dec!(5),
409            );
410            assert_eq!(result.match_status, MatchStatus::Matched);
411        }
412
413        #[test]
414        fn quantity_variance_over_tolerance_is_variance() {
415            let line = Uuid::new_v4();
416            // Billed 110 vs 100 => 10% variance, 5% tolerance.
417            let result = perform_three_way_match(
418                &[po_item(line, dec!(100), dec!(5))],
419                &[receipt_item(line, dec!(100))],
420                &[bill_item(Some(line), dec!(110), dec!(5))],
421                dec!(5),
422            );
423            assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
424            assert!(!result.lines[0].matched);
425            assert_eq!(result.lines[0].issues.len(), 2); // vs ordered and vs received
426            assert_eq!(result.lines[0].quantity_variance, dec!(10));
427        }
428
429        #[test]
430        fn price_variance_over_tolerance_is_variance() {
431            let line = Uuid::new_v4();
432            let result = perform_three_way_match(
433                &[po_item(line, dec!(10), dec!(5))],
434                &[receipt_item(line, dec!(10))],
435                &[bill_item(Some(line), dec!(10), dec!(6))],
436                dec!(5),
437            );
438            assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
439            assert_eq!(result.lines[0].price_variance, dec!(1));
440            assert!(result.lines[0].issues[0].contains("unit price"));
441        }
442
443        #[test]
444        fn missing_receipt_is_pending() {
445            let line = Uuid::new_v4();
446            let result = perform_three_way_match(
447                &[po_item(line, dec!(10), dec!(5))],
448                &[],
449                &[bill_item(Some(line), dec!(10), dec!(5))],
450                Decimal::ZERO,
451            );
452            assert_eq!(result.match_status, MatchStatus::Pending);
453            assert!(!result.lines[0].matched);
454            assert!(result.lines[0].issues.iter().any(|i| i.contains("no quantity received")));
455        }
456
457        #[test]
458        fn partial_receipt_is_variance() {
459            let line = Uuid::new_v4();
460            // Only 4 of 10 received, billed 10.
461            let result = perform_three_way_match(
462                &[po_item(line, dec!(10), dec!(5))],
463                &[receipt_item(line, dec!(4))],
464                &[bill_item(Some(line), dec!(10), dec!(5))],
465                dec!(5),
466            );
467            assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
468            assert_eq!(result.lines[0].received_quantity, dec!(4));
469            assert_eq!(result.lines[0].quantity_variance, dec!(6));
470        }
471
472        #[test]
473        fn partial_receipt_across_multiple_receipts_sums() {
474            let line = Uuid::new_v4();
475            let result = perform_three_way_match(
476                &[po_item(line, dec!(10), dec!(5))],
477                &[receipt_item(line, dec!(4)), receipt_item(line, dec!(6))],
478                &[bill_item(Some(line), dec!(10), dec!(5))],
479                Decimal::ZERO,
480            );
481            assert_eq!(result.match_status, MatchStatus::Matched);
482            assert_eq!(result.lines[0].received_quantity, dec!(10));
483        }
484
485        #[test]
486        fn unlinked_bill_line_is_variance() {
487            let line = Uuid::new_v4();
488            let result = perform_three_way_match(
489                &[po_item(line, dec!(10), dec!(5))],
490                &[receipt_item(line, dec!(10))],
491                &[bill_item(None, dec!(10), dec!(5))],
492                Decimal::ZERO,
493            );
494            assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
495            assert!(result.lines[0].issues[0].contains("not linked"));
496        }
497    }
498}
499
500// ============================================================================
501// Input Types
502// ============================================================================
503
504/// Input for creating a bill.
505#[derive(Debug, Clone, Serialize, Deserialize, Default)]
506pub struct CreateBill {
507    pub bill_number: Option<String>,
508    pub supplier_id: Uuid,
509    pub purchase_order_id: Option<Uuid>,
510    pub bill_date: Option<DateTime<Utc>>,
511    pub due_date: DateTime<Utc>,
512    pub payment_terms: Option<String>,
513    pub currency: Option<CurrencyCode>,
514    pub reference_number: Option<String>,
515    pub memo: Option<String>,
516    pub items: Vec<CreateBillItem>,
517}
518
519/// Input for creating a bill item.
520#[derive(Debug, Clone, Serialize, Deserialize, Default)]
521pub struct CreateBillItem {
522    pub description: String,
523    pub account_code: Option<String>,
524    pub quantity: Decimal,
525    pub unit_price: Decimal,
526    pub tax_rate: Option<Decimal>,
527    pub po_line_id: Option<Uuid>,
528}
529
530/// Input for updating a bill.
531#[derive(Debug, Clone, Serialize, Deserialize, Default)]
532pub struct UpdateBill {
533    pub due_date: Option<DateTime<Utc>>,
534    pub payment_terms: Option<String>,
535    pub reference_number: Option<String>,
536    pub memo: Option<String>,
537}
538
539/// Input for creating a payment.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct CreateBillPayment {
542    pub supplier_id: Uuid,
543    pub payment_date: Option<DateTime<Utc>>,
544    pub payment_method: PaymentMethodAP,
545    pub amount: Decimal,
546    pub currency: Option<CurrencyCode>,
547    pub reference_number: Option<String>,
548    pub bank_account: Option<String>,
549    pub check_number: Option<String>,
550    pub memo: Option<String>,
551    pub allocations: Vec<PaymentAllocationInput>,
552}
553
554/// Payment allocation input.
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct PaymentAllocationInput {
557    pub bill_id: Uuid,
558    pub amount: Decimal,
559}
560
561/// Input for creating a payment run.
562#[derive(Debug, Clone, Serialize, Deserialize, Default)]
563pub struct CreatePaymentRun {
564    pub payment_date: DateTime<Utc>,
565    pub payment_method: PaymentMethodAP,
566    pub bill_ids: Vec<Uuid>,
567    pub notes: Option<String>,
568    pub created_by: Option<String>,
569}
570
571/// Input for paying a bill directly.
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct PayBill {
574    pub amount: Decimal,
575    pub payment_method: PaymentMethodAP,
576    pub reference_number: Option<String>,
577    pub memo: Option<String>,
578    pub payment_date: Option<DateTime<Utc>>,
579}
580
581impl Default for PayBill {
582    fn default() -> Self {
583        Self {
584            amount: Decimal::ZERO,
585            payment_method: PaymentMethodAP::default(),
586            reference_number: None,
587            memo: None,
588            payment_date: None,
589        }
590    }
591}
592
593// ============================================================================
594// Filter Types
595// ============================================================================
596
597/// Filter for listing bills.
598#[derive(Debug, Clone, Serialize, Deserialize, Default)]
599pub struct BillFilter {
600    pub supplier_id: Option<Uuid>,
601    pub status: Option<BillStatus>,
602    pub purchase_order_id: Option<Uuid>,
603    pub overdue_only: Option<bool>,
604    pub from_date: Option<DateTime<Utc>>,
605    pub to_date: Option<DateTime<Utc>>,
606    pub min_amount: Option<Decimal>,
607    pub max_amount: Option<Decimal>,
608    pub limit: Option<u32>,
609    pub offset: Option<u32>,
610}
611
612/// Filter for listing payments.
613#[derive(Debug, Clone, Serialize, Deserialize, Default)]
614pub struct BillPaymentFilter {
615    pub supplier_id: Option<Uuid>,
616    pub status: Option<PaymentStatusAP>,
617    pub payment_method: Option<PaymentMethodAP>,
618    pub from_date: Option<DateTime<Utc>>,
619    pub to_date: Option<DateTime<Utc>>,
620    pub limit: Option<u32>,
621    pub offset: Option<u32>,
622}
623
624/// Filter for payment runs.
625#[derive(Debug, Clone, Serialize, Deserialize, Default)]
626pub struct PaymentRunFilter {
627    pub status: Option<PaymentRunStatus>,
628    pub from_date: Option<DateTime<Utc>>,
629    pub to_date: Option<DateTime<Utc>>,
630    pub limit: Option<u32>,
631    pub offset: Option<u32>,
632}
633
634// ============================================================================
635// Summary Types
636// ============================================================================
637
638/// AP aging summary.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct ApAgingSummary {
641    pub current: Decimal,
642    pub days_1_30: Decimal,
643    pub days_31_60: Decimal,
644    pub days_61_90: Decimal,
645    pub days_over_90: Decimal,
646    pub total: Decimal,
647}
648
649/// AP summary by supplier.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct SupplierApSummary {
652    pub supplier_id: Uuid,
653    pub supplier_name: Option<String>,
654    pub total_outstanding: Decimal,
655    pub total_overdue: Decimal,
656    pub bill_count: i32,
657}
658
659// ============================================================================
660// Three-Way Match (PO <-> Receipt <-> Bill)
661// ============================================================================
662
663/// Outcome of a three-way match evaluation for a bill.
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
665#[serde(tag = "status", rename_all = "snake_case")]
666#[non_exhaustive]
667pub enum MatchStatus {
668    /// The bill is not linked to a purchase order, so no match is required.
669    NotRequired,
670    /// No goods have been received yet; matching cannot complete.
671    Pending,
672    /// Every bill line matched its PO line and receipts within tolerance.
673    Matched,
674    /// One or more lines fell outside tolerance.
675    Variance {
676        /// Number of bill lines with at least one variance issue.
677        variance_line_count: usize,
678    },
679}
680
681/// Per-line comparison of ordered vs received vs billed quantities/costs.
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683pub struct ThreeWayMatchLine {
684    /// PO line the bill line references (if any).
685    pub po_line_id: Option<Uuid>,
686    /// Bill line item ID.
687    pub bill_item_id: Uuid,
688    /// Bill line description.
689    pub description: String,
690    /// Quantity ordered on the PO line (if linked).
691    pub ordered_quantity: Option<Decimal>,
692    /// Unit cost on the PO line (if linked).
693    pub ordered_unit_cost: Option<Decimal>,
694    /// Total quantity received against the PO line (across all receipts).
695    pub received_quantity: Decimal,
696    /// Quantity billed on this bill line.
697    pub billed_quantity: Decimal,
698    /// Unit price billed on this bill line.
699    pub billed_unit_cost: Decimal,
700    /// `billed_quantity - received_quantity`.
701    pub quantity_variance: Decimal,
702    /// `billed_unit_cost - ordered_unit_cost` (zero when no PO line).
703    pub price_variance: Decimal,
704    /// Whether this line matched within tolerance.
705    pub matched: bool,
706    /// Human-readable variance issues for this line.
707    pub issues: Vec<String>,
708}
709
710/// Result of [`perform_three_way_match`].
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712pub struct ThreeWayMatchResult {
713    /// Overall match outcome.
714    pub match_status: MatchStatus,
715    /// Tolerance applied, as a percentage (e.g. `5` = 5%).
716    pub tolerance_percent: Decimal,
717    /// Per-line comparison details.
718    pub lines: Vec<ThreeWayMatchLine>,
719}
720
721impl ThreeWayMatchResult {
722    /// A result for a bill with no purchase order linkage.
723    #[must_use]
724    pub const fn not_required() -> Self {
725        Self {
726            match_status: MatchStatus::NotRequired,
727            tolerance_percent: Decimal::ZERO,
728            lines: Vec::new(),
729        }
730    }
731}
732
733/// Whether `actual` is within `tolerance_percent` of `expected` (relative).
734fn within_tolerance(expected: Decimal, actual: Decimal, tolerance_percent: Decimal) -> bool {
735    let diff = (actual - expected).abs();
736    if expected.is_zero() {
737        return diff.is_zero();
738    }
739    diff * Decimal::ONE_HUNDRED <= expected.abs() * tolerance_percent
740}
741
742/// Perform a three-way match between a purchase order's lines, the receipt
743/// lines recorded against it, and the lines of a supplier bill.
744///
745/// Lines are correlated by PO line ID: each bill line's `po_line_id` is matched
746/// to a [`crate::PurchaseOrderItem`] and to the sum of received quantities of
747/// all [`crate::ReceiptItem`]s referencing that PO line. `tolerance_percent` is
748/// a relative tolerance (e.g. `dec!(5)` allows a 5% deviation) applied to both
749/// quantity and unit-cost comparisons.
750///
751/// Returns [`MatchStatus::Pending`] when nothing has been received yet,
752/// [`MatchStatus::Matched`] when all lines agree within tolerance, and
753/// [`MatchStatus::Variance`] otherwise. Callers should short-circuit to
754/// [`MatchStatus::NotRequired`] when the bill has no purchase order.
755#[must_use]
756pub fn perform_three_way_match(
757    po_items: &[crate::PurchaseOrderItem],
758    receipt_items: &[crate::ReceiptItem],
759    bill_lines: &[BillItem],
760    tolerance_percent: Decimal,
761) -> ThreeWayMatchResult {
762    let tolerance_percent = tolerance_percent.max(Decimal::ZERO);
763    let nothing_received =
764        receipt_items.iter().fold(Decimal::ZERO, |acc, r| acc + r.received_quantity).is_zero();
765
766    let mut lines = Vec::with_capacity(bill_lines.len());
767    for bill_line in bill_lines {
768        let po_item = bill_line.po_line_id.and_then(|id| po_items.iter().find(|p| p.id == id));
769        let received_quantity = bill_line.po_line_id.map_or(Decimal::ZERO, |id| {
770            receipt_items
771                .iter()
772                .filter(|r| r.po_line_id == Some(id))
773                .fold(Decimal::ZERO, |acc, r| acc + r.received_quantity)
774        });
775
776        let mut issues = Vec::new();
777        match po_item {
778            None => issues.push("bill line is not linked to a purchase order line".to_string()),
779            Some(po) => {
780                if !within_tolerance(po.quantity_ordered, bill_line.quantity, tolerance_percent) {
781                    issues.push(format!(
782                        "billed quantity {} differs from ordered quantity {} beyond tolerance",
783                        bill_line.quantity, po.quantity_ordered
784                    ));
785                }
786                if !within_tolerance(po.unit_cost, bill_line.unit_price, tolerance_percent) {
787                    issues.push(format!(
788                        "billed unit price {} differs from ordered unit cost {} beyond tolerance",
789                        bill_line.unit_price, po.unit_cost
790                    ));
791                }
792                if !within_tolerance(received_quantity, bill_line.quantity, tolerance_percent) {
793                    issues.push(if received_quantity.is_zero() {
794                        "no quantity received against this purchase order line".to_string()
795                    } else {
796                        format!(
797                            "billed quantity {} differs from received quantity {received_quantity} beyond tolerance",
798                            bill_line.quantity
799                        )
800                    });
801                }
802            }
803        }
804
805        lines.push(ThreeWayMatchLine {
806            po_line_id: bill_line.po_line_id,
807            bill_item_id: bill_line.id,
808            description: bill_line.description.clone(),
809            ordered_quantity: po_item.map(|p| p.quantity_ordered),
810            ordered_unit_cost: po_item.map(|p| p.unit_cost),
811            received_quantity,
812            billed_quantity: bill_line.quantity,
813            billed_unit_cost: bill_line.unit_price,
814            quantity_variance: bill_line.quantity - received_quantity,
815            price_variance: po_item.map_or(Decimal::ZERO, |p| bill_line.unit_price - p.unit_cost),
816            matched: issues.is_empty(),
817            issues,
818        });
819    }
820
821    let variance_line_count = lines.iter().filter(|l| !l.matched).count();
822    let match_status = if nothing_received {
823        MatchStatus::Pending
824    } else if variance_line_count == 0 {
825        MatchStatus::Matched
826    } else {
827        MatchStatus::Variance { variance_line_count }
828    };
829
830    ThreeWayMatchResult { match_status, tolerance_percent, lines }
831}
832
833// ============================================================================
834// Helper Functions
835// ============================================================================
836
837/// Generate a bill number.
838#[must_use]
839pub fn generate_bill_number() -> String {
840    let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
841    let random = &uuid::Uuid::new_v4().to_string()[..6].to_uppercase();
842    format!("BILL-{timestamp}-{random}")
843}
844
845/// Generate a payment number.
846#[must_use]
847pub fn generate_ap_payment_number() -> String {
848    let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
849    let random = &uuid::Uuid::new_v4().to_string()[..6].to_uppercase();
850    format!("APMT-{timestamp}-{random}")
851}
852
853/// Generate a payment run number.
854#[must_use]
855pub fn generate_payment_run_number() -> String {
856    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M").to_string();
857    let random = &uuid::Uuid::new_v4().to_string()[..4].to_uppercase();
858    format!("RUN-{timestamp}-{random}")
859}