use billing::{BillingDocument, LineItem, Sign};
use rust_decimal::Decimal;
use crate::extensions::{AdvancePayment, Extensions};
use crate::invoice::{
Code, DocumentAllowanceCharge, DocumentTotals, Invoice, Item, LineAllowanceCharge, LineVat,
Party, Period, PriceDetails, VatBreakdown,
};
use crate::{Date, InvoiceAmount, InvoiceLine, Percentage, Quantity, UnitPriceAmount};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConversionError {
#[error(
"{what} = {value} needs more than two decimals; rebuild the document with \
`.amount_scale(AmountScale::EN16931)` rather than rounding here"
)]
PrecisionLoss {
what: String,
value: String,
},
#[error("position {index} ({description:?}) has no VAT attribution; BR-CO-04 requires BT-151")]
NoVatAttribution {
index: usize,
description: String,
},
#[error(
"unit label {label:?} on position {index} has no BT-130 code; set `Quantity::code` \
or extend the `UnitResolver`"
)]
UnresolvedUnit {
index: usize,
label: String,
},
#[error("{field} = {value:?} is not an ISO 8601 calendar date")]
UnparsableDate {
field: &'static str,
value: String,
},
#[error(
"the document's currency is {0}; XXX means \"no currency involved\" and a document \
still carrying it was never configured"
)]
NoCurrency(String),
#[error("billing: {0}")]
Billing(String),
}
#[derive(Debug, Clone, Default)]
pub struct UnitResolver {
extra: Vec<(String, String)>,
}
const BUILT_IN: &[(&str, &str)] = &[
("kWh", "KWH"),
("MWh", "MWH"),
("Wh", "WHR"),
("kW", "KWT"),
("m³", "MTQ"),
("m3", "MTQ"),
("m²", "MTK"),
("m", "MTR"),
("km", "KMT"),
("kg", "KGM"),
("g", "GRM"),
("t", "TNE"),
("l", "LTR"),
("h", "HUR"),
("d", "DAY"),
("Monat", "MON"),
("month", "MON"),
("Stk", "H87"),
("Stück", "H87"),
("pcs", "H87"),
("piece", "H87"),
("Stunde", "HUR"),
("%", "P1"),
("Pauschale", "C62"),
("one", "C62"),
];
impl UnitResolver {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, label: impl Into<String>, code: impl Into<String>) -> Self {
self.extra.push((label.into(), code.into()));
self
}
#[must_use]
pub fn resolve(&self, label: &str) -> Option<&str> {
self.extra
.iter()
.find(|(l, _)| l == label)
.map(|(_, c)| c.as_str())
.or_else(|| BUILT_IN.iter().find(|(l, _)| *l == label).map(|(_, c)| *c))
}
}
fn amount(a: billing::Amount<5>, what: &str) -> Result<InvoiceAmount, ConversionError> {
a.exact_to::<2>()
.map_err(|_| ConversionError::PrecisionLoss {
what: what.to_owned(),
value: a.to_string(),
})
.map(|v: billing::Amount<2>| InvoiceAmount::from_minor_units(v.to_raw()))
}
fn rate(fraction: Decimal) -> Percentage {
Percentage::from_fraction(fraction).unwrap_or_else(|| Percentage::new(fraction))
}
fn date(s: Option<&str>, field: &'static str) -> Result<Option<Date>, ConversionError> {
s.map(|v| {
Date::parse(v).map_err(|_| ConversionError::UnparsableDate {
field,
value: v.to_owned(),
})
})
.transpose()
}
fn period(
p: Option<&billing::Period>,
what: &'static str,
) -> Result<Option<Period>, ConversionError> {
p.map(|p| {
Ok(Period {
start: date(Some(&p.from), what)?,
end: date(Some(&p.to), what)?,
})
})
.transpose()
}
fn line_vat(v: Option<&billing::vat::LineVat>) -> Option<LineVat> {
v.map(|v| LineVat {
category: Code::new(v.category.code()),
rate: crate::VatCategory::from_code(v.category.code())
.is_none_or(crate::VatCategory::states_rate)
.then(|| rate(v.rate)),
})
}
fn allowance_charge(item: &LineItem) -> (Option<InvoiceAmount>, Option<Percentage>, Option<Code>) {
match &item.allowance_charge {
Some(ac) => (
ac.base_amount
.and_then(|b| b.exact_to::<2>().ok())
.map(|v: billing::Amount<2>| InvoiceAmount::from_minor_units(v.to_raw())),
ac.percentage.map(Percentage::new),
ac.reason_code.as_deref().map(Code::new),
),
None => (None, None, None),
}
}
pub struct FromBilling<'a> {
doc: &'a BillingDocument,
specification_id: Option<String>,
seller: Party,
buyer: Party,
units: UnitResolver,
verify_attribution: bool,
}
impl<'a> FromBilling<'a> {
#[must_use]
pub fn new(doc: &'a BillingDocument) -> Self {
Self {
doc,
specification_id: None,
seller: Party::default(),
buyer: Party::default(),
units: UnitResolver::new(),
verify_attribution: true,
}
}
#[must_use]
pub fn specification_id(mut self, id: impl Into<String>) -> Self {
self.specification_id = Some(id.into());
self
}
#[must_use]
pub fn seller(mut self, seller: Party) -> Self {
self.seller = seller;
self
}
#[must_use]
pub fn buyer(mut self, buyer: Party) -> Self {
self.buyer = buyer;
self
}
#[must_use]
pub fn units(mut self, units: UnitResolver) -> Self {
self.units = units;
self
}
#[must_use]
pub fn allow_unverified_attribution(mut self) -> Self {
self.verify_attribution = false;
self
}
pub fn build(self) -> Result<Invoice, ConversionError> {
let doc = self.doc;
let currency = doc.currency();
if currency.is_unset() {
return Err(ConversionError::NoCurrency(currency.code().to_owned()));
}
if self.verify_attribution {
doc.verify_vat_attribution()
.map_err(|e| ConversionError::Billing(e.to_string()))?;
}
let totals = self.totals()?;
let mut inv = Invoice {
specification_id: self.specification_id.clone(),
number: Some(doc.meta.invoice_number.clone()).filter(|s| !s.is_empty()),
issue_date: date(doc.meta.issue_date.as_deref(), "issue_date")?,
due_date: date(doc.meta.due_date.as_deref(), "due_date")?,
type_code: Some(Code::new(doc.meta.kind.code().to_string())),
currency: Some(Code::new(currency.code())),
invoicing_period: period(doc.meta.period.as_ref(), "period")?,
notes: doc
.meta
.notes
.iter()
.map(crate::invoice::InvoiceNote::new)
.collect(),
seller: self.seller.clone(),
buyer: self.buyer.clone(),
..Default::default()
};
for (i, item) in doc.net_positions().iter().enumerate() {
inv.lines.push(self.line(i, item)?);
}
for (i, item) in doc.discount_positions().iter().enumerate() {
let (base, pct, reason_code) = allowance_charge(item);
inv.allowances.push(DocumentAllowanceCharge {
amount: amount(
item.net_amount
.checked_neg()
.map_err(|e| ConversionError::Billing(e.to_string()))?,
&format!("discount[{i}] BT-92"),
)?,
base_amount: base,
percentage: pct,
vat: line_vat(item.vat.as_ref()).ok_or_else(|| {
ConversionError::NoVatAttribution {
index: i,
description: item.description.clone(),
}
})?,
reason: Some(item.description.clone()),
reason_code,
});
}
for (i, item) in doc.charge_positions().enumerate() {
let (base, pct, reason_code) = allowance_charge(item);
inv.charges.push(DocumentAllowanceCharge {
amount: amount(item.net_amount, &format!("charge[{i}] BT-99"))?,
base_amount: base,
percentage: pct,
vat: line_vat(item.vat.as_ref()).ok_or_else(|| {
ConversionError::NoVatAttribution {
index: i,
description: item.description.clone(),
}
})?,
reason: Some(item.description.clone()),
reason_code,
});
}
for (i, e) in doc.tax_breakdown().iter().enumerate() {
let category = Code::new(e.category.code());
let states_rate = crate::VatCategory::from_code(e.category.code()).is_none_or(|_| true); inv.vat_breakdown.push(VatBreakdown {
taxable_amount: amount(e.taxable_base, &format!("BG-23[{i}] BT-116"))?,
tax_amount: amount(e.tax_amount, &format!("BG-23[{i}] BT-117"))?,
category,
rate: states_rate.then(|| rate(e.rate)),
exemption_reason: e.exemption_reason.clone(),
exemption_reason_code: e.exemption_reason_code.as_deref().map(Code::new),
});
}
inv.extensions = self.advances()?;
inv.totals = totals;
Ok(inv)
}
fn line(&self, i: usize, item: &LineItem) -> Result<InvoiceLine, ConversionError> {
let quantity = item.quantity.as_ref();
let unit_code = match quantity {
Some(q) => match q.code.as_deref() {
Some(c) => c.to_owned(),
None => self
.units
.resolve(&q.unit)
.ok_or_else(|| ConversionError::UnresolvedUnit {
index: i,
label: q.unit.clone(),
})?
.to_owned(),
},
None => "C62".to_owned(),
};
let net = amount(item.net_amount, &format!("line[{i}] BT-131"))?;
let (bt_129, bt_146) = match (quantity, item.unit_price.as_ref()) {
(Some(q), Some(p)) => {
let mut qty = q.value;
let mut price = p.value;
if item.sign == Sign::Credit {
qty = -qty;
}
if price < Decimal::ZERO {
price = -price;
qty = -qty;
}
(Quantity::new(qty), UnitPriceAmount::new(price))
}
_ => {
let one = if item.sign == Sign::Credit {
Decimal::NEGATIVE_ONE
} else {
Decimal::ONE
};
let abs = net.into_decimal().abs();
(Quantity::new(one), UnitPriceAmount::new(abs))
}
};
Ok(InvoiceLine {
id: (i + 1).to_string(),
note: None,
order_line_reference: None,
accounting_reference: None,
object_identifier: None,
quantity: bt_129,
unit_code: Code::new(unit_code),
net_amount: net,
period: period(item.period.as_ref(), "line period")?,
allowances: self.line_allowances(item, billing::AllowanceKind::Allowance)?,
charges: self.line_allowances(item, billing::AllowanceKind::Charge)?,
price: PriceDetails {
net_price: bt_146,
price_discount: item
.unit_price
.as_ref()
.and_then(|p| p.price_discount)
.map(UnitPriceAmount::new),
gross_price: item
.unit_price
.as_ref()
.and_then(|p| p.gross_price)
.map(UnitPriceAmount::new),
base_quantity: item
.unit_price
.as_ref()
.and_then(|p| p.base_quantity)
.map(Quantity::new),
base_quantity_code: item
.unit_price
.as_ref()
.and_then(|p| p.base_quantity_code.clone())
.map(Code::new),
},
vat: line_vat(item.vat.as_ref()).ok_or_else(|| ConversionError::NoVatAttribution {
index: i,
description: item.description.clone(),
})?,
item: Item {
name: Some(item.description.clone()),
..Default::default()
},
})
}
fn line_allowances(
&self,
item: &LineItem,
kind: billing::AllowanceKind,
) -> Result<Vec<LineAllowanceCharge>, ConversionError> {
item.line_allowances
.iter()
.filter(|a| a.kind == kind)
.map(|a| {
Ok(LineAllowanceCharge {
amount: amount(a.amount, "line allowance/charge")?,
base_amount: a
.base_amount
.map(|b| amount(b, "line allowance/charge base"))
.transpose()?,
percentage: a.percentage.map(Percentage::new),
reason: a.reason.clone(),
reason_code: a.reason_code.as_deref().map(Code::new),
})
})
.collect()
}
fn advances(&self) -> Result<Extensions, ConversionError> {
let billing_err = |e: billing::BillingError| ConversionError::Billing(e.to_string());
let mut out = Vec::new();
for a in self.doc.advances() {
out.push(AdvancePayment {
gross: amount(a.checked_gross().map_err(billing_err)?, "BT-X-291")?,
received_on: date(a.received_on(), "advance received_on")?,
tax: a
.tax()
.iter()
.map(|e| {
Ok(VatBreakdown {
taxable_amount: amount(e.taxable_base, "BG-X-46 base")?,
tax_amount: amount(e.tax_amount, "BG-X-46 tax")?,
category: Code::new(e.category.code()),
rate: Some(rate(e.rate)),
exemption_reason: e.exemption_reason.clone(),
exemption_reason_code: e
.exemption_reason_code
.as_deref()
.map(Code::new),
})
})
.collect::<Result<Vec<_>, ConversionError>>()?,
reference: a.reference().map(crate::DocumentReference::new),
reference_date: date(a.reference_date(), "advance reference_date")?,
});
}
Ok(Extensions {
sub_invoice_lines: Vec::new(),
third_party_payments: Vec::new(),
advance_payments: out,
})
}
fn totals(&self) -> Result<DocumentTotals, ConversionError> {
let doc = self.doc;
let billing_err = |e: billing::BillingError| ConversionError::Billing(e.to_string());
let line_total = amount(doc.line_total().map_err(billing_err)?, "BT-106")?;
let allowances = doc.discount_total();
let charges = doc.charge_total().map_err(billing_err)?;
let vat = doc.vat_total().map_err(billing_err)?;
Ok(DocumentTotals {
line_total,
allowance_total: (!doc.discount_positions().is_empty())
.then(|| amount(allowances.checked_neg().map_err(billing_err)?, "BT-107"))
.transpose()?,
charge_total: (doc.charge_positions().next().is_some())
.then(|| amount(charges, "BT-108"))
.transpose()?,
taxable_total: amount(doc.taxable_total().map_err(billing_err)?, "BT-109")?,
vat_total: (!doc.tax_breakdown().is_empty())
.then(|| amount(vat, "BT-110"))
.transpose()?,
vat_total_accounting: None,
gross_total: amount(doc.gross_total(), "BT-112")?,
paid: (!doc.prepaid().is_zero())
.then(|| amount(doc.prepaid(), "BT-113"))
.transpose()?,
rounding: (!doc.rounding().is_zero())
.then(|| amount(doc.rounding(), "BT-114"))
.transpose()?,
due: amount(doc.amount_due().map_err(billing_err)?, "BT-115")?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_resolver_prefers_caller_mappings_and_refuses_to_guess() {
let r = UnitResolver::new().with("kWh", "XXX").with("Kiste", "BX");
assert_eq!(r.resolve("kWh"), Some("XXX"), "caller overrides built-in");
assert_eq!(r.resolve("Kiste"), Some("BX"));
assert_eq!(r.resolve("Stk"), Some("H87"), "built-in still reachable");
assert_eq!(r.resolve("Furlong"), None, "never guesses");
}
#[test]
fn every_built_in_unit_code_is_real() {
for (label, code) in BUILT_IN {
assert!(
crate::codes::contains(crate::codes::generated::UNIT_CODES, code),
"{label} maps to {code}, which is not in BR-CL-23's list"
);
}
}
#[test]
fn rates_convert_from_fraction_to_per_cent() {
assert_eq!(
rate(rust_decimal::dec!(0.19)),
Percentage::new(rust_decimal::dec!(19))
);
assert_eq!(
rate(rust_decimal::dec!(0.075)),
Percentage::new(rust_decimal::dec!(7.5))
);
assert_eq!(rate(Decimal::ZERO), Percentage::ZERO);
}
}