1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3
4mod decimal_format {
5 use serde::{self, Deserialize, Deserializer, Serializer};
6
7 pub fn serialize<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
8 where
9 S: Serializer,
10 {
11 let abs = value.abs();
12 if abs == 0.0 {
13 serializer.serialize_f64(0.0)
14 } else if abs < 0.0001 {
15 serializer.serialize_str(
16 format!("{:.10}", value)
17 .trim_end_matches('0')
18 .trim_end_matches('.'),
19 )
20 } else if abs < 1.0 {
21 serializer.serialize_str(
22 format!("{:.8}", value)
23 .trim_end_matches('0')
24 .trim_end_matches('.'),
25 )
26 } else {
27 serializer.serialize_f64(*value)
28 }
29 }
30
31 pub fn deserialize<'de, D>(deserializer: D) -> Result<f64, D::Error>
32 where
33 D: Deserializer<'de>,
34 {
35 #[derive(Deserialize)]
36 #[serde(untagged)]
37 enum StringOrFloat {
38 String(String),
39 Float(f64),
40 }
41
42 match StringOrFloat::deserialize(deserializer)? {
43 StringOrFloat::String(s) => s.parse().map_err(serde::de::Error::custom),
44 StringOrFloat::Float(f) => Ok(f),
45 }
46 }
47}
48
49mod decimal_format_option {
50 use serde::{self, Deserialize, Deserializer, Serializer};
51
52 pub fn serialize<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
53 where
54 S: Serializer,
55 {
56 match value {
57 Some(v) => {
58 let abs = v.abs();
59 if abs == 0.0 {
60 serializer.serialize_f64(0.0)
61 } else if abs < 0.0001 {
62 serializer.serialize_str(
63 format!("{:.10}", v)
64 .trim_end_matches('0')
65 .trim_end_matches('.'),
66 )
67 } else if abs < 1.0 {
68 serializer.serialize_str(
69 format!("{:.8}", v)
70 .trim_end_matches('0')
71 .trim_end_matches('.'),
72 )
73 } else {
74 serializer.serialize_f64(*v)
75 }
76 }
77 None => serializer.serialize_none(),
78 }
79 }
80
81 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
82 where
83 D: Deserializer<'de>,
84 {
85 #[derive(Deserialize)]
86 #[serde(untagged)]
87 enum StringOrFloat {
88 String(String),
89 Float(f64),
90 }
91
92 let opt: Option<StringOrFloat> = Option::deserialize(deserializer)?;
93 match opt {
94 Some(StringOrFloat::String(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
95 Some(StringOrFloat::Float(f)) => Ok(Some(f)),
96 None => Ok(None),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
102pub enum DocumentFormat {
103 #[default]
104 Unknown,
105 AwsDirect,
106 ECloudValleyAws,
107 MicrofusionAliyun,
108 AliyunDirect,
109 UCloud,
110 GoogleCloud,
111 Azure,
112 Lokalise,
113 Sentry,
114 Mux,
115 MlyticsConsolidated,
116 AzureCsp,
117 AliyunUsageDetail,
118 MicrofusionGcpUsage,
119 Chargebee,
120 Edgenext,
121 DataStar,
122 DigicentreHk,
123 CloudMile,
124 MetaageAkamai,
125 VnisInvoice,
126 TencentEdgeOne,
127 AzurePlanDaily,
128 GoogleWorkspaceBilling,
129 HubSpot,
130 Reachtop,
131 GenericConsultant,
132 CdnOverageDetail,
133 Atlassian,
134 Contentsquare,
135 Slack,
136 MlyticsInvoice,
137 VNetwork,
138 VnisSummary,
139 CdnTraffic,
140 NonInvoice,
141}
142
143impl std::fmt::Display for DocumentFormat {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 DocumentFormat::Unknown => write!(f, "Unknown"),
147 DocumentFormat::AwsDirect => write!(f, "AWS Direct"),
148 DocumentFormat::ECloudValleyAws => write!(f, "eCloudValley AWS"),
149 DocumentFormat::MicrofusionAliyun => write!(f, "Microfusion Aliyun"),
150 DocumentFormat::AliyunDirect => write!(f, "Alibaba Cloud Direct"),
151 DocumentFormat::UCloud => write!(f, "UCloud"),
152 DocumentFormat::GoogleCloud => write!(f, "Google Cloud"),
153 DocumentFormat::Azure => write!(f, "Microsoft Azure"),
154 DocumentFormat::Lokalise => write!(f, "Lokalise"),
155 DocumentFormat::Sentry => write!(f, "Sentry"),
156 DocumentFormat::Mux => write!(f, "Mux"),
157 DocumentFormat::MlyticsConsolidated => write!(f, "Mlytics Consolidated"),
158 DocumentFormat::AzureCsp => write!(f, "Azure CSP"),
159 DocumentFormat::AliyunUsageDetail => write!(f, "Aliyun Usage Detail"),
160 DocumentFormat::MicrofusionGcpUsage => write!(f, "Microfusion GCP Usage"),
161 DocumentFormat::Chargebee => write!(f, "Chargebee"),
162 DocumentFormat::Edgenext => write!(f, "Edgenext"),
163 DocumentFormat::DataStar => write!(f, "Data Star"),
164 DocumentFormat::DigicentreHk => write!(f, "Digicentre HK"),
165 DocumentFormat::CloudMile => write!(f, "CloudMile"),
166 DocumentFormat::MetaageAkamai => write!(f, "Metaage Akamai"),
167 DocumentFormat::VnisInvoice => write!(f, "VNIS Invoice"),
168 DocumentFormat::TencentEdgeOne => write!(f, "Tencent EdgeOne"),
169 DocumentFormat::AzurePlanDaily => write!(f, "Azure Plan Daily"),
170 DocumentFormat::GoogleWorkspaceBilling => write!(f, "Google Workspace Billing"),
171 DocumentFormat::HubSpot => write!(f, "HubSpot"),
172 DocumentFormat::Reachtop => write!(f, "Reachtop"),
173 DocumentFormat::GenericConsultant => write!(f, "Generic Consultant"),
174 DocumentFormat::CdnOverageDetail => write!(f, "CDN Overage Detail"),
175 DocumentFormat::Atlassian => write!(f, "Atlassian"),
176 DocumentFormat::Contentsquare => write!(f, "Contentsquare"),
177 DocumentFormat::Slack => write!(f, "Slack"),
178 DocumentFormat::MlyticsInvoice => write!(f, "Mlytics Invoice"),
179 DocumentFormat::VNetwork => write!(f, "VNetwork"),
180 DocumentFormat::VnisSummary => write!(f, "VNIS Summary"),
181 DocumentFormat::CdnTraffic => write!(f, "CDN Traffic"),
182 DocumentFormat::NonInvoice => write!(f, "Non-Invoice"),
183 }
184 }
185}
186
187impl DocumentFormat {
188 pub fn vendor_name(&self) -> &str {
189 match self {
190 DocumentFormat::AwsDirect => "AWS",
191 DocumentFormat::ECloudValleyAws => "AWS",
192 DocumentFormat::MicrofusionAliyun => "阿里云",
193 DocumentFormat::AliyunDirect => "阿里云",
194 DocumentFormat::UCloud => "UCloud",
195 DocumentFormat::GoogleCloud => "Google Cloud",
196 DocumentFormat::Azure => "Microsoft Azure",
197 DocumentFormat::Lokalise => "Lokalise",
198 DocumentFormat::Sentry => "Sentry",
199 DocumentFormat::Mux => "Mux",
200 DocumentFormat::MlyticsConsolidated => "Mlytics",
201 DocumentFormat::AzureCsp => "Microsoft Azure",
202 DocumentFormat::AliyunUsageDetail => "阿里云",
203 DocumentFormat::MicrofusionGcpUsage => "Google Cloud",
204 DocumentFormat::Chargebee => "Chargebee",
205 DocumentFormat::Edgenext => "Edgenext",
206 DocumentFormat::DataStar => "Data Star",
207 DocumentFormat::DigicentreHk => "AWS",
208 DocumentFormat::CloudMile => "CloudMile",
209 DocumentFormat::MetaageAkamai => "Akamai",
210 DocumentFormat::VnisInvoice => "Mlytics",
211 DocumentFormat::TencentEdgeOne => "Tencent",
212 DocumentFormat::AzurePlanDaily => "Microsoft Azure",
213 DocumentFormat::GoogleWorkspaceBilling => "Google",
214 DocumentFormat::HubSpot => "HubSpot",
215 DocumentFormat::Reachtop => "Reachtop",
216 DocumentFormat::GenericConsultant => "",
217 DocumentFormat::CdnOverageDetail => "CDN",
218 DocumentFormat::Atlassian => "Atlassian",
219 DocumentFormat::Contentsquare => "Contentsquare",
220 DocumentFormat::Slack => "Slack",
221 DocumentFormat::MlyticsInvoice => "Mlytics",
222 DocumentFormat::VNetwork => "VNetwork",
223 DocumentFormat::VnisSummary => "VNIS",
224 DocumentFormat::CdnTraffic => "CDN",
225 DocumentFormat::NonInvoice => "",
226 DocumentFormat::Unknown => "",
227 }
228 }
229 pub fn format_type(&self) -> &str {
230 match self {
231 DocumentFormat::UCloud => "XLSX",
232 DocumentFormat::AliyunUsageDetail => "XLSX",
233 DocumentFormat::MicrofusionGcpUsage => "XLSX",
234 DocumentFormat::TencentEdgeOne => "XLSX",
235 DocumentFormat::AzurePlanDaily => "XLSX",
236 DocumentFormat::GoogleWorkspaceBilling => "XLSX",
237 DocumentFormat::Unknown => "其他",
238 _ => "PDF",
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
245pub enum Currency {
246 #[default]
248 USD,
249 EUR,
251 GBP,
253 JPY,
255 CNY,
257 HKD,
259 SGD,
261 AUD,
263 CAD,
265 CHF,
267 Other(String),
269}
270
271impl From<&str> for Currency {
272 fn from(s: &str) -> Self {
273 match s.to_uppercase().as_str() {
274 "USD" | "$" | "US$" => Currency::USD,
275 "EUR" | "€" => Currency::EUR,
276 "GBP" | "£" => Currency::GBP,
277 "JPY" | "¥" | "YEN" => Currency::JPY,
278 "CNY" | "RMB" | "元" => Currency::CNY,
279 "HKD" | "HK$" => Currency::HKD,
280 "SGD" | "S$" => Currency::SGD,
281 "AUD" | "A$" => Currency::AUD,
282 "CAD" | "C$" => Currency::CAD,
283 "CHF" => Currency::CHF,
284 other => Currency::Other(other.to_string()),
285 }
286 }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
291pub enum InvoiceType {
292 #[default]
294 Standard,
295 CreditNote,
297 DebitNote,
299 ProformaInvoice,
301 CommercialInvoice,
303 Receipt,
305 Bill,
307 Statement,
309 Unknown,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, Default)]
315pub struct Address {
316 pub line1: Option<String>,
318 pub line2: Option<String>,
320 pub city: Option<String>,
322 pub state: Option<String>,
324 pub postal_code: Option<String>,
326 pub country: Option<String>,
328}
329
330impl Address {
331 pub fn full_address(&self) -> String {
333 [
334 self.line1.as_deref(),
335 self.line2.as_deref(),
336 self.city.as_deref(),
337 self.state.as_deref(),
338 self.postal_code.as_deref(),
339 self.country.as_deref(),
340 ]
341 .iter()
342 .filter_map(|&s| s)
343 .collect::<Vec<_>>()
344 .join(", ")
345 }
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, Default)]
350pub struct Party {
351 pub name: Option<String>,
353 pub tax_id: Option<String>,
355 pub address: Option<Address>,
357 pub email: Option<String>,
359 pub phone: Option<String>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct LineItem {
366 pub line_number: Option<u32>,
368 pub service_name: Option<String>,
370 pub description: String,
372 #[serde(with = "decimal_format_option")]
374 pub quantity: Option<f64>,
375 pub unit: Option<String>,
377 #[serde(with = "decimal_format_option")]
379 pub unit_price: Option<f64>,
380 #[serde(with = "decimal_format_option")]
382 pub discount: Option<f64>,
383 #[serde(with = "decimal_format_option")]
385 pub tax_rate: Option<f64>,
386 #[serde(with = "decimal_format_option")]
388 pub tax_amount: Option<f64>,
389 #[serde(with = "decimal_format")]
391 pub amount: f64,
392}
393
394impl LineItem {
395 pub fn calculate_amount(&self) -> f64 {
397 let qty = self.quantity.unwrap_or(1.0);
398 let price = self.unit_price.unwrap_or(self.amount);
399 let discount = self.discount.unwrap_or(0.0);
400 let tax = self.tax_amount.unwrap_or(0.0);
401
402 (qty * price) - discount + tax
403 }
404
405 pub fn validate_amount(&self) -> LineItemValidation {
408 let (qty, price) = match (self.quantity, self.unit_price) {
409 (Some(q), Some(p)) => (q, p),
410 _ => {
411 return LineItemValidation {
412 is_valid: true,
413 can_validate: false,
414 calculated_amount: None,
415 difference: None,
416 difference_percent: None,
417 }
418 }
419 };
420
421 let calculated = qty * price;
422 let diff = (self.amount - calculated).abs();
423 let diff_percent = if calculated.abs() > 0.0001 {
424 (diff / calculated.abs()) * 100.0
425 } else {
426 0.0
427 };
428
429 LineItemValidation {
430 is_valid: diff < 0.01 || diff_percent < 1.0,
431 can_validate: true,
432 calculated_amount: Some(calculated),
433 difference: Some(self.amount - calculated),
434 difference_percent: Some(diff_percent),
435 }
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct LineItemValidation {
442 pub is_valid: bool,
444 pub can_validate: bool,
446 pub calculated_amount: Option<f64>,
448 pub difference: Option<f64>,
450 pub difference_percent: Option<f64>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, Default)]
456pub struct TaxSummary {
457 pub tax_type: Option<String>,
459 pub tax_rate: Option<f64>,
461 pub taxable_amount: Option<f64>,
463 pub tax_amount: f64,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, Default)]
469pub struct PaymentInfo {
470 pub method: Option<String>,
472 pub bank_name: Option<String>,
474 pub account_number: Option<String>,
476 pub routing_number: Option<String>,
478 pub iban: Option<String>,
480 pub swift_code: Option<String>,
482 pub reference: Option<String>,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, Default)]
490pub struct Invoice {
491 pub document_format: DocumentFormat,
492 pub invoice_type: InvoiceType,
493 pub invoice_number: Option<String>,
495 pub account_name: Option<String>,
497 pub customer_id: Option<String>,
499 pub billing_period: Option<String>,
501 pub invoice_date: Option<NaiveDate>,
503 pub due_date: Option<NaiveDate>,
505 pub currency: Currency,
507 pub vendor: Party,
509 pub customer: Party,
511 pub line_items: Vec<LineItem>,
513 pub subtotal: Option<f64>,
515 pub discount_amount: Option<f64>,
517 pub discount_rate: Option<f64>,
519 pub tax_summaries: Vec<TaxSummary>,
521 pub total_tax: Option<f64>,
523 pub total_amount: f64,
525 pub amount_paid: Option<f64>,
527 pub amount_due: Option<f64>,
529 pub payment_info: Option<PaymentInfo>,
531 pub notes: Option<String>,
533 pub raw_text: Option<String>,
535 pub metadata: std::collections::HashMap<String, String>,
537}
538
539impl Invoice {
540 pub fn new() -> Self {
542 Self::default()
543 }
544
545 pub fn to_json(&self) -> Result<String, serde_json::Error> {
547 serde_json::to_string_pretty(self)
548 }
549
550 pub fn to_json_compact(&self) -> Result<String, serde_json::Error> {
552 serde_json::to_string(self)
553 }
554
555 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
557 serde_json::from_str(json)
558 }
559
560 pub fn calculate_subtotal(&self) -> f64 {
562 self.line_items.iter().map(|item| item.amount).sum()
563 }
564
565 pub fn calculate_total_tax(&self) -> f64 {
567 self.tax_summaries.iter().map(|t| t.tax_amount).sum()
568 }
569
570 pub fn is_paid(&self) -> bool {
572 match (self.amount_paid, self.amount_due) {
573 (Some(paid), _) if paid >= self.total_amount => true,
574 (_, Some(due)) if due <= 0.0 => true,
575 _ => false,
576 }
577 }
578
579 pub fn validate_line_items(&self) -> InvoiceValidation {
580 let mut validations = Vec::new();
581 let mut invalid_count = 0;
582 let mut validatable_count = 0;
583
584 for (idx, item) in self.line_items.iter().enumerate() {
585 let validation = item.validate_amount();
586 if validation.can_validate {
587 validatable_count += 1;
588 if !validation.is_valid {
589 invalid_count += 1;
590 }
591 }
592 validations.push((idx, item.description.clone(), validation));
593 }
594
595 let line_items_sum = self.calculate_subtotal();
596 let subtotal_diff = self.subtotal.map(|s| {
597 let diff = s - line_items_sum;
598 let pct = if s.abs() > 0.0001 {
599 (diff.abs() / s.abs()) * 100.0
600 } else {
601 0.0
602 };
603 (diff, pct)
604 });
605
606 InvoiceValidation {
607 all_valid: invalid_count == 0 && Self::is_subtotal_valid(subtotal_diff),
608 subtotal_valid: Self::is_subtotal_valid(subtotal_diff),
609 total_items: self.line_items.len(),
610 validatable_items: validatable_count,
611 invalid_items: invalid_count,
612 line_items_sum,
613 subtotal: self.subtotal,
614 subtotal_difference: subtotal_diff.map(|(d, _)| d),
615 subtotal_difference_percent: subtotal_diff.map(|(_, p)| p),
616 item_validations: validations,
617 }
618 }
619
620 fn is_subtotal_valid(subtotal_diff: Option<(f64, f64)>) -> bool {
621 const MAX_ALLOWED_DIFFERENCE_PERCENT: f64 = 1.0;
622 match subtotal_diff {
623 Some((_, pct)) => pct <= MAX_ALLOWED_DIFFERENCE_PERCENT,
624 None => true,
625 }
626 }
627}
628
629#[derive(Debug, Clone)]
630pub struct InvoiceValidation {
631 pub all_valid: bool,
632 pub subtotal_valid: bool,
633 pub total_items: usize,
634 pub validatable_items: usize,
635 pub invalid_items: usize,
636 pub line_items_sum: f64,
637 pub subtotal: Option<f64>,
638 pub subtotal_difference: Option<f64>,
639 pub subtotal_difference_percent: Option<f64>,
640 pub item_validations: Vec<(usize, String, LineItemValidation)>,
641}
642
643impl InvoiceValidation {
644 pub fn print_report(&self) {
645 println!("=== Invoice Validation Report ===");
646 println!("Total items: {}", self.total_items);
647 println!("Validatable items: {}", self.validatable_items);
648 println!("Invalid items: {}", self.invalid_items);
649 println!("Line items sum: {:.2}", self.line_items_sum);
650
651 if let Some(subtotal) = self.subtotal {
652 println!("Invoice subtotal: {:.2}", subtotal);
653 if let (Some(diff), Some(pct)) =
654 (self.subtotal_difference, self.subtotal_difference_percent)
655 {
656 println!("Difference: {:.2} ({:.2}%)", diff, pct);
657 if !self.subtotal_valid {
658 println!("ERROR: Line items sum does not match subtotal (>1% difference)");
659 }
660 }
661 }
662
663 let invalid_items: Vec<_> = self
664 .item_validations
665 .iter()
666 .filter(|(_, _, v)| v.can_validate && !v.is_valid)
667 .collect();
668
669 if !invalid_items.is_empty() {
670 println!("\nInvalid line items:");
671 for (idx, desc, v) in invalid_items {
672 println!(
673 " Line {}: {} | calculated: {:.4} | diff: {:.4}",
674 idx + 1,
675 desc,
676 v.calculated_amount.unwrap_or(0.0),
677 v.difference.unwrap_or(0.0)
678 );
679 }
680 }
681 }
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct ParseResult {
689 pub invoices: Vec<Invoice>,
691 pub source_file: Option<String>,
693 pub parse_warnings: Vec<String>,
695}
696
697impl ParseResult {
698 pub fn single(invoice: Invoice) -> Self {
700 Self {
701 invoices: vec![invoice],
702 source_file: None,
703 parse_warnings: Vec::new(),
704 }
705 }
706
707 pub fn multiple(invoices: Vec<Invoice>) -> Self {
709 Self {
710 invoices,
711 source_file: None,
712 parse_warnings: Vec::new(),
713 }
714 }
715
716 pub fn with_source(mut self, source: impl Into<String>) -> Self {
718 self.source_file = Some(source.into());
719 self
720 }
721
722 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
724 self.parse_warnings.push(warning.into());
725 self
726 }
727}