1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Bill {
20 pub id: Uuid,
22 pub bill_number: String,
24 pub supplier_id: Uuid,
26 pub supplier_name: Option<String>,
28 pub purchase_order_id: Option<Uuid>,
30 pub status: BillStatus,
32 pub bill_date: DateTime<Utc>,
34 pub due_date: DateTime<Utc>,
36 pub payment_terms: Option<String>,
38 pub subtotal: Decimal,
40 pub tax_amount: Decimal,
42 pub shipping_amount: Decimal,
44 pub discount_amount: Decimal,
46 pub total_amount: Decimal,
48 pub amount_paid: Decimal,
50 pub amount_due: Decimal,
52 pub currency: CurrencyCode,
54 pub reference_number: Option<String>,
56 pub memo: Option<String>,
58 pub created_at: DateTime<Utc>,
60 pub updated_at: DateTime<Utc>,
62}
63
64#[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#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct BillPayment {
84 pub id: Uuid,
86 pub payment_number: String,
88 pub supplier_id: Uuid,
90 pub payment_date: DateTime<Utc>,
92 pub payment_method: PaymentMethodAP,
94 pub amount: Decimal,
96 pub currency: CurrencyCode,
98 pub reference_number: Option<String>,
100 pub bank_account: Option<String>,
102 pub check_number: Option<String>,
104 pub memo: Option<String>,
106 pub status: PaymentStatusAP,
108 pub created_at: DateTime<Utc>,
110 pub updated_at: DateTime<Utc>,
112}
113
114#[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#[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#[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 #[default]
155 Draft,
156 Pending,
158 Approved,
160 PartiallyPaid,
162 Paid,
164 Overdue,
166 Cancelled,
168 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#[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 #[default]
199 Check,
200 Ach,
202 Wire,
204 #[strum(serialize = "credit_card", serialize = "creditcard")]
206 CreditCard,
207 Cash,
209 Other,
211}
212
213#[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 #[default]
221 Pending,
222 Processed,
224 Cleared,
226 Voided,
228 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#[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 #[default]
254 Draft,
255 Pending,
257 Approved,
259 Processing,
261 Completed,
263 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 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 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); 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 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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct PaymentAllocationInput {
557 pub bill_id: Uuid,
558 pub amount: Decimal,
559}
560
561#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
665#[serde(tag = "status", rename_all = "snake_case")]
666#[non_exhaustive]
667pub enum MatchStatus {
668 NotRequired,
670 Pending,
672 Matched,
674 Variance {
676 variance_line_count: usize,
678 },
679}
680
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683pub struct ThreeWayMatchLine {
684 pub po_line_id: Option<Uuid>,
686 pub bill_item_id: Uuid,
688 pub description: String,
690 pub ordered_quantity: Option<Decimal>,
692 pub ordered_unit_cost: Option<Decimal>,
694 pub received_quantity: Decimal,
696 pub billed_quantity: Decimal,
698 pub billed_unit_cost: Decimal,
700 pub quantity_variance: Decimal,
702 pub price_variance: Decimal,
704 pub matched: bool,
706 pub issues: Vec<String>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712pub struct ThreeWayMatchResult {
713 pub match_status: MatchStatus,
715 pub tolerance_percent: Decimal,
717 pub lines: Vec<ThreeWayMatchLine>,
719}
720
721impl ThreeWayMatchResult {
722 #[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
733fn 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#[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#[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#[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#[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}