Skip to main content

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    /// Optional product name (schema.org/Product)
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub name: Option<String>,
54
55    /// Optional product image URL (schema.org/Product)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub image: Option<String>,
58
59    /// Optional product URL (schema.org/Product)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub url: Option<String>,
62}
63
64/// Builder for LineItem objects
65#[derive(Default)]
66pub struct LineItemBuilder {
67    id: Option<String>,
68    description: Option<String>,
69    quantity: Option<f64>,
70    unit_code: Option<String>,
71    unit_price: Option<f64>,
72    line_total: Option<f64>,
73    tax_category: Option<TaxCategory>,
74    name: Option<String>,
75    image: Option<String>,
76    url: Option<String>,
77}
78
79impl LineItemBuilder {
80    /// Set the line item ID
81    pub fn id(mut self, id: String) -> Self {
82        self.id = Some(id);
83        self
84    }
85
86    /// Set the line item description
87    pub fn description(mut self, description: String) -> Self {
88        self.description = Some(description);
89        self
90    }
91
92    /// Set the quantity
93    pub fn quantity(mut self, quantity: f64) -> Self {
94        self.quantity = Some(quantity);
95        self
96    }
97
98    /// Set the unit code
99    pub fn unit_code(mut self, unit_code: String) -> Self {
100        self.unit_code = Some(unit_code);
101        self
102    }
103
104    /// Set the unit price
105    pub fn unit_price(mut self, unit_price: f64) -> Self {
106        self.unit_price = Some(unit_price);
107        self
108    }
109
110    /// Set the line total
111    pub fn line_total(mut self, line_total: f64) -> Self {
112        self.line_total = Some(line_total);
113        self
114    }
115
116    /// Set the tax category
117    pub fn tax_category(mut self, tax_category: TaxCategory) -> Self {
118        self.tax_category = Some(tax_category);
119        self
120    }
121
122    /// Set the product name (schema.org/Product)
123    pub fn name(mut self, name: String) -> Self {
124        self.name = Some(name);
125        self
126    }
127
128    /// Set the product image URL (schema.org/Product)
129    pub fn image(mut self, image: String) -> Self {
130        self.image = Some(image);
131        self
132    }
133
134    /// Set the product URL (schema.org/Product)
135    pub fn url(mut self, url: String) -> Self {
136        self.url = Some(url);
137        self
138    }
139
140    /// Build the LineItem
141    pub fn build(self) -> LineItem {
142        LineItem {
143            id: self.id.expect("id is required"),
144            description: self.description.expect("description is required"),
145            quantity: self.quantity.expect("quantity is required"),
146            unit_code: self.unit_code,
147            unit_price: self.unit_price.expect("unit_price is required"),
148            line_total: self.line_total.expect("line_total is required"),
149            tax_category: self.tax_category,
150            name: self.name,
151            image: self.image,
152            url: self.url,
153        }
154    }
155}
156
157impl LineItem {
158    /// Create a builder for constructing LineItem objects
159    pub fn builder() -> LineItemBuilder {
160        LineItemBuilder::default()
161    }
162}
163
164/// Tax subtotal information
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct TaxSubtotal {
167    /// Amount subject to this tax
168    #[serde(rename = "taxableAmount")]
169    pub taxable_amount: f64,
170
171    /// Tax amount for this category
172    #[serde(rename = "taxAmount")]
173    pub tax_amount: f64,
174
175    /// Tax category information
176    #[serde(rename = "taxCategory")]
177    pub tax_category: TaxCategory,
178}
179
180/// Aggregate tax information
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct TaxTotal {
183    /// Total tax amount for the invoice
184    #[serde(rename = "taxAmount")]
185    pub tax_amount: f64,
186
187    /// Optional breakdown of taxes by category
188    #[serde(rename = "taxSubtotal", skip_serializing_if = "Option::is_none")]
189    pub tax_subtotal: Option<Vec<TaxSubtotal>>,
190}
191
192/// Order reference information
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct OrderReference {
195    /// Order identifier
196    pub id: String,
197
198    /// Optional issue date of the order
199    #[serde(rename = "issueDate", skip_serializing_if = "Option::is_none")]
200    pub issue_date: Option<String>,
201}
202
203/// Reference to an additional document
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct DocumentReference {
206    /// Document identifier
207    pub id: String,
208
209    /// Optional document type
210    #[serde(rename = "documentType", skip_serializing_if = "Option::is_none")]
211    pub document_type: Option<String>,
212
213    /// Optional URL where the document can be accessed
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub url: Option<String>,
216}
217
218/// Invoice structure according to TAIP-16
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct Invoice {
221    /// Unique identifier for the invoice
222    pub id: String,
223
224    /// Date when the invoice was issued (ISO 8601 format)
225    #[serde(rename = "issueDate")]
226    pub issue_date: String,
227
228    /// ISO 4217 currency code
229    #[serde(rename = "currencyCode")]
230    pub currency_code: String,
231
232    /// Line items in the invoice
233    #[serde(rename = "lineItems")]
234    pub line_items: Vec<LineItem>,
235
236    /// Optional tax total information
237    #[serde(rename = "taxTotal", skip_serializing_if = "Option::is_none")]
238    pub tax_total: Option<TaxTotal>,
239
240    /// Total amount of the invoice, including taxes
241    pub total: f64,
242
243    /// Optional sum of line totals before taxes
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub sub_total: Option<f64>,
246
247    /// Optional due date for payment (ISO 8601 format)
248    #[serde(rename = "dueDate", skip_serializing_if = "Option::is_none")]
249    pub due_date: Option<String>,
250
251    /// Optional additional notes
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub note: Option<String>,
254
255    /// Optional payment terms
256    #[serde(rename = "paymentTerms", skip_serializing_if = "Option::is_none")]
257    pub payment_terms: Option<String>,
258
259    /// Optional accounting cost code
260    #[serde(rename = "accountingCost", skip_serializing_if = "Option::is_none")]
261    pub accounting_cost: Option<String>,
262
263    /// Optional order reference
264    #[serde(rename = "orderReference", skip_serializing_if = "Option::is_none")]
265    pub order_reference: Option<OrderReference>,
266
267    /// Optional references to additional documents
268    #[serde(
269        rename = "additionalDocumentReference",
270        skip_serializing_if = "Option::is_none"
271    )]
272    pub additional_document_reference: Option<Vec<DocumentReference>>,
273
274    /// Additional metadata
275    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
276    pub metadata: HashMap<String, serde_json::Value>,
277}
278
279impl Invoice {
280    /// Creates a new basic Invoice
281    pub fn new(
282        id: String,
283        issue_date: String,
284        currency_code: String,
285        line_items: Vec<LineItem>,
286        total: f64,
287    ) -> Self {
288        Self {
289            id,
290            issue_date,
291            currency_code,
292            line_items,
293            tax_total: None,
294            total,
295            sub_total: None,
296            due_date: None,
297            note: None,
298            payment_terms: None,
299            accounting_cost: None,
300            order_reference: None,
301            additional_document_reference: None,
302            metadata: HashMap::new(),
303        }
304    }
305
306    /// Validate the Invoice according to TAIP-16 rules
307    pub fn validate(&self) -> crate::error::Result<()> {
308        use crate::error::Error;
309
310        // Required fields validation
311        if self.id.is_empty() {
312            return Err(Error::Validation("Invoice ID is required".to_string()));
313        }
314
315        if self.issue_date.is_empty() {
316            return Err(Error::Validation("Issue date is required".to_string()));
317        }
318
319        if self.currency_code.is_empty() {
320            return Err(Error::Validation("Currency code is required".to_string()));
321        }
322
323        if self.line_items.is_empty() {
324            return Err(Error::Validation(
325                "At least one line item is required".to_string(),
326            ));
327        }
328
329        // Validate line items
330        for (i, item) in self.line_items.iter().enumerate() {
331            if item.id.is_empty() {
332                return Err(Error::Validation(format!(
333                    "Line item {} is missing an ID",
334                    i
335                )));
336            }
337
338            if item.description.is_empty() {
339                return Err(Error::Validation(format!(
340                    "Line item {} is missing a description",
341                    i
342                )));
343            }
344
345            // Validate that line total is approximately equal to quantity * unit price
346            // Allow for some floating point imprecision
347            let calculated_total = item.quantity * item.unit_price;
348            let difference = (calculated_total - item.line_total).abs();
349            if difference > 0.01 {
350                // Allow a small tolerance for floating point calculations
351                return Err(Error::Validation(format!(
352                    "Line item {}: Line total ({}) does not match quantity ({}) * unit price ({})",
353                    i, item.line_total, item.quantity, item.unit_price
354                )));
355            }
356        }
357
358        // Validate sub_total if present
359        if let Some(sub_total) = self.sub_total {
360            let calculated_sub_total: f64 =
361                self.line_items.iter().map(|item| item.line_total).sum();
362            let difference = (calculated_sub_total - sub_total).abs();
363            if difference > 0.01 {
364                // Allow a small tolerance for floating point calculations
365                return Err(Error::Validation(format!(
366                    "Sub-total ({}) does not match the sum of line totals ({})",
367                    sub_total, calculated_sub_total
368                )));
369            }
370        }
371
372        // Validate tax_total if present
373        if let Some(tax_total) = &self.tax_total {
374            if let Some(tax_subtotals) = &tax_total.tax_subtotal {
375                let sum_of_subtotals: f64 = tax_subtotals.iter().map(|st| st.tax_amount).sum();
376                let difference = (sum_of_subtotals - tax_total.tax_amount).abs();
377                if difference > 0.01 {
378                    // Allow a small tolerance for floating point calculations
379                    return Err(Error::Validation(format!(
380                        "Tax total amount ({}) does not match the sum of tax subtotal amounts ({})",
381                        tax_total.tax_amount, sum_of_subtotals
382                    )));
383                }
384            }
385        }
386
387        // Validate total
388        let sub_total = self
389            .sub_total
390            .unwrap_or_else(|| self.line_items.iter().map(|item| item.line_total).sum());
391        let tax_amount = self.tax_total.as_ref().map_or(0.0, |tt| tt.tax_amount);
392        let calculated_total = sub_total + tax_amount;
393        let difference = (calculated_total - self.total).abs();
394        if difference > 0.01 {
395            // Allow a small tolerance for floating point calculations
396            return Err(Error::Validation(format!(
397                "Total ({}) does not match sub-total ({}) + tax amount ({})",
398                self.total, sub_total, tax_amount
399            )));
400        }
401
402        // Validate date formats
403        if self.issue_date.len() != 10 {
404            return Err(Error::SerializationError(
405                "issue_date must be in YYYY-MM-DD format".to_string(),
406            ));
407        }
408        if chrono::NaiveDate::parse_from_str(&self.issue_date, "%Y-%m-%d").is_err() {
409            return Err(Error::SerializationError(
410                "Invalid issue_date format or value".to_string(),
411            ));
412        }
413
414        if let Some(due_date) = &self.due_date {
415            if due_date.len() != 10 {
416                return Err(Error::SerializationError(
417                    "due_date must be in YYYY-MM-DD format".to_string(),
418                ));
419            }
420            if chrono::NaiveDate::parse_from_str(due_date, "%Y-%m-%d").is_err() {
421                return Err(Error::SerializationError(
422                    "Invalid due_date format or value".to_string(),
423                ));
424            }
425        }
426
427        Ok(())
428    }
429}