use core::fmt;
use core::marker::PhantomData;
use crate::bt::{BtId, Path};
use crate::invoice::Invoice;
use crate::validation::{Finding, Rule, Severity, ValidationReport, validate_with_all};
pub type Occurrences = Vec<(Path, Option<String>)>;
pub type Rejected = Box<(Invoice, ValidationReport)>;
pub struct TermAccessor {
pub term: BtId,
pub name: &'static str,
pub read: fn(&Invoice) -> Occurrences,
}
impl fmt::Debug for TermAccessor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.term, self.name)
}
}
pub mod terms {
use super::TermAccessor;
use crate::bt::{BtId, Group, Path};
use crate::invoice::terms as bt;
macro_rules! doc_term {
($konst:ident, $bt:expr, $name:literal, $group:expr, |$inv:ident| $get:expr) => {
#[doc = concat!($name, ".")]
pub static $konst: TermAccessor = TermAccessor {
term: $bt,
name: $name,
read: |$inv: &crate::invoice::Invoice| vec![(Path::group_term($group, $bt), $get)],
};
};
}
doc_term!(
BUYER_REFERENCE,
bt::BUYER_REFERENCE,
"Buyer reference",
Group::Document,
|inv| inv.buyer_reference.clone()
);
doc_term!(SELLER_CITY, BtId(37), "Seller city", Group::Seller, |inv| {
inv.seller.address.city.clone()
});
doc_term!(
SELLER_POST_CODE,
BtId(38),
"Seller post code",
Group::Seller,
|inv| inv.seller.address.post_code.clone()
);
doc_term!(
SELLER_CONTACT_POINT,
BtId(41),
"Seller contact point",
Group::Seller,
|inv| inv.seller.contact.name.clone()
);
doc_term!(
SELLER_CONTACT_PHONE,
BtId(42),
"Seller contact telephone number",
Group::Seller,
|inv| inv.seller.contact.phone.clone()
);
doc_term!(
SELLER_CONTACT_EMAIL,
BtId(43),
"Seller contact email address",
Group::Seller,
|inv| inv.seller.contact.email.clone()
);
doc_term!(BUYER_CITY, BtId(52), "Buyer city", Group::Buyer, |inv| inv
.buyer
.address
.city
.clone());
doc_term!(
BUYER_POST_CODE,
BtId(53),
"Buyer post code",
Group::Buyer,
|inv| inv.buyer.address.post_code.clone()
);
doc_term!(
TYPE_CODE,
bt::TYPE_CODE,
"Invoice type code",
Group::Document,
|inv| inv.type_code.as_ref().map(|c| c.as_str().to_owned())
);
doc_term!(
SPECIFICATION_ID,
bt::SPECIFICATION_ID,
"Specification identifier",
Group::Document,
|inv| inv.specification_id.clone()
);
doc_term!(
PAYMENT_MEANS_CODE,
bt::PAYMENT_MEANS_CODE,
"Payment means type code",
Group::Payment,
|inv| inv
.payment
.as_ref()
.and_then(|p| p.means_code.as_ref())
.map(|c| c.as_str().to_owned())
);
pub static VAT_RATE: TermAccessor = TermAccessor {
term: bt::VAT_RATE,
name: "VAT category rate",
read: |inv| {
inv.vat_breakdown
.iter()
.enumerate()
.map(|(i, e)| {
(
Path::at_term(Group::VatBreakdown, i, bt::VAT_RATE),
e.rate.map(|r| r.to_string()),
)
})
.collect()
},
};
pub static PAYMENT_INSTRUCTIONS: TermAccessor = TermAccessor {
term: BtId(0),
name: "PAYMENT INSTRUCTIONS (BG-16)",
read: |inv| {
vec![(
Path::group(Group::Payment),
inv.payment.as_ref().map(|_| "present".to_owned()),
)]
},
};
}
#[derive(Debug)]
pub enum Restriction {
Mandatory {
id: &'static str,
term: &'static TermAccessor,
},
NotUsed {
id: &'static str,
term: &'static TermAccessor,
},
CodeValues {
id: &'static str,
term: &'static TermAccessor,
allowed: &'static [&'static str],
},
}
impl Restriction {
#[must_use]
pub const fn id(&self) -> &'static str {
match self {
Self::Mandatory { id, .. } | Self::NotUsed { id, .. } | Self::CodeValues { id, .. } => {
id
}
}
}
#[must_use]
pub const fn term(&self) -> &'static TermAccessor {
match self {
Self::Mandatory { term, .. }
| Self::NotUsed { term, .. }
| Self::CodeValues { term, .. } => term,
}
}
fn check(&self, inv: &Invoice, out: &mut Vec<Finding>) {
let acc = self.term();
for (path, value) in (acc.read)(inv) {
let (ok, why, hint) = match self {
Self::Mandatory { .. } => (
value.as_deref().is_some_and(|v| !v.trim().is_empty()),
format!("{} ({}) shall be present", acc.name, acc.term),
None,
),
Self::NotUsed { .. } => (
value.as_deref().is_none_or(|v| v.trim().is_empty()),
format!("{} ({}) shall not be used", acc.name, acc.term),
None,
),
Self::CodeValues { allowed, .. } => {
let v = value.as_deref();
(
v.is_none_or(|v| allowed.contains(&v)),
format!(
"{} ({}) shall be one of: {}",
acc.name,
acc.term,
allowed.join(", ")
),
v.and_then(|v| case_or_scope_hint(v, allowed)),
)
}
};
if !ok {
out.push(Finding {
rule: self.id().to_owned(),
severity: Severity::Fatal,
path,
message: why,
detail: None,
hint,
});
}
}
}
}
fn case_or_scope_hint(value: &str, allowed: &[&'static str]) -> Option<String> {
if let Some(c) = allowed.iter().find(|c| c.eq_ignore_ascii_case(value)) {
return Some(format!(
"did you mean {c:?}? EN 16931-1 §6.5.8 requires codes \"entered exactly as shown\""
));
}
let core = crate::codes::guard::ALL
.iter()
.find(|l| l.accepts(value) && l.values.len() > allowed.len())?;
Some(format!(
"{value:?} is a valid {} value but this profile does not admit it — \
a CIUS may narrow a code list (§7.3.2), so this is a scope question, not a typo",
core.name
))
}
#[derive(Debug)]
pub struct Profile {
pub id: &'static str,
pub specification_id: &'static str,
pub edition: crate::Edition,
pub underlying: &'static [&'static str],
pub restrictions: &'static [Restriction],
pub extra_rules: &'static [&'static Rule],
pub extensions: &'static [&'static str],
pub suppressed: &'static [&'static str],
}
impl Profile {
#[must_use]
pub fn validate(&self, invoice: &Invoice) -> ValidationReport {
let extensions_covered = invoice
.extensions
.populated()
.iter()
.all(|g| self.extensions.contains(g));
let skip = |r: &Rule| {
(extensions_covered && r.id.as_str() == "EN-EXT-01")
|| self.suppressed.contains(&r.id.as_str())
};
let mut report = if self.suppressed.is_empty() && !extensions_covered {
validate_with_all(
invoice,
super::rules::CORE.iter().copied(),
self.extra_rules,
)
} else {
let core: Vec<&'static Rule> = super::rules::CORE
.iter()
.copied()
.filter(|r| !skip(r))
.collect();
validate_with_all(invoice, core.into_iter(), self.extra_rules)
};
let mut extra = Vec::new();
for restriction in self.restrictions {
restriction.check(invoice, &mut extra);
}
report.absorb(extra, self.restrictions.len());
report.attribute_to(self.id, self.edition);
report
}
#[must_use]
pub const fn is_conformant_cius(&self) -> bool {
self.suppressed.is_empty()
}
#[must_use]
pub fn missing_terms(&self, invoice: &Invoice) -> Vec<Missing> {
let mut out = Vec::new();
for r in self.restrictions {
let Restriction::Mandatory { id, term } = r else {
continue;
};
for (path, value) in (term.read)(invoice) {
if value.as_deref().is_none_or(|v| v.trim().is_empty()) {
out.push(Missing {
term: term.term,
name: term.name,
rule: id,
path,
});
}
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Missing {
pub term: BtId,
pub name: &'static str,
pub rule: &'static str,
pub path: Path,
}
impl fmt::Display for Missing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {} — {}", self.rule, self.path, self.name)
}
}
pub trait ProfileMarker {
const PROFILE: &'static Profile;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Validated<P: ProfileMarker> {
invoice: Invoice,
_profile: PhantomData<P>,
}
impl<P: ProfileMarker> Validated<P> {
pub fn new(invoice: Invoice) -> Result<Self, Rejected> {
let report = P::PROFILE.validate(&invoice);
if report.is_valid() {
Ok(Self {
invoice,
_profile: PhantomData,
})
} else {
Err(Box::new((invoice, report)))
}
}
#[must_use]
pub fn invoice(&self) -> &Invoice {
&self.invoice
}
#[must_use]
pub fn into_inner(self) -> Invoice {
self.invoice
}
#[must_use]
pub fn widen<Q>(self) -> Validated<Q>
where
Q: Underlies<P>,
{
Validated {
invoice: self.invoice,
_profile: PhantomData,
}
}
}
pub trait Underlies<P: ProfileMarker>: ProfileMarker {}
#[cfg(test)]
mod tests {
use super::*;
use crate::profiles::{En16931, XRechnung};
#[test]
fn a_cius_states_what_4_4_2_requires() {
let p = XRechnung::PROFILE;
assert!(!p.specification_id.is_empty(), "§7.6: BT-24 identifier");
assert!(
!p.underlying.is_empty(),
"§4.4.2: underlying specifications"
);
assert!(p.is_conformant_cius());
}
#[test]
fn restrictions_are_data_not_code() {
let p = XRechnung::PROFILE;
assert!(
p.restrictions.len() >= 10,
"XRechnung has eleven pure Mandatory rules and two CodeValues"
);
let ids: Vec<_> = p.restrictions.iter().map(Restriction::id).collect();
for expect in ["BR-DE-3", "BR-DE-15", "BR-DE-14", "BR-DE-17"] {
assert!(ids.contains(&expect), "{expect} missing from {ids:?}");
}
}
#[test]
fn core_is_reachable_from_a_cius_proof() {
fn takes_core(_: Validated<En16931>) {}
fn round_trip(v: Validated<XRechnung>) {
takes_core(v.widen());
}
let _ = round_trip; }
}
#[cfg(test)]
mod restriction_tests {
use super::*;
use crate::invoice::Code;
static EVERY_VARIANT: Profile = Profile {
id: "test",
edition: crate::Edition::En2017A1,
specification_id: "urn:test",
underlying: &["EN 16931"],
restrictions: &[
Restriction::Mandatory {
id: "T-MANDATORY",
term: &terms::BUYER_REFERENCE,
},
Restriction::NotUsed {
id: "T-NOTUSED",
term: &terms::SELLER_CITY,
},
Restriction::CodeValues {
id: "T-CODES",
term: &terms::TYPE_CODE,
allowed: &["380"],
},
],
extra_rules: &[],
extensions: &[],
suppressed: &[],
};
fn subject() -> Invoice {
Invoice {
type_code: Some(Code::new("380")),
buyer_reference: Some("REF-1".to_owned()),
..Default::default()
}
}
#[test]
fn mandatory_fires_only_when_the_term_is_absent_or_blank() {
let mut inv = subject();
assert!(!EVERY_VARIANT.validate(&inv).has("T-MANDATORY"));
inv.buyer_reference = None;
assert!(EVERY_VARIANT.validate(&inv).has("T-MANDATORY"));
inv.buyer_reference = Some(" ".to_owned());
assert!(
EVERY_VARIANT.validate(&inv).has("T-MANDATORY"),
"whitespace is not a value"
);
}
#[test]
fn not_used_fires_only_when_the_term_carries_something() {
let mut inv = subject();
assert!(!EVERY_VARIANT.validate(&inv).has("T-NOTUSED"));
inv.seller.address.city = Some("Berlin".to_owned());
assert!(EVERY_VARIANT.validate(&inv).has("T-NOTUSED"));
inv.seller.address.city = Some(" ".to_owned());
assert!(
!EVERY_VARIANT.validate(&inv).has("T-NOTUSED"),
"blank is not 'used'"
);
}
#[test]
fn code_values_fires_only_on_a_value_outside_the_list() {
let mut inv = subject();
assert!(!EVERY_VARIANT.validate(&inv).has("T-CODES"));
inv.type_code = Some(Code::new("381"));
assert!(EVERY_VARIANT.validate(&inv).has("T-CODES"));
inv.type_code = None;
assert!(!EVERY_VARIANT.validate(&inv).has("T-CODES"));
}
#[test]
fn a_derived_finding_is_indistinguishable_from_a_hand_written_one() {
let mut inv = subject();
inv.buyer_reference = None;
let report = EVERY_VARIANT.validate(&inv);
let f = report
.fatal()
.find(|f| f.rule == "T-MANDATORY")
.expect("finding");
assert_eq!(f.path.to_string(), "BT-10");
assert!(f.message.contains("Buyer reference"), "{}", f.message);
}
}