use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::CurrencyCode;
use std::str::FromStr;
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bill {
pub id: Uuid,
pub bill_number: String,
pub supplier_id: Uuid,
pub supplier_name: Option<String>,
pub purchase_order_id: Option<Uuid>,
pub status: BillStatus,
pub bill_date: DateTime<Utc>,
pub due_date: DateTime<Utc>,
pub payment_terms: Option<String>,
pub subtotal: Decimal,
pub tax_amount: Decimal,
pub shipping_amount: Decimal,
pub discount_amount: Decimal,
pub total_amount: Decimal,
pub amount_paid: Decimal,
pub amount_due: Decimal,
pub currency: CurrencyCode,
pub reference_number: Option<String>,
pub memo: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillItem {
pub id: Uuid,
pub bill_id: Uuid,
pub line_number: i32,
pub description: String,
pub account_code: Option<String>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub amount: Decimal,
pub tax_rate: Option<Decimal>,
pub tax_amount: Decimal,
pub po_line_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillPayment {
pub id: Uuid,
pub payment_number: String,
pub supplier_id: Uuid,
pub payment_date: DateTime<Utc>,
pub payment_method: PaymentMethodAP,
pub amount: Decimal,
pub currency: CurrencyCode,
pub reference_number: Option<String>,
pub bank_account: Option<String>,
pub check_number: Option<String>,
pub memo: Option<String>,
pub status: PaymentStatusAP,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentAllocation {
pub id: Uuid,
pub payment_id: Uuid,
pub bill_id: Uuid,
pub amount: Decimal,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentRun {
pub id: Uuid,
pub run_number: String,
pub status: PaymentRunStatus,
pub payment_date: DateTime<Utc>,
pub payment_method: PaymentMethodAP,
pub total_amount: Decimal,
pub payment_count: i32,
pub notes: Option<String>,
pub created_by: Option<String>,
pub approved_by: Option<String>,
pub approved_at: Option<DateTime<Utc>>,
pub processed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BillStatus {
#[default]
Draft,
Pending,
Approved,
PartiallyPaid,
Paid,
Overdue,
Cancelled,
Disputed,
}
impl FromStr for BillStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"draft" => Ok(Self::Draft),
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"partially_paid" | "partiallypaid" => Ok(Self::PartiallyPaid),
"paid" => Ok(Self::Paid),
"overdue" => Ok(Self::Overdue),
"cancelled" | "canceled" => Ok(Self::Cancelled),
"disputed" => Ok(Self::Disputed),
_ => Err(format!("Unknown bill status: {s}")),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PaymentMethodAP {
#[default]
Check,
Ach,
Wire,
#[strum(serialize = "credit_card", serialize = "creditcard")]
CreditCard,
Cash,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PaymentStatusAP {
#[default]
Pending,
Processed,
Cleared,
Voided,
Failed,
}
impl FromStr for PaymentStatusAP {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"pending" => Ok(Self::Pending),
"processed" => Ok(Self::Processed),
"cleared" => Ok(Self::Cleared),
"voided" => Ok(Self::Voided),
"failed" => Ok(Self::Failed),
_ => Err(format!("Unknown payment status: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PaymentRunStatus {
#[default]
Draft,
Pending,
Approved,
Processing,
Completed,
Cancelled,
}
impl FromStr for PaymentRunStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"draft" => Ok(Self::Draft),
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"processing" | "in_progress" | "inprogress" => Ok(Self::Processing),
"completed" => Ok(Self::Completed),
"cancelled" | "canceled" => Ok(Self::Cancelled),
_ => Err(format!("Unknown payment run status: {s}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn bill_status_from_str() {
assert_eq!(BillStatus::from_str("partiallypaid").unwrap(), BillStatus::PartiallyPaid);
assert_eq!(BillStatus::from_str("canceled").unwrap(), BillStatus::Cancelled);
assert!(BillStatus::from_str("unknown").is_err());
}
#[test]
fn payment_method_from_str() {
assert_eq!(PaymentMethodAP::from_str("creditcard").unwrap(), PaymentMethodAP::CreditCard);
assert_eq!(PaymentMethodAP::from_str("other").unwrap(), PaymentMethodAP::Other);
assert!(PaymentMethodAP::from_str("wire_transfer").is_err());
}
#[test]
fn payment_status_from_str() {
assert_eq!(PaymentStatusAP::from_str("processed").unwrap(), PaymentStatusAP::Processed);
assert!(PaymentStatusAP::from_str("unknown").is_err());
}
#[test]
fn payment_run_status_from_str() {
assert_eq!(
PaymentRunStatus::from_str("in_progress").unwrap(),
PaymentRunStatus::Processing
);
assert_eq!(PaymentRunStatus::from_str("cancelled").unwrap(), PaymentRunStatus::Cancelled);
assert!(PaymentRunStatus::from_str("unknown").is_err());
}
mod three_way_match {
use super::super::*;
use crate::{PurchaseOrderItem, ReceiptItem, ReceiptItemStatus};
use rust_decimal_macros::dec;
fn po_item(id: Uuid, qty: Decimal, unit_cost: Decimal) -> PurchaseOrderItem {
let now = Utc::now();
PurchaseOrderItem {
id,
purchase_order_id: stateset_primitives::PurchaseOrderId::new(),
product_id: None,
sku: "SKU-1".into(),
name: "Widget".into(),
supplier_sku: None,
quantity_ordered: qty,
quantity_received: Decimal::ZERO,
unit_of_measure: None,
unit_cost,
line_total: qty * unit_cost,
tax_amount: Decimal::ZERO,
discount_amount: Decimal::ZERO,
expected_date: None,
notes: None,
created_at: now,
updated_at: now,
}
}
fn receipt_item(po_line_id: Uuid, received: Decimal) -> ReceiptItem {
let now = Utc::now();
ReceiptItem {
id: Uuid::new_v4(),
receipt_id: Uuid::new_v4(),
line_number: 1,
sku: "SKU-1".into(),
description: None,
po_line_id: Some(po_line_id),
expected_quantity: received,
received_quantity: received,
rejected_quantity: Decimal::ZERO,
unit_cost: None,
lot_number: None,
serial_numbers: None,
expiration_date: None,
status: ReceiptItemStatus::Received,
notes: None,
created_at: now,
updated_at: now,
}
}
fn bill_item(po_line_id: Option<Uuid>, qty: Decimal, unit_price: Decimal) -> BillItem {
BillItem {
id: Uuid::new_v4(),
bill_id: Uuid::new_v4(),
line_number: 1,
description: "Widget".into(),
account_code: None,
quantity: qty,
unit_price,
amount: qty * unit_price,
tax_rate: None,
tax_amount: Decimal::ZERO,
po_line_id,
created_at: Utc::now(),
}
}
#[test]
fn exact_match_is_matched() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(10), dec!(5))],
&[receipt_item(line, dec!(10))],
&[bill_item(Some(line), dec!(10), dec!(5))],
Decimal::ZERO,
);
assert_eq!(result.match_status, MatchStatus::Matched);
assert_eq!(result.lines.len(), 1);
assert!(result.lines[0].matched);
assert_eq!(result.lines[0].quantity_variance, Decimal::ZERO);
}
#[test]
fn quantity_variance_within_tolerance_matches() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(100), dec!(5))],
&[receipt_item(line, dec!(100))],
&[bill_item(Some(line), dec!(102), dec!(5))],
dec!(5),
);
assert_eq!(result.match_status, MatchStatus::Matched);
}
#[test]
fn quantity_variance_over_tolerance_is_variance() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(100), dec!(5))],
&[receipt_item(line, dec!(100))],
&[bill_item(Some(line), dec!(110), dec!(5))],
dec!(5),
);
assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
assert!(!result.lines[0].matched);
assert_eq!(result.lines[0].issues.len(), 2); assert_eq!(result.lines[0].quantity_variance, dec!(10));
}
#[test]
fn price_variance_over_tolerance_is_variance() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(10), dec!(5))],
&[receipt_item(line, dec!(10))],
&[bill_item(Some(line), dec!(10), dec!(6))],
dec!(5),
);
assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
assert_eq!(result.lines[0].price_variance, dec!(1));
assert!(result.lines[0].issues[0].contains("unit price"));
}
#[test]
fn missing_receipt_is_pending() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(10), dec!(5))],
&[],
&[bill_item(Some(line), dec!(10), dec!(5))],
Decimal::ZERO,
);
assert_eq!(result.match_status, MatchStatus::Pending);
assert!(!result.lines[0].matched);
assert!(result.lines[0].issues.iter().any(|i| i.contains("no quantity received")));
}
#[test]
fn partial_receipt_is_variance() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(10), dec!(5))],
&[receipt_item(line, dec!(4))],
&[bill_item(Some(line), dec!(10), dec!(5))],
dec!(5),
);
assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
assert_eq!(result.lines[0].received_quantity, dec!(4));
assert_eq!(result.lines[0].quantity_variance, dec!(6));
}
#[test]
fn partial_receipt_across_multiple_receipts_sums() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(10), dec!(5))],
&[receipt_item(line, dec!(4)), receipt_item(line, dec!(6))],
&[bill_item(Some(line), dec!(10), dec!(5))],
Decimal::ZERO,
);
assert_eq!(result.match_status, MatchStatus::Matched);
assert_eq!(result.lines[0].received_quantity, dec!(10));
}
#[test]
fn unlinked_bill_line_is_variance() {
let line = Uuid::new_v4();
let result = perform_three_way_match(
&[po_item(line, dec!(10), dec!(5))],
&[receipt_item(line, dec!(10))],
&[bill_item(None, dec!(10), dec!(5))],
Decimal::ZERO,
);
assert_eq!(result.match_status, MatchStatus::Variance { variance_line_count: 1 });
assert!(result.lines[0].issues[0].contains("not linked"));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateBill {
pub bill_number: Option<String>,
pub supplier_id: Uuid,
pub purchase_order_id: Option<Uuid>,
pub bill_date: Option<DateTime<Utc>>,
pub due_date: DateTime<Utc>,
pub payment_terms: Option<String>,
pub currency: Option<CurrencyCode>,
pub reference_number: Option<String>,
pub memo: Option<String>,
pub items: Vec<CreateBillItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateBillItem {
pub description: String,
pub account_code: Option<String>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub tax_rate: Option<Decimal>,
pub po_line_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateBill {
pub due_date: Option<DateTime<Utc>>,
pub payment_terms: Option<String>,
pub reference_number: Option<String>,
pub memo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBillPayment {
pub supplier_id: Uuid,
pub payment_date: Option<DateTime<Utc>>,
pub payment_method: PaymentMethodAP,
pub amount: Decimal,
pub currency: Option<CurrencyCode>,
pub reference_number: Option<String>,
pub bank_account: Option<String>,
pub check_number: Option<String>,
pub memo: Option<String>,
pub allocations: Vec<PaymentAllocationInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentAllocationInput {
pub bill_id: Uuid,
pub amount: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreatePaymentRun {
pub payment_date: DateTime<Utc>,
pub payment_method: PaymentMethodAP,
pub bill_ids: Vec<Uuid>,
pub notes: Option<String>,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayBill {
pub amount: Decimal,
pub payment_method: PaymentMethodAP,
pub reference_number: Option<String>,
pub memo: Option<String>,
pub payment_date: Option<DateTime<Utc>>,
}
impl Default for PayBill {
fn default() -> Self {
Self {
amount: Decimal::ZERO,
payment_method: PaymentMethodAP::default(),
reference_number: None,
memo: None,
payment_date: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BillFilter {
pub supplier_id: Option<Uuid>,
pub status: Option<BillStatus>,
pub purchase_order_id: Option<Uuid>,
pub overdue_only: Option<bool>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub min_amount: Option<Decimal>,
pub max_amount: Option<Decimal>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BillPaymentFilter {
pub supplier_id: Option<Uuid>,
pub status: Option<PaymentStatusAP>,
pub payment_method: Option<PaymentMethodAP>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PaymentRunFilter {
pub status: Option<PaymentRunStatus>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApAgingSummary {
pub current: Decimal,
pub days_1_30: Decimal,
pub days_31_60: Decimal,
pub days_61_90: Decimal,
pub days_over_90: Decimal,
pub total: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupplierApSummary {
pub supplier_id: Uuid,
pub supplier_name: Option<String>,
pub total_outstanding: Decimal,
pub total_overdue: Decimal,
pub bill_count: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
#[non_exhaustive]
pub enum MatchStatus {
NotRequired,
Pending,
Matched,
Variance {
variance_line_count: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreeWayMatchLine {
pub po_line_id: Option<Uuid>,
pub bill_item_id: Uuid,
pub description: String,
pub ordered_quantity: Option<Decimal>,
pub ordered_unit_cost: Option<Decimal>,
pub received_quantity: Decimal,
pub billed_quantity: Decimal,
pub billed_unit_cost: Decimal,
pub quantity_variance: Decimal,
pub price_variance: Decimal,
pub matched: bool,
pub issues: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreeWayMatchResult {
pub match_status: MatchStatus,
pub tolerance_percent: Decimal,
pub lines: Vec<ThreeWayMatchLine>,
}
impl ThreeWayMatchResult {
#[must_use]
pub const fn not_required() -> Self {
Self {
match_status: MatchStatus::NotRequired,
tolerance_percent: Decimal::ZERO,
lines: Vec::new(),
}
}
}
fn within_tolerance(expected: Decimal, actual: Decimal, tolerance_percent: Decimal) -> bool {
let diff = (actual - expected).abs();
if expected.is_zero() {
return diff.is_zero();
}
diff * Decimal::ONE_HUNDRED <= expected.abs() * tolerance_percent
}
#[must_use]
pub fn perform_three_way_match(
po_items: &[crate::PurchaseOrderItem],
receipt_items: &[crate::ReceiptItem],
bill_lines: &[BillItem],
tolerance_percent: Decimal,
) -> ThreeWayMatchResult {
let tolerance_percent = tolerance_percent.max(Decimal::ZERO);
let nothing_received =
receipt_items.iter().fold(Decimal::ZERO, |acc, r| acc + r.received_quantity).is_zero();
let mut lines = Vec::with_capacity(bill_lines.len());
for bill_line in bill_lines {
let po_item = bill_line.po_line_id.and_then(|id| po_items.iter().find(|p| p.id == id));
let received_quantity = bill_line.po_line_id.map_or(Decimal::ZERO, |id| {
receipt_items
.iter()
.filter(|r| r.po_line_id == Some(id))
.fold(Decimal::ZERO, |acc, r| acc + r.received_quantity)
});
let mut issues = Vec::new();
match po_item {
None => issues.push("bill line is not linked to a purchase order line".to_string()),
Some(po) => {
if !within_tolerance(po.quantity_ordered, bill_line.quantity, tolerance_percent) {
issues.push(format!(
"billed quantity {} differs from ordered quantity {} beyond tolerance",
bill_line.quantity, po.quantity_ordered
));
}
if !within_tolerance(po.unit_cost, bill_line.unit_price, tolerance_percent) {
issues.push(format!(
"billed unit price {} differs from ordered unit cost {} beyond tolerance",
bill_line.unit_price, po.unit_cost
));
}
if !within_tolerance(received_quantity, bill_line.quantity, tolerance_percent) {
issues.push(if received_quantity.is_zero() {
"no quantity received against this purchase order line".to_string()
} else {
format!(
"billed quantity {} differs from received quantity {received_quantity} beyond tolerance",
bill_line.quantity
)
});
}
}
}
lines.push(ThreeWayMatchLine {
po_line_id: bill_line.po_line_id,
bill_item_id: bill_line.id,
description: bill_line.description.clone(),
ordered_quantity: po_item.map(|p| p.quantity_ordered),
ordered_unit_cost: po_item.map(|p| p.unit_cost),
received_quantity,
billed_quantity: bill_line.quantity,
billed_unit_cost: bill_line.unit_price,
quantity_variance: bill_line.quantity - received_quantity,
price_variance: po_item.map_or(Decimal::ZERO, |p| bill_line.unit_price - p.unit_cost),
matched: issues.is_empty(),
issues,
});
}
let variance_line_count = lines.iter().filter(|l| !l.matched).count();
let match_status = if nothing_received {
MatchStatus::Pending
} else if variance_line_count == 0 {
MatchStatus::Matched
} else {
MatchStatus::Variance { variance_line_count }
};
ThreeWayMatchResult { match_status, tolerance_percent, lines }
}
#[must_use]
pub fn generate_bill_number() -> String {
let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
let random = &uuid::Uuid::new_v4().to_string()[..6].to_uppercase();
format!("BILL-{timestamp}-{random}")
}
#[must_use]
pub fn generate_ap_payment_number() -> String {
let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
let random = &uuid::Uuid::new_v4().to_string()[..6].to_uppercase();
format!("APMT-{timestamp}-{random}")
}
#[must_use]
pub fn generate_payment_run_number() -> String {
let timestamp = chrono::Utc::now().format("%Y%m%d%H%M").to_string();
let random = &uuid::Uuid::new_v4().to_string()[..4].to_uppercase();
format!("RUN-{timestamp}-{random}")
}