use rust_decimal::Decimal;
use en16931::amount::{InvoiceAmount, UnitPriceAmount};
use en16931::date::Date;
use en16931::invoice::{
Code, Invoice as EnInvoice, InvoiceLine, Item, LineVat, Party, Period, PriceDetails,
};
use en16931::numeric::{Percentage, Quantity};
use crate::invoice::{Invoice, VatCategory};
use crate::position::PositionCategory;
use crate::rates::RoundMoney;
pub const XRECHNUNG_SPEC_ID: &str =
"urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0";
pub const EN16931_SPEC_ID: &str = "urn:cen.eu:en16931:2017";
pub const SECT13B_EXEMPTION_REASON: &str =
"Steuerschuldnerschaft des Leistungsempfängers (§13b UStG)";
pub const VATEX_REVERSE_CHARGE: &str = "VATEX-EU-AE";
pub const ABSCHLAG_ALLOWANCE_REASON: &str = "Abzug erhaltener Abschlagszahlung";
fn unece_unit(unit: &str) -> &'static str {
match unit {
"kWh" | "kWh_th" | "kWh_Hs" => "KWH",
"kW" => "KWT",
"m³" | "m3" => "MTQ",
"Tage" | "Tag" | "d" => "DAY",
"Monat" => "MON",
"Jahr" => "ANN",
_ => "C62",
}
}
impl Invoice {
pub fn to_en16931(
&self,
spec_id: &str,
seller: Party,
buyer: Party,
) -> Result<EnInvoice, crate::EngineError> {
fn calendar_date(d: time::Date) -> Option<Date> {
Date::new(d.year(), d.month() as u8, d.day()).ok()
}
let is_credit = self.context.invoice_type.is_reversal()
|| matches!(self.context.invoice_type, crate::InvoiceType::CreditNote);
let sign = if self.context.invoice_type.is_reversal() {
Decimal::NEGATIVE_ONE
} else {
Decimal::ONE
};
let type_code = if is_credit { "381" } else { "380" };
let default_rate = self.context.regulatory_rates.mwst_rate;
let categories: std::collections::BTreeSet<VatCategory> = self
.positions
.iter()
.filter(|p| {
!matches!(
p.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
})
.map(|p| {
crate::invoice::vat_category_of(
p,
p.applicable_tax_rate.unwrap_or(default_rate).normalize(),
)
})
.collect();
if categories.len() > 1 && categories.iter().any(|c| c.is_exclusive()) {
return Err(crate::EngineError::ValidationBlocked {
warnings: vec![crate::BillingWarning {
code: "EN16931_KATEGORIE_O_NICHT_KOMBINIERBAR",
severity: crate::WarningSeverity::Error,
message: format!(
"EN 16931 BR-O-11 ff.: eine nicht steuerbare Position (Kategorie O, \
z. B. eine öffentlich-rechtliche Abwassergebühr) darf nicht mit \
anderen Steuerkategorien auf einem Beleg stehen. Gefunden: {}",
categories
.iter()
.map(|c| c.code())
.collect::<Vec<_>>()
.join(", ")
),
}],
});
}
let issue = self.context.ausstellungsdatum();
let issue_date =
calendar_date(issue).ok_or_else(|| crate::EngineError::Unrepresentable {
field: "BT-2 (Ausstellungsdatum, § 14 Abs. 4 Nr. 3 UStG)".to_owned(),
value: issue.to_string(),
})?;
let due = self.context.faelligkeitsdatum();
let due_date = calendar_date(due).ok_or_else(|| crate::EngineError::Unrepresentable {
field: "BT-9 (Fälligkeit, § 40c Abs. 1 EnWG)".to_owned(),
value: due.to_string(),
})?;
let mut builder = EnInvoice::builder(
spec_id,
self.context.rechnungsnummer.clone(),
issue_date,
type_code,
"EUR",
)
.seller(seller)
.buyer(buyer)
.due_date(due_date)
.invoicing_period(Period {
start: calendar_date(self.context.period_from()),
end: calendar_date(self.context.period_to()),
});
if is_credit {
builder = builder.credit_note();
}
for p in self
.positions
.iter()
.filter(|p| p.category == PositionCategory::Info)
{
let note = match p.legal_basis.as_deref() {
Some(basis) if basis.contains('\u{a7}') => {
format!("{} ({basis})", p.description)
}
_ => p.description.clone(),
};
builder = builder.note(note);
}
let mut line_no = 0u32;
let mut has_reverse_charge = false;
for p in &self.positions {
if matches!(
p.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
) {
continue;
}
line_no += 1;
let rate = p.applicable_tax_rate.unwrap_or(default_rate).normalize();
let cat = crate::invoice::vat_category_of(p, rate);
if cat == VatCategory::ReverseCharge {
has_reverse_charge = true;
}
let cat = cat.code();
let pct = (rate * Decimal::ONE_HUNDRED).normalize();
let net = (p.net_eur * sign).round_kfm(2);
builder = builder.line(InvoiceLine {
id: line_no.to_string(),
note: None,
order_line_reference: None,
accounting_reference: None,
object_identifier: None,
quantity: Quantity::from(p.quantity),
unit_code: Code::from(unece_unit(&p.unit)),
net_amount: amount("BT-131 (Nettobetrag der Position)", net)?,
period: None,
allowances: Vec::new(),
charges: Vec::new(),
price: PriceDetails {
net_price: UnitPriceAmount::from((p.unit_price_eur * sign).abs()),
..Default::default()
},
vat: LineVat {
category: Code::from(cat),
rate: Some(Percentage::from(pct)),
},
item: Item {
name: Some(p.description.clone()),
..Default::default()
},
});
}
let advances = self.advance_payments()?;
let restrechnung = self.context.settlement_form == crate::SettlementForm::Restrechnung
&& !advances.is_empty()
&& !is_credit;
if restrechnung {
let mut groups: std::collections::BTreeMap<(&'static str, Decimal), Decimal> =
std::collections::BTreeMap::new();
for advance in &advances {
for entry in advance.tax() {
let (cat, rate) = entry.group_key();
*groups.entry((cat.code(), rate)).or_insert(Decimal::ZERO) +=
entry.taxable_base.into_decimal();
}
}
for ((cat, rate), base) in groups {
builder = builder.allowance(en16931::invoice::DocumentAllowanceCharge {
amount: amount("BT-92 (Nachlass auf Dokumentenebene)", base.round_kfm(2))?,
base_amount: None,
percentage: None,
vat: LineVat {
category: Code::from(cat),
rate: Some(Percentage::from((rate * Decimal::ONE_HUNDRED).normalize())),
},
reason: Some(ABSCHLAG_ALLOWANCE_REASON.to_owned()),
reason_code: Some(Code::from("95")),
});
}
}
let mut inv = builder.build();
let paid = (self.abschlag_total_eur * sign).round_kfm(2);
let mut rec = en16931::reconcile::Reconciler::new();
if has_reverse_charge {
rec = rec.exemption(
"AE",
Some(SECT13B_EXEMPTION_REASON),
Some(VATEX_REVERSE_CHARGE),
);
}
if !restrechnung && !paid.is_zero() {
rec = rec.paid(amount("BT-113 (Vorauszahlung)", paid)?);
}
rec.apply(&mut inv)
.map_err(|e| crate::EngineError::ReconciliationFailed {
reason: e.to_string(),
})?;
Ok(inv)
}
}
fn amount(field: &str, d: Decimal) -> Result<InvoiceAmount, crate::EngineError> {
InvoiceAmount::try_from(d).map_err(|_| crate::EngineError::Unrepresentable {
field: field.to_owned(),
value: d.to_string(),
})
}