tap_msg/message/
invoice.rs

1//! Invoice message types and structures according to TAIP-16.
2//!
3//! This module defines the structured Invoice object that can be embedded
4//! in a TAIP-14 Payment Request message.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Tax category for a line item or tax subtotal
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TaxCategory {
12    /// Tax category code (e.g., "S" for standard rate, "Z" for zero-rated)
13    pub id: String,
14
15    /// Tax rate percentage
16    pub percent: f64,
17
18    /// Tax scheme (e.g., "VAT", "GST")
19    #[serde(rename = "taxScheme")]
20    pub tax_scheme: String,
21}
22
23/// Line item in an invoice
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LineItem {
26    /// Unique identifier for the line item
27    pub id: String,
28
29    /// Description of the item or service
30    pub description: String,
31
32    /// Quantity of the item
33    pub quantity: f64,
34
35    /// Optional unit of measure (e.g., "KGM" for kilogram)
36    #[serde(rename = "unitCode", skip_serializing_if = "Option::is_none")]
37    pub unit_code: Option<String>,
38
39    /// Price per unit
40    #[serde(rename = "unitPrice")]
41    pub unit_price: f64,
42
43    /// Total amount for this line item
44    #[serde(rename = "lineTotal")]
45    pub line_total: f64,
46
47    /// Optional tax category for the line item
48    #[serde(rename = "taxCategory", skip_serializing_if = "Option::is_none")]
49    pub tax_category: Option<TaxCategory>,
50}
51
52/// Tax subtotal information
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TaxSubtotal {
55    /// Amount subject to this tax
56    #[serde(rename = "taxableAmount")]
57    pub taxable_amount: f64,
58
59    /// Tax amount for this category
60    #[serde(rename = "taxAmount")]
61    pub tax_amount: f64,
62
63    /// Tax category information
64    #[serde(rename = "taxCategory")]
65    pub tax_category: TaxCategory,
66}
67
68/// Aggregate tax information
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TaxTotal {
71    /// Total tax amount for the invoice
72    #[serde(rename = "taxAmount")]
73    pub tax_amount: f64,
74
75    /// Optional breakdown of taxes by category
76    #[serde(rename = "taxSubtotal", skip_serializing_if = "Option::is_none")]
77    pub tax_subtotal: Option<Vec<TaxSubtotal>>,
78}
79
80/// Order reference information
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct OrderReference {
83    /// Order identifier
84    pub id: String,
85
86    /// Optional issue date of the order
87    #[serde(rename = "issueDate", skip_serializing_if = "Option::is_none")]
88    pub issue_date: Option<String>,
89}
90
91/// Reference to an additional document
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct DocumentReference {
94    /// Document identifier
95    pub id: String,
96
97    /// Optional document type
98    #[serde(rename = "documentType", skip_serializing_if = "Option::is_none")]
99    pub document_type: Option<String>,
100
101    /// Optional URL where the document can be accessed
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub url: Option<String>,
104}
105
106/// Invoice structure according to TAIP-16
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Invoice {
109    /// Unique identifier for the invoice
110    pub id: String,
111
112    /// Date when the invoice was issued (ISO 8601 format)
113    #[serde(rename = "issueDate")]
114    pub issue_date: String,
115
116    /// ISO 4217 currency code
117    #[serde(rename = "currencyCode")]
118    pub currency_code: String,
119
120    /// Line items in the invoice
121    #[serde(rename = "lineItems")]
122    pub line_items: Vec<LineItem>,
123
124    /// Optional tax total information
125    #[serde(rename = "taxTotal", skip_serializing_if = "Option::is_none")]
126    pub tax_total: Option<TaxTotal>,
127
128    /// Total amount of the invoice, including taxes
129    pub total: f64,
130
131    /// Optional sum of line totals before taxes
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub sub_total: Option<f64>,
134
135    /// Optional due date for payment (ISO 8601 format)
136    #[serde(rename = "dueDate", skip_serializing_if = "Option::is_none")]
137    pub due_date: Option<String>,
138
139    /// Optional additional notes
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub note: Option<String>,
142
143    /// Optional payment terms
144    #[serde(rename = "paymentTerms", skip_serializing_if = "Option::is_none")]
145    pub payment_terms: Option<String>,
146
147    /// Optional accounting cost code
148    #[serde(rename = "accountingCost", skip_serializing_if = "Option::is_none")]
149    pub accounting_cost: Option<String>,
150
151    /// Optional order reference
152    #[serde(rename = "orderReference", skip_serializing_if = "Option::is_none")]
153    pub order_reference: Option<OrderReference>,
154
155    /// Optional references to additional documents
156    #[serde(
157        rename = "additionalDocumentReference",
158        skip_serializing_if = "Option::is_none"
159    )]
160    pub additional_document_reference: Option<Vec<DocumentReference>>,
161
162    /// Additional metadata
163    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
164    pub metadata: HashMap<String, serde_json::Value>,
165}
166
167impl Invoice {
168    /// Creates a new basic Invoice
169    pub fn new(
170        id: String,
171        issue_date: String,
172        currency_code: String,
173        line_items: Vec<LineItem>,
174        total: f64,
175    ) -> Self {
176        Self {
177            id,
178            issue_date,
179            currency_code,
180            line_items,
181            tax_total: None,
182            total,
183            sub_total: None,
184            due_date: None,
185            note: None,
186            payment_terms: None,
187            accounting_cost: None,
188            order_reference: None,
189            additional_document_reference: None,
190            metadata: HashMap::new(),
191        }
192    }
193
194    /// Validate the Invoice according to TAIP-16 rules
195    pub fn validate(&self) -> crate::error::Result<()> {
196        use crate::error::Error;
197
198        // Required fields validation
199        if self.id.is_empty() {
200            return Err(Error::Validation("Invoice ID is required".to_string()));
201        }
202
203        if self.issue_date.is_empty() {
204            return Err(Error::Validation("Issue date is required".to_string()));
205        }
206
207        if self.currency_code.is_empty() {
208            return Err(Error::Validation("Currency code is required".to_string()));
209        }
210
211        if self.line_items.is_empty() {
212            return Err(Error::Validation(
213                "At least one line item is required".to_string(),
214            ));
215        }
216
217        // Validate line items
218        for (i, item) in self.line_items.iter().enumerate() {
219            if item.id.is_empty() {
220                return Err(Error::Validation(format!(
221                    "Line item {} is missing an ID",
222                    i
223                )));
224            }
225
226            if item.description.is_empty() {
227                return Err(Error::Validation(format!(
228                    "Line item {} is missing a description",
229                    i
230                )));
231            }
232
233            // Validate that line total is approximately equal to quantity * unit price
234            // Allow for some floating point imprecision
235            let calculated_total = item.quantity * item.unit_price;
236            let difference = (calculated_total - item.line_total).abs();
237            if difference > 0.01 {
238                // Allow a small tolerance for floating point calculations
239                return Err(Error::Validation(format!(
240                    "Line item {}: Line total ({}) does not match quantity ({}) * unit price ({})",
241                    i, item.line_total, item.quantity, item.unit_price
242                )));
243            }
244        }
245
246        // Validate sub_total if present
247        if let Some(sub_total) = self.sub_total {
248            let calculated_sub_total: f64 =
249                self.line_items.iter().map(|item| item.line_total).sum();
250            let difference = (calculated_sub_total - sub_total).abs();
251            if difference > 0.01 {
252                // Allow a small tolerance for floating point calculations
253                return Err(Error::Validation(format!(
254                    "Sub-total ({}) does not match the sum of line totals ({})",
255                    sub_total, calculated_sub_total
256                )));
257            }
258        }
259
260        // Validate tax_total if present
261        if let Some(tax_total) = &self.tax_total {
262            if let Some(tax_subtotals) = &tax_total.tax_subtotal {
263                let sum_of_subtotals: f64 = tax_subtotals.iter().map(|st| st.tax_amount).sum();
264                let difference = (sum_of_subtotals - tax_total.tax_amount).abs();
265                if difference > 0.01 {
266                    // Allow a small tolerance for floating point calculations
267                    return Err(Error::Validation(format!(
268                        "Tax total amount ({}) does not match the sum of tax subtotal amounts ({})",
269                        tax_total.tax_amount, sum_of_subtotals
270                    )));
271                }
272            }
273        }
274
275        // Validate total
276        let sub_total = self
277            .sub_total
278            .unwrap_or_else(|| self.line_items.iter().map(|item| item.line_total).sum());
279        let tax_amount = self.tax_total.as_ref().map_or(0.0, |tt| tt.tax_amount);
280        let calculated_total = sub_total + tax_amount;
281        let difference = (calculated_total - self.total).abs();
282        if difference > 0.01 {
283            // Allow a small tolerance for floating point calculations
284            return Err(Error::Validation(format!(
285                "Total ({}) does not match sub-total ({}) + tax amount ({})",
286                self.total, sub_total, tax_amount
287            )));
288        }
289
290        // Validate date formats
291        if self.issue_date.len() != 10 {
292            return Err(Error::SerializationError(
293                "issue_date must be in YYYY-MM-DD format".to_string(),
294            ));
295        }
296        if chrono::NaiveDate::parse_from_str(&self.issue_date, "%Y-%m-%d").is_err() {
297            return Err(Error::SerializationError(
298                "Invalid issue_date format or value".to_string(),
299            ));
300        }
301
302        if let Some(due_date) = &self.due_date {
303            if due_date.len() != 10 {
304                return Err(Error::SerializationError(
305                    "due_date must be in YYYY-MM-DD format".to_string(),
306                ));
307            }
308            if chrono::NaiveDate::parse_from_str(due_date, "%Y-%m-%d").is_err() {
309                return Err(Error::SerializationError(
310                    "Invalid due_date format or value".to_string(),
311                ));
312            }
313        }
314
315        Ok(())
316    }
317}