1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[builder(on(_, into))]
34pub struct InvoiceReconciliationRecord {
35 pub country_code: CountryCode,
37 pub party_id: PartyId,
39 pub id: CiString<36>,
41 pub invoice_id: CiString<255>,
43 pub cdrs: Vec<CiString<36>>,
45 pub last_updated: DateTime,
47 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
49 #[builder(default)]
50 pub extensions: Extensions,
51}
52
53impl InvoiceReconciliationRecord {
54 #[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 #[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#[derive(Clone, Debug, PartialEq)]
94pub struct Reconciliation {
95 pub invoice_id: String,
97 pub total_excl_taxes: Number,
99 pub total_incl_taxes: Number,
101 pub missing_cdrs: Vec<String>,
105 pub unlisted_cdrs: Vec<String>,
111 pub currencies: Vec<String>,
115}
116
117impl Reconciliation {
118 #[must_use]
120 pub fn is_complete(&self) -> bool {
121 self.missing_cdrs.is_empty()
122 }
123
124 #[must_use]
126 pub fn is_conclusive(&self) -> bool {
127 self.is_complete() && self.currencies.len() == 1
128 }
129
130 #[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#[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 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(¤cy) {
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}