use crate::context::BillingContext;
use crate::error::EngineError;
use crate::invoice::Invoice;
use crate::position::{BillingPosition, BillingWarning, WarningSeverity};
use crate::provider::BillingProvider;
use crate::quantities::Quantities;
use crate::rates::RoundMoney;
#[derive(Default)]
pub struct BillingEngine {
providers: Vec<Box<dyn BillingProvider>>,
}
impl BillingEngine {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
#[allow(clippy::should_implement_trait)] pub fn add<P: BillingProvider + 'static>(mut self, provider: P) -> Self {
self.providers.push(Box::new(provider));
self
}
#[must_use]
pub fn validate(&self, ctx: &BillingContext, quantities: &Quantities) -> Vec<BillingWarning> {
self.providers
.iter()
.flat_map(|p| p.validate_warnings(ctx, quantities))
.collect()
}
pub fn bill_batch(
&self,
batch: Vec<(BillingContext, Quantities)>,
) -> Vec<Result<Invoice, EngineError>> {
batch
.into_iter()
.map(|(ctx, quantities)| self.bill(ctx, &quantities))
.collect()
}
pub fn bill(
&self,
ctx: BillingContext,
quantities: &Quantities,
) -> Result<Invoice, EngineError> {
let mut warnings: Vec<BillingWarning> = context_warnings(&ctx);
warnings.extend(zero_quantity_warning(&ctx, quantities));
warnings.extend(coverage_warning(quantities));
for provider in self.providers.iter() {
warnings.extend(provider.validate_warnings(&ctx, quantities));
}
if warnings
.iter()
.any(|x| x.severity == WarningSeverity::Error)
{
return Err(EngineError::ValidationBlocked { warnings });
}
let mut positions: Vec<BillingPosition> = Vec::new();
for provider in self.providers.iter().filter(|p| !p.is_tax_pass()) {
let new = provider.bill(&ctx, quantities, &positions)?;
positions.extend(new);
}
if ctx.reverse_charge {
use crate::position::PositionCategory;
positions = positions
.into_iter()
.map(|p| {
if matches!(
p.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
) {
p
} else {
p.with_reverse_charge()
}
})
.collect();
}
let charged_rate = self
.providers
.iter()
.filter(|p| p.is_tax_pass())
.find_map(|p| p.charged_tax_rate());
if let Some(charged) = charged_rate {
use crate::position::PositionCategory;
for p in &mut positions {
if p.applicable_tax_rate.is_none()
&& !matches!(
p.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
p.applicable_tax_rate = Some(charged);
}
}
}
let pre_tax_snap: Vec<BillingPosition> = positions.clone();
for provider in self.providers.iter().filter(|p| p.is_tax_pass()) {
let new = provider.bill(&ctx, quantities, &pre_tax_snap)?;
positions.extend(new);
}
if ctx.invoice_type.settles_advances() {
for abschlag in &ctx.abschlage {
let label = abschlag
.beschreibung
.clone()
.unwrap_or_else(|| format!("Abschlag {}", abschlag.datum));
positions.push(
crate::position::BillingPosition::debit(
label,
rust_decimal::Decimal::ONE,
"EUR",
-abschlag.betrag_eur, crate::position::PositionCategory::Abschlag,
)
.with_legal_basis("§40 EnWG"),
);
}
}
if let Some(min_brutto) = ctx.minimum_invoice_eur_brutto {
let current_invoice = Invoice::from_positions(ctx.clone(), positions.clone(), vec![]);
let current_brutto = current_invoice.brutto_eur;
if current_brutto < min_brutto {
let gap_brutto = min_brutto - current_brutto;
let mwst_rate = ctx
.minimum_invoice_mwst_rate
.or(charged_rate)
.unwrap_or(ctx.regulatory_rates.mwst_rate);
let divisor = if ctx.reverse_charge {
rust_decimal::Decimal::ONE
} else {
rust_decimal::Decimal::ONE + mwst_rate
};
let gap_netto = if divisor.is_zero() {
gap_brutto
} else {
(gap_brutto / divisor).round_kfm(5)
};
let mut positions2: Vec<BillingPosition> = positions
.iter()
.filter(|p| p.category != crate::position::PositionCategory::Tax)
.cloned()
.collect();
let mut topup = crate::position::BillingPosition::debit(
format!("Mindestbetrag (Minimum {min_brutto:.2}\u{202f}EUR brutto)"),
rust_decimal::Decimal::ONE,
"EUR",
gap_netto,
crate::position::PositionCategory::Commodity,
)
.with_legal_basis("Vertraglich")
.with_tag("mindestbetrag");
if let Some(rate) = ctx.minimum_invoice_mwst_rate.or(charged_rate) {
topup = topup.with_tax_rate(rate);
}
if ctx.reverse_charge {
topup = topup.with_reverse_charge();
}
positions2.push(topup);
let pre_tax2: Vec<BillingPosition> = positions2.clone();
for provider in self.providers.iter().filter(|p| p.is_tax_pass()) {
let new = provider.bill(&ctx, quantities, &pre_tax2)?;
positions2.extend(new);
}
positions = positions2;
}
}
if ctx.invoice_type.is_reversal() {
negate_positions(&mut positions);
}
Ok(Invoice::from_positions(ctx, positions, warnings))
}
}
fn context_warnings(ctx: &BillingContext) -> Vec<BillingWarning> {
let mut warnings = Vec::new();
if ctx.vertragsart == crate::context::Vertragsart::Ersatzversorgung {
let beginn = ctx.vertragsbeginn.unwrap_or_else(|| ctx.period_from());
if ctx.vertragsbeginn.is_none() {
warnings.push(BillingWarning {
code: "ERSATZVERSORGUNG_BEGINN_FEHLT",
severity: WarningSeverity::Warning,
message: format!(
"Ersatzversorgung ohne Belieferungsbeginn im Kontext: die \
Drei-Monats-Grenze des § 38 Abs. 4 EnWG wird ab dem \
Zeitraumbeginn {beginn} gemessen — vertragsbeginn setzen, \
wenn die Belieferung früher begann"
),
});
}
let limit = add_months(beginn, 3);
if ctx.period_to() >= limit {
warnings.push(BillingWarning {
code: "ERSATZVERSORGUNG_UEBER_3_MONATE",
severity: WarningSeverity::Error,
message: format!(
"Ersatzversorgung endet spätestens drei Monate nach Beginn \
der Belieferung am {beginn} (§ 38 Abs. 4 EnWG): Zeitraum \
{}..{} überschreitet die Grenze {limit}",
ctx.period_from(),
ctx.period_to(),
),
});
}
}
warnings
}
fn zero_quantity_warning(ctx: &BillingContext, quantities: &Quantities) -> Option<BillingWarning> {
if ctx.days() <= 1 {
return None;
}
let empty = quantities.empty_energy_sources();
if empty.is_empty() {
return None;
}
Some(BillingWarning {
code: "KEINE_MENGE",
severity: WarningSeverity::Warning,
message: format!(
"kein Verbrauch im Zeitraum {}..{}: {} liefer(n) 0 — die Rechnung stellt \
nur Grund- und Leistungspreise. Fehlt die Ablesung, ist die Menge \
nachzuliefern, bevor abgerechnet wird",
ctx.period_from(),
ctx.period_to(),
empty.join(", "),
),
})
}
fn coverage_warning(quantities: &Quantities) -> Vec<BillingWarning> {
let full = rust_decimal::Decimal::ONE_HUNDRED;
[
(
"Strom",
quantities.electricity.as_ref().and_then(|m| m.coverage_pct),
),
("Gas", quantities.gas.as_ref().and_then(|m| m.coverage_pct)),
]
.into_iter()
.filter_map(|(label, pct)| {
let pct = pct?;
(pct < full).then(|| BillingWarning {
code: "MENGE_UNVOLLSTAENDIG",
severity: WarningSeverity::Warning,
message: format!(
"{label}: nur {pct} % des Abrechnungszeitraums sind durch abrechenbare \
Messwerte gedeckt — die Lücke ist nach § 40a Abs. 2 EnWG zu schätzen \
und die Schätzung auf der Rechnung hervorgehoben auszuweisen"
),
})
})
.collect()
}
fn add_months(date: time::Date, months: i32) -> time::Date {
let total = date.month() as i32 - 1 + months;
let year = date.year() + total.div_euclid(12);
let month = time::Month::try_from((total.rem_euclid(12) + 1) as u8).expect("1..=12");
let day = date.day().min(time::util::days_in_month(month, year));
time::Date::from_calendar_date(year, month, day).expect("valid clamped date")
}
fn negate_positions(positions: &mut [crate::position::BillingPosition]) {
for p in positions.iter_mut() {
p.net_eur = -p.net_eur;
p.unit_price_eur = -p.unit_price_eur;
}
}