Skip to main content

ocpi_kit/v2_3_0/
invoice_reconciliation.rs

1//! The *Invoice Reconciliation* module, from the OCPI 2.3.0 `payments` release branch.
2//!
3//! *Module Identifier: `invoicereconciliation`* — Data owner: CPO **or** eMSP.
4//!
5//! > *Invoice Reconciliation enables Parties that receive invoices for Charging Sessions to check
6//! > the amounts of these invoices against the CDR data that they transferred via OCPI.*
7//!
8//! The record itself is deliberately small: an invoice identifier and the list of CDR ids that
9//! invoice covers. Everything else — when to invoice, how the document is delivered, how it is
10//! paid — is left to the parties.
11//!
12//! The reconciliation itself is a local computation, and this crate can do it:
13//! [`reconcile`] adds up the CDRs a record names and compares the total to the invoice.
14//!
15//! Spec: 2.3.0-payments §mod_invoice_reconciliation_module
16
17use bon::Builder;
18use serde::{Deserialize, Serialize};
19
20use crate::types::validate_fields;
21use crate::types::{
22    CiString, CountryCode, DateTime, Extensions, Number, PartyId, PartyRef, Validate, Validator,
23    ViolationCode,
24};
25
26use super::cdrs::Cdr;
27
28/// One invoice, and the CDRs it covers.
29///
30/// Spec: 2.3.0-payments §mod_invoice_reconciliation_record_object
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[builder(on(_, into))]
34pub struct InvoiceReconciliationRecord {
35    /// ISO-3166 alpha-2 country code of the party that 'owns' this record.
36    pub country_code: CountryCode,
37    /// ID of the party that 'owns' this record.
38    pub party_id: PartyId,
39    /// Uniquely identifies this record.
40    pub id: CiString<36>,
41    /// An identifier for the invoice this record is about.
42    pub invoice_id: CiString<255>,
43    /// The CDRs invoiced by that invoice. Cardinality `+`.
44    pub cdrs: Vec<CiString<36>>,
45    /// When this record was issued.
46    pub last_updated: DateTime,
47    /// Undocumented JSON fields, preserved verbatim.
48    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
49    #[builder(default)]
50    pub extensions: Extensions,
51}
52
53impl InvoiceReconciliationRecord {
54    /// The party that issued this record.
55    #[must_use]
56    pub fn owner_party(&self) -> PartyRef {
57        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
58    }
59
60    /// Whether this record covers a given CDR id, comparing case-insensitively.
61    #[must_use]
62    pub fn covers(&self, cdr_id: &str) -> bool {
63        self.cdrs.iter().any(|id| id.eq_ignore_case(cdr_id))
64    }
65}
66
67impl Validate for InvoiceReconciliationRecord {
68    fn validate_in(&self, v: &mut Validator) {
69        validate_fields!(self, v, country_code, party_id, id, invoice_id, cdrs, last_updated);
70        if self.cdrs.is_empty() {
71            v.report_at(
72                "cdrs",
73                ViolationCode::EmptyRequiredList,
74                "an Invoice Reconciliation Record has cardinality `+` cdrs: an invoice that \
75                 covers no CDR cannot be reconciled",
76            );
77        }
78        let mut seen: Vec<&CiString<36>> = Vec::new();
79        for id in &self.cdrs {
80            if seen.contains(&id) {
81                v.report_at(
82                    "cdrs",
83                    ViolationCode::Inconsistent,
84                    format!("the CDR {:?} is listed more than once", id.as_str()),
85                );
86            }
87            seen.push(id);
88        }
89    }
90}
91
92/// The outcome of checking an invoice against the CDRs it claims to cover.
93#[derive(Clone, Debug, PartialEq)]
94pub struct Reconciliation {
95    /// The record that was checked.
96    pub invoice_id: String,
97    /// The total of the CDRs that were found, excluding taxes.
98    pub total_excl_taxes: Number,
99    /// The total of the CDRs that were found, including the taxes each CDR carries.
100    pub total_incl_taxes: Number,
101    /// CDR ids the record names that were not among the CDRs supplied.
102    ///
103    /// A non-empty list means the check is incomplete, not that the invoice is wrong.
104    pub missing_cdrs: Vec<String>,
105    /// CDRs that were supplied but the record does not name.
106    ///
107    /// Not an error: an invoice covers the CDRs it lists, and *"the set of invoices referenced by
108    /// an Invoice Reconciliation Record is not determined by timing, but by the list of invoice
109    /// IDs"*.
110    pub unlisted_cdrs: Vec<String>,
111    /// The currencies encountered, in the order first seen.
112    ///
113    /// More than one is a problem: the totals are then not comparable to a single invoice amount.
114    pub currencies: Vec<String>,
115}
116
117impl Reconciliation {
118    /// Whether every CDR the record names was available to check.
119    #[must_use]
120    pub fn is_complete(&self) -> bool {
121        self.missing_cdrs.is_empty()
122    }
123
124    /// Whether the totals are meaningful: complete, and in a single currency.
125    #[must_use]
126    pub fn is_conclusive(&self) -> bool {
127        self.is_complete() && self.currencies.len() == 1
128    }
129
130    /// The difference between an invoiced amount and the computed pre-tax total.
131    ///
132    /// Positive means the invoice asks for more than the CDRs add up to.
133    #[must_use]
134    pub fn difference_from(&self, invoiced_excl_taxes: Number) -> Number {
135        invoiced_excl_taxes - self.total_excl_taxes
136    }
137}
138
139/// Adds up the CDRs an Invoice Reconciliation Record names.
140///
141/// `cdrs` may contain more CDRs than the record covers; only the ones it names are counted, and
142/// the rest are reported as [`Reconciliation::unlisted_cdrs`].
143///
144/// ```
145/// # use ocpi_kit::v2_3_0::invoice_reconciliation::{reconcile, InvoiceReconciliationRecord};
146/// # use ocpi_kit::v2_3_0::cdrs::Cdr;
147/// # fn check(record: &InvoiceReconciliationRecord, cdrs: &[Cdr]) {
148/// let result = reconcile(record, cdrs);
149/// if !result.is_conclusive() {
150///     eprintln!("cannot check invoice {}: {:?}", result.invoice_id, result.missing_cdrs);
151/// }
152/// println!("the CDRs add up to {}", result.total_incl_taxes);
153/// # }
154/// ```
155///
156/// Spec: 2.3.0-payments §mod_invoice_reconciliation_flow_and_lifecycle
157#[must_use]
158pub fn reconcile(record: &InvoiceReconciliationRecord, cdrs: &[Cdr]) -> Reconciliation {
159    let mut total_excl = Number::ZERO;
160    let mut total_incl = Number::ZERO;
161    let mut currencies: Vec<String> = Vec::new();
162    let mut found: Vec<String> = Vec::new();
163
164    for cdr in cdrs {
165        if !record.covers(cdr.id.as_str()) {
166            continue;
167        }
168        found.push(cdr.id.as_str().to_owned());
169        // A credit CDR corrects an earlier one, so it counts against the invoice.
170        let sign = if cdr.is_credit() { -Number::ONE } else { Number::ONE };
171        total_excl = total_excl + cdr.total_cost.before_taxes * sign;
172        total_incl = total_incl + cdr.total_cost.after_taxes() * sign;
173        let currency = cdr.currency.as_str().to_owned();
174        if !currencies.contains(&currency) {
175            currencies.push(currency);
176        }
177    }
178
179    let missing_cdrs = record
180        .cdrs
181        .iter()
182        .filter(|id| !found.iter().any(|f| f.eq_ignore_ascii_case(id.as_str())))
183        .map(|id| id.as_str().to_owned())
184        .collect();
185    let unlisted_cdrs = cdrs
186        .iter()
187        .filter(|cdr| !record.covers(cdr.id.as_str()))
188        .map(|cdr| cdr.id.as_str().to_owned())
189        .collect();
190
191    Reconciliation {
192        invoice_id: record.invoice_id.as_str().to_owned(),
193        total_excl_taxes: total_excl,
194        total_incl_taxes: total_incl,
195        missing_cdrs,
196        unlisted_cdrs,
197        currencies,
198    }
199}
200
201#[cfg(all(test, feature = "testkit"))]
202mod tests {
203    use super::*;
204    use crate::types::Extensions;
205    use crate::v2_3_0::types::Price;
206
207    fn cdr(id: &str, cost: &str, credit: bool) -> Cdr {
208        let mut cdr = crate::testkit::sample::cdr(id).unwrap();
209        cdr.total_cost = Price {
210            before_taxes: cost.parse().unwrap(),
211            taxes: vec![
212                super::super::types::TaxAmount::new(
213                    "VAT",
214                    Some(Number::from(10u32)),
215                    (cost.parse::<Number>().unwrap() / Number::from(10u32)).round_dp(2),
216                )
217                .unwrap(),
218            ],
219            extensions: Extensions::new(),
220        };
221        cdr.credit = credit.then_some(true);
222        if credit {
223            cdr.credit_reference_id = Some(CiString::new("CDR1").unwrap());
224        }
225        cdr
226    }
227
228    fn record(cdr_ids: &[&str]) -> InvoiceReconciliationRecord {
229        InvoiceReconciliationRecord::builder()
230            .country_code("NL")
231            .party_id("TNM")
232            .id("IRR1")
233            .invoice_id("INV-2024-03")
234            .cdrs(cdr_ids.iter().map(|id| CiString::new(*id).unwrap()).collect::<Vec<_>>())
235            .last_updated("2024-04-01T00:00:00Z".parse::<DateTime>().unwrap())
236            .build()
237    }
238
239    #[test]
240    fn the_named_cdrs_are_added_up_and_the_rest_ignored() {
241        let record = record(&["CDR1", "CDR2"]);
242        let cdrs = vec![cdr("CDR1", "10.00", false), cdr("CDR2", "5.00", false), cdr("CDR3", "99.00", false)];
243        let result = reconcile(&record, &cdrs);
244        assert_eq!(result.total_excl_taxes.to_string(), "15.00");
245        assert_eq!(result.total_incl_taxes.to_string(), "16.50");
246        assert_eq!(result.unlisted_cdrs, vec!["CDR3".to_owned()]);
247        assert!(result.is_conclusive());
248        assert_eq!(result.difference_from("15.00".parse().unwrap()), Number::ZERO);
249    }
250
251    #[test]
252    fn a_credit_cdr_counts_against_the_invoice() {
253        let record = record(&["CDR1", "CDR2"]);
254        let cdrs = vec![cdr("CDR1", "10.00", false), cdr("CDR2", "4.00", true)];
255        let result = reconcile(&record, &cdrs);
256        assert_eq!(result.total_excl_taxes.to_string(), "6.00");
257    }
258
259    #[test]
260    fn a_missing_cdr_makes_the_check_inconclusive() {
261        let record = record(&["CDR1", "CDR2"]);
262        let result = reconcile(&record, &[cdr("CDR1", "10.00", false)]);
263        assert_eq!(result.missing_cdrs, vec!["CDR2".to_owned()]);
264        assert!(!result.is_complete() && !result.is_conclusive());
265    }
266
267    #[test]
268    fn several_currencies_make_the_totals_meaningless() {
269        let record = record(&["CDR1", "CDR2"]);
270        let mut second = cdr("CDR2", "5.00", false);
271        second.currency = crate::types::Currency::new("CHF").unwrap();
272        let result = reconcile(&record, &[cdr("CDR1", "10.00", false), second]);
273        assert_eq!(result.currencies.len(), 2);
274        assert!(result.is_complete());
275        assert!(!result.is_conclusive(), "two currencies cannot be compared to one amount");
276    }
277
278    #[test]
279    fn a_record_that_covers_no_cdr_is_reported() {
280        let empty = InvoiceReconciliationRecord { cdrs: Vec::new(), ..record(&["CDR1"]) };
281        assert_eq!(empty.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
282        let duplicated = record(&["CDR1", "cdr1"]);
283        assert!(duplicated.validate().is_err(), "ids compare case-insensitively");
284    }
285
286    #[test]
287    fn round_trips_through_json() {
288        let json = r#"{"country_code":"NL","party_id":"TNM","id":"IRR1","invoice_id":"INV-2024-03","cdrs":["CDR1","CDR2"],"last_updated":"2024-04-01T00:00:00Z"}"#;
289        let record: InvoiceReconciliationRecord = serde_json::from_str(json).unwrap();
290        assert!(record.covers("cdr1"));
291        assert_eq!(serde_json::to_string(&record).unwrap(), json);
292    }
293}