use super::{Findings, Rule, RuleId, Severity, Source};
use crate::VatCategory;
use crate::bt::{BtId, Group, Path};
use crate::invoice::{Invoice, PaymentMeans, terms as bt};
macro_rules! rule {
(
$konst:ident, $id:literal, $sev:ident,
terms: [$($t:expr),* $(,)?],
$text:literal,
|$inv:ident, $f:ident| $body:block
) => {
#[doc = $text]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::$sev,
text: $text,
terms: &[$($t),*],
source: Source::ArtefactOnly,
eval: |$inv: &Invoice, $f: &mut Findings<'_>| $body,
};
};
}
const CREDIT_TRANSFER: &[&str] = &["30", "58"];
const CARD: &[&str] = &["48", "54", "55"];
const DIRECT_DEBIT: &[&str] = &["59"];
fn means_code(inv: &Invoice) -> Option<&str> {
inv.payment
.as_ref()
.and_then(|p| p.means_code.as_ref())
.map(crate::invoice::Code::as_str)
}
rule!(BR_DE_16, "BR-DE-16", Fatal,
terms: [bt::SELLER_VAT_ID, bt::SELLER_TAX_ID],
"Wenn in einer Rechnung die Steuercodes S, Z, E, AE, K, G, L oder M verwendet werden, muss \
mindestens eines der Elemente \"Seller VAT identifier\" (BT-31), \"Seller tax registration \
identifier\" (BT-32) oder \"Seller tax representative VAT identifier\" (BT-63) übermittelt \
werden.",
|inv, f| {
let needs_id = inv.categories_used().iter().any(|c| {
!matches!(c, VatCategory::OutOfScope | VatCategory::SplitPayment)
});
if needs_id
&& inv.seller.vat_identifier.is_none()
&& inv.seller.tax_registration.is_none()
{
f.at(Path::group_term(Group::Seller, bt::SELLER_VAT_ID));
}
});
macro_rules! means_rule {
($konst:ident, $id:literal, $codes:ident, $variant:pat, $text:literal) => {
rule!($konst, $id, Fatal, terms: [bt::PAYMENT_MEANS_CODE], $text, |inv, f| {
if means_code(inv).is_some_and(|c| $codes.contains(&c)) {
let ok = matches!(
inv.payment.as_ref().and_then(|p| p.means.as_ref()),
Some($variant)
);
if !ok {
f.at(Path::group_term(Group::Payment, bt::PAYMENT_MEANS_CODE));
}
}
});
};
}
means_rule!(
BR_DE_23_A,
"BR-DE-23-a",
CREDIT_TRANSFER,
PaymentMeans::CreditTransfer(_),
"Wenn BT-81 \"Payment means type code\" einen Schlüssel für Überweisungen enthält (30, 58), \
muss BG-17 \"CREDIT TRANSFER\" übermittelt werden."
);
means_rule!(
BR_DE_24_A,
"BR-DE-24-a",
CARD,
PaymentMeans::Card(_),
"Wenn BT-81 \"Payment means type code\" einen Schlüssel für Kartenzahlungen enthält \
(48, 54, 55), muss genau BG-18 \"PAYMENT CARD INFORMATION\" übermittelt werden."
);
means_rule!(
BR_DE_25_A,
"BR-DE-25-a",
DIRECT_DEBIT,
PaymentMeans::DirectDebit(_),
"Wenn BT-81 \"Payment means type code\" einen Schlüssel für Lastschriften enthält (59), muss \
genau BG-19 \"DIRECT DEBIT\" übermittelt werden."
);
macro_rules! means_rule_b {
($konst:ident, $id:literal, $text:literal) => {
#[doc = $text]
#[doc = ""]
#[doc = "Satisfied by `PaymentMeans` being an enum: the forbidden combination cannot be written down."]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::Fatal,
text: $text,
terms: &[],
source: Source::ArtefactOnly,
eval: |_, _| {},
};
};
}
means_rule_b!(
BR_DE_23_B,
"BR-DE-23-b",
"Wenn BT-81 einen Schlüssel für Überweisungen enthält (30, 58), dürfen BG-18 und BG-19 nicht \
übermittelt werden."
);
means_rule_b!(
BR_DE_24_B,
"BR-DE-24-b",
"Wenn BT-81 einen Schlüssel für Kartenzahlungen enthält (48, 54, 55), dürfen BG-17 und BG-19 \
nicht übermittelt werden."
);
means_rule_b!(
BR_DE_25_B,
"BR-DE-25-b",
"Wenn BT-81 einen Schlüssel für Lastschriften enthält (59), dürfen BG-17 und BG-18 nicht \
übermittelt werden."
);
rule!(BR_DE_30, "BR-DE-30", Fatal, terms: [BtId(90)],
"Wenn \"DIRECT DEBIT\" (BG-19) vorhanden ist, dann muss \"Bank assigned creditor identifier\" \
(BT-90) übermittelt werden.",
|inv, f| {
if let Some(PaymentMeans::DirectDebit(d)) =
inv.payment.as_ref().and_then(|p| p.means.as_ref())
&& d.creditor_identifier.as_deref().is_none_or(str::is_empty)
{
f.at(Path::group_term(Group::Payment, BtId(90)));
}
});
#[must_use]
pub fn is_valid_creditor_identifier(s: &str) -> bool {
#[cfg(feature = "sepa")]
{
sepa::validate_creditor_id(s).is_ok()
}
#[cfg(not(feature = "sepa"))]
{
let c: String = s.chars().filter(|c| !c.is_whitespace()).collect();
c.len() >= 8
&& c.len() <= 35
&& c.bytes().all(|b| b.is_ascii_alphanumeric())
&& c[..2].bytes().all(|b| b.is_ascii_alphabetic())
&& c[2..4].bytes().all(|b| b.is_ascii_digit())
}
}
pub static EN_SEPA_01: Rule = Rule {
id: RuleId::new("EN-SEPA-01"),
severity: Severity::Warning,
text: "\"Bank assigned creditor identifier\" (BT-90) should be a valid SEPA Creditor \
Identifier (EPC AT-02).",
terms: &[BtId(90)],
source: Source::Crate,
eval: |inv, f| {
if let Some(PaymentMeans::DirectDebit(d)) =
inv.payment.as_ref().and_then(|p| p.means.as_ref())
&& d.creditor_identifier
.as_deref()
.is_some_and(|id| !id.is_empty() && !is_valid_creditor_identifier(id))
{
f.at(Path::group_term(Group::Payment, BtId(90)));
}
},
};
rule!(BR_DE_31, "BR-DE-31", Fatal, terms: [BtId(91)],
"Wenn \"DIRECT DEBIT\" (BG-19) vorhanden ist, dann muss \"Debited account identifier\" \
(BT-91) übermittelt werden.",
|inv, f| {
if let Some(PaymentMeans::DirectDebit(d)) =
inv.payment.as_ref().and_then(|p| p.means.as_ref())
&& d.debited_account.as_deref().is_none_or(str::is_empty)
{
f.at(Path::group_term(Group::Payment, BtId(91)));
}
});
rule!(BR_DE_26, "BR-DE-26", Fatal, terms: [bt::TYPE_CODE, bt::PRECEDING_INVOICE],
"Wenn im Element \"Invoice type code\" (BT-3) der Code 384 (Corrected invoice) übergeben \
wird, soll PRECEDING INVOICE REFERENCE (BG-3) mindestens einmal übermittelt werden.",
|inv, f| {
if inv.type_code.as_ref().is_some_and(|c| c.as_str() == "384")
&& inv.preceding_invoices.is_empty()
{
f.at(Path::term(bt::PRECEDING_INVOICE));
}
});
#[must_use]
pub fn is_valid_iban(s: &str) -> bool {
#[cfg(feature = "sepa")]
{
sepa::validate_iban(s).is_ok()
}
#[cfg(not(feature = "sepa"))]
{
is_valid_iban_checksum(s)
}
}
#[must_use]
pub fn is_valid_iban_checksum(s: &str) -> bool {
let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
if compact.len() < 5 || compact.len() > 34 {
return false;
}
if !compact.bytes().all(|b| b.is_ascii_alphanumeric()) {
return false;
}
let bytes = compact.as_bytes();
if !bytes[0].is_ascii_alphabetic()
|| !bytes[1].is_ascii_alphabetic()
|| !bytes[2].is_ascii_digit()
|| !bytes[3].is_ascii_digit()
{
return false;
}
let rearranged = compact[4..].chars().chain(compact[..4].chars());
let mut remainder: u32 = 0;
for c in rearranged {
let value = if c.is_ascii_digit() {
u32::from(c as u8 - b'0')
} else {
u32::from(c.to_ascii_uppercase() as u8 - b'A') + 10
};
remainder = if value >= 10 {
(remainder * 100 + value) % 97
} else {
(remainder * 10 + value) % 97
};
}
remainder == 1
}
rule!(BR_DE_19, "BR-DE-19", Warning, terms: [bt::PAYMENT_ACCOUNT],
"\"Payment account identifier\" (BT-84) soll eine korrekte IBAN enthalten, wenn in \"Payment \
means type code\" (BT-81) der Code 58 (SEPA credit transfer) angegeben ist.",
|inv, f| {
if means_code(inv) == Some("58")
&& let Some(p) = &inv.payment
&& let Some(acc) = p.account_identifier()
&& !is_valid_iban(acc)
{
f.at(Path::group_term(Group::Payment, bt::PAYMENT_ACCOUNT));
}
});
rule!(BR_DE_20, "BR-DE-20", Warning, terms: [BtId(91)],
"\"Debited account identifier\" (BT-91) soll eine korrekte IBAN enthalten, wenn in \"Payment \
means type code\" (BT-81) der Code 59 (SEPA direct debit) angegeben ist.",
|inv, f| {
if means_code(inv) == Some("59")
&& let Some(PaymentMeans::DirectDebit(d)) =
inv.payment.as_ref().and_then(|p| p.means.as_ref())
&& let Some(acc) = d.debited_account.as_deref()
&& !is_valid_iban(acc)
{
f.at(Path::group_term(Group::Payment, BtId(91)));
}
});
rule!(BR_DE_27, "BR-DE-27", Fatal, terms: [BtId(42)],
"In BT-42 sollen mindestens drei Ziffern enthalten sein.",
|inv, f| {
if let Some(phone) = inv.seller.contact.phone.as_deref()
&& phone.chars().filter(char::is_ascii_digit).count() < 3
{
f.at(Path::group_term(Group::Seller, BtId(42)));
}
});
rule!(BR_DE_28, "BR-DE-28", Fatal, terms: [BtId(43)],
"In BT-43 soll genau ein @-Zeichen enthalten sein, welches nicht von einem Leerzeichen oder \
einem Punkt, aber von mindestens zwei Zeichen auf beiden Seiten flankiert wird. Ein Punkt \
sollte nicht am Anfang oder am Ende stehen.",
|inv, f| {
if let Some(email) = inv.seller.contact.email.as_deref()
&& !plausible_email(email)
{
f.at(Path::group_term(Group::Seller, BtId(43)));
}
});
fn plausible_email(s: &str) -> bool {
let at: Vec<usize> = s.match_indices('@').map(|(i, _)| i).collect();
if at.len() != 1 {
return false;
}
let (local, domain) = s.split_at(at[0]);
let domain = &domain[1..];
if local.chars().count() < 2 || domain.chars().count() < 2 {
return false;
}
let bad = |c: Option<char>| matches!(c, Some(' ' | '.'));
if bad(local.chars().next_back()) || bad(domain.chars().next()) {
return false;
}
!s.starts_with('.') && !s.ends_with('.')
}
pub static ALL: &[&Rule] = &[
&BR_DE_2,
&BR_DE_10,
&BR_DE_11,
&BR_DE_18,
&BR_DE_22,
&BR_TMP_2,
&BR_DE_TMP_32,
&BR_DE_16,
&BR_DE_19,
&BR_DE_20,
&BR_DE_23_A,
&BR_DE_23_B,
&BR_DE_24_A,
&BR_DE_24_B,
&BR_DE_25_A,
&BR_DE_25_B,
&BR_DE_26,
&BR_DE_27,
&BR_DE_28,
&BR_DE_30,
&BR_DE_31,
&EN_SEPA_01,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iban_mod_97_accepts_real_ibans_and_rejects_typos() {
for ok in [
"DE89370400440532013000",
"GB82 WEST 1234 5698 7654 32",
"FR1420041010050500013M02606",
"NL91ABNA0417164300",
] {
assert!(is_valid_iban(ok), "{ok} should be valid");
}
for bad in [
"DE89370400440532013001", "DE8937040044053201300", "XX00", "0089370400440532013000", "DE89-3704-0044", "",
] {
assert!(!is_valid_iban(bad), "{bad:?} should be invalid");
}
}
#[test]
fn br_de_28_is_a_shape_test_not_an_rfc_parser() {
for ok in ["rechnung@seller.de", "a.b@example.co.uk"] {
assert!(plausible_email(ok), "{ok}");
}
for bad in [
"no-at-sign",
"two@@ats.de",
"a@b.de", "ab@c", "ab .@seller.de", "ab.@seller.de", ".ab@seller.de", "ab@seller.de.", ] {
assert!(!plausible_email(bad), "{bad:?} should be rejected");
}
}
#[test]
fn the_b_halves_are_unrepresentable_rather_than_unchecked() {
for r in [&BR_DE_23_B, &BR_DE_24_B, &BR_DE_25_B] {
let mut out = Vec::new();
let mut sink = crate::validation::Findings::for_test(&mut out, r);
(r.eval)(&Invoice::default(), &mut sink);
assert!(out.is_empty());
}
}
#[test]
fn both_iban_rules_are_advisory() {
assert_eq!(BR_DE_19.severity, Severity::Warning);
assert_eq!(BR_DE_20.severity, Severity::Warning);
}
}
rule!(BR_DE_2, "BR-DE-2", Fatal, terms: [BtId(41), BtId(42), BtId(43)],
"Die Gruppe \"SELLER CONTACT\" (BG-6) muss übermittelt werden.",
|inv, f| {
let c = &inv.seller.contact;
if c.name.is_none() && c.phone.is_none() && c.email.is_none() {
f.at(Path::group(Group::Seller));
}
});
rule!(BR_DE_10, "BR-DE-10", Fatal, terms: [BtId(77)],
"Das Element \"Deliver to city\" (BT-77) muss übermittelt werden, wenn die Gruppe \"DELIVER TO \
ADDRESS\" (BG-15) übermittelt wird.",
|inv, f| {
if let Some(a) = inv.delivery.as_ref().and_then(|d| d.address.as_ref())
&& a.city.as_deref().is_none_or(|c| c.trim().is_empty())
{
f.at(Path::at_term(Group::Delivery, 0, BtId(77)));
}
});
rule!(BR_DE_11, "BR-DE-11", Fatal, terms: [BtId(78)],
"Das Element \"Deliver to post code\" (BT-78) muss übermittelt werden, wenn die Gruppe \"DELIVER \
TO ADDRESS\" (BG-15) übermittelt wird.",
|inv, f| {
if let Some(a) = inv.delivery.as_ref().and_then(|d| d.address.as_ref())
&& a.post_code.as_deref().is_none_or(|c| c.trim().is_empty())
{
f.at(Path::at_term(Group::Delivery, 0, BtId(78)));
}
});
rule!(BR_DE_22, "BR-DE-22", Fatal, terms: [BtId(125)],
"Das \"filename\"-Attribut aller \"EmbeddedDocumentBinaryObject\"-Elemente muss eindeutig sein.",
|inv, f| {
for (i, doc) in inv.attachments.iter().enumerate() {
let Some(name) = doc.attachment.as_ref().map(crate::Attachment::filename) else {
continue;
};
let dup = inv.attachments[..i]
.iter()
.filter_map(|d| d.attachment.as_ref())
.any(|a| a.filename() == name);
if dup {
f.at(Path::at_term(Group::Attachment, i, BtId(125)));
}
}
});
rule!(BR_DE_18, "BR-DE-18", Fatal, terms: [bt::PAYMENT_TERMS],
"Skonto-Zeilen in BT-20 müssen der Form #SKONTO#TAGE=n#PROZENT=n.nn[#BASISBETRAG=n.nn]# \
entsprechen.",
|inv, f| {
let Some(terms) = inv.payment_terms.as_deref() else {
return;
};
let mut bad = terms
.lines()
.map(str::trim)
.any(|line| line.starts_with('#') && !is_skonto_line(line));
if let Some(last_hash) = terms.rfind('#') {
let tail = &terms[last_hash + 1..];
if terms.contains("#SKONTO#") && !tail.trim_start_matches([' ', '\t', '\r']).starts_with('\n')
{
bad = true;
}
}
if bad {
f.at(Path::term(bt::PAYMENT_TERMS));
}
});
fn is_skonto_line(line: &str) -> bool {
let Some(rest) = line.strip_prefix("#SKONTO#TAGE=") else {
return false;
};
let Some((days, rest)) = rest.split_once("#PROZENT=") else {
return false;
};
if days.is_empty() || !days.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let Some(body) = rest.strip_suffix('#') else {
return false;
};
match body.split_once("#BASISBETRAG=") {
Some((pct, base)) => is_two_dp(pct, false) && is_two_dp(base, true),
None => is_two_dp(body, false),
}
}
fn is_two_dp(s: &str, allow_sign: bool) -> bool {
let s = if allow_sign {
s.strip_prefix('-').unwrap_or(s)
} else {
s
};
let Some((int, frac)) = s.split_once('.') else {
return false;
};
!int.is_empty()
&& int.bytes().all(|b| b.is_ascii_digit())
&& frac.len() == 2
&& frac.bytes().all(|b| b.is_ascii_digit())
}
rule!(BR_TMP_2, "BR-TMP-2", Warning, terms: [BtId(124)],
"BT-124 \"External document location\" muss eine absolute URL mit gültigem Schema enthalten.",
|inv, f| {
for (i, doc) in inv.attachments.iter().enumerate() {
if let Some(uri) = doc.uri.as_deref()
&& !is_absolute_url(uri)
{
f.at(Path::at_term(Group::Attachment, i, BtId(124)));
}
}
});
fn is_absolute_url(s: &str) -> bool {
let Some((scheme, rest)) = s.split_once("://") else {
return false;
};
!scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
&& !rest.is_empty()
}
rule!(BR_DE_TMP_32, "BR-DE-TMP-32", Info, terms: [BtId(72), BtId(73), BtId(134)],
"Eine Rechnung sollte zur Angabe des Liefer-/Leistungsdatums entweder BT-72, BG-14 oder BG-26 \
(in allen Rechnungspositionen) enthalten.",
|inv, f| {
let has_delivery_date = inv.delivery.as_ref().is_some_and(|d| d.date.is_some());
let has_period = inv.invoicing_period.is_some();
let every_line_has_one = !inv.lines.is_empty() && inv.lines.iter().all(|l| l.period.is_some());
if !(has_delivery_date || has_period || every_line_has_one) {
f.at(Path::term(BtId(72)));
}
});
pub mod cvd {
use super::{Findings, Group, Invoice, Path, Rule, RuleId, Severity, Source};
use crate::bt::BtId;
pub const VEHICLE_CATEGORIES: &[&str] = &["M1", "M2", "M3", "N1", "N2", "N3"];
pub const CVA_VALUES: &[&str] = &["clean", "other", "zero-emission"];
pub const CVD_SCHEME: &str = "CVD";
pub const CVA_NAME: &str = "cva";
fn cvd_classifications(item: &crate::invoice::Item) -> usize {
item.classification_identifiers
.iter()
.filter(|id| id.scheme() == Some(CVD_SCHEME))
.count()
}
fn cva_attributes(item: &crate::invoice::Item) -> usize {
item.attributes
.iter()
.filter(|a| a.name.as_deref() == Some(CVA_NAME))
.count()
}
macro_rules! cvd_rule {
($konst:ident, $id:literal, terms: [$($t:expr),* $(,)?], $text:literal,
|$inv:ident, $f:ident| $body:block) => {
#[doc = $text]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::Fatal,
text: $text,
terms: &[$($t),*],
source: Source::ArtefactOnly,
eval: |$inv: &Invoice, $f: &mut Findings<'_>| $body,
};
};
}
cvd_rule!(BR_DE_CVD_01, "BR-DE-CVD-01", terms: [BtId(12)],
"Das Element \"Contract reference\" (BT-12) muss übermittelt werden.",
|inv, f| {
if inv
.contract_reference
.as_ref()
.is_none_or(|r| r.as_str().trim().is_empty())
{
f.at(Path::term(BtId(12)));
}
});
cvd_rule!(BR_DE_CVD_02, "BR-DE-CVD-02", terms: [BtId(17)],
"Das Element \"Tender or lot reference\" (BT-17) muss übermittelt werden.",
|inv, f| {
if inv
.tender_reference
.as_ref()
.is_none_or(|r| r.as_str().trim().is_empty())
{
f.at(Path::term(BtId(17)));
}
});
cvd_rule!(BR_DE_CVD_03, "BR-DE-CVD-03", terms: [BtId(158), BtId(160)],
"In einer Rechnung muss mindestens eine INVOICE LINE (BG-25) enthalten sein, in der der Scheme \
identifier von BT-158 'CVD' ist und BT-160 den Wert 'cva' hat.",
|inv, f| {
let any = inv
.lines
.iter()
.any(|l| cvd_classifications(&l.item) > 0 && cva_attributes(&l.item) > 0);
if !any {
f.at(Path::term(BtId(158)));
}
});
cvd_rule!(BR_DE_CVD_04, "BR-DE-CVD-04", terms: [BtId(158)],
"Ein \"Item classification identifier\" (BT-158) mit dem Scheme identifier 'CVD' muss einen \
Wert aus der Liste der Fahrzeugklassen enthalten.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
for id in &line.item.classification_identifiers {
if id.scheme() == Some(CVD_SCHEME)
&& !VEHICLE_CATEGORIES.contains(&id.content())
{
f.at(Path::at_term(Group::Line, i, BtId(158)));
}
}
}
});
cvd_rule!(BR_DE_CVD_05, "BR-DE-CVD-05", terms: [BtId(161)],
"Wenn \"Item attribute name\" (BT-160) den Wert 'cva' hat, muss BT-161 einen der Werte \
'clean', 'zero-emission' oder 'other' enthalten.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
for a in &line.item.attributes {
if a.name.as_deref() == Some(CVA_NAME)
&& !a.value.as_deref().is_some_and(|v| CVA_VALUES.contains(&v))
{
f.at(Path::at_term(Group::Line, i, BtId(161)));
}
}
}
});
cvd_rule!(BR_DE_CVD_06_A, "BR-DE-CVD-06-a", terms: [BtId(158), BtId(160)],
"Wenn der Scheme identifier von BT-158 den Wert 'CVD' hat, muss genau ein BT-160 mit dem Wert \
'cva' in derselben Rechnungsposition angegeben sein.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
if cvd_classifications(&line.item) > 0 && cva_attributes(&line.item) != 1 {
f.at(Path::at_term(Group::Line, i, BtId(160)));
}
}
});
cvd_rule!(BR_DE_CVD_06_B, "BR-DE-CVD-06-b", terms: [BtId(158), BtId(160)],
"Wenn BT-160 mit dem Wert 'cva' angegeben ist, muss in derselben Rechnungsposition genau ein \
BT-158 mit dem Scheme identifier 'CVD' angegeben sein.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
if cva_attributes(&line.item) > 0 && cvd_classifications(&line.item) != 1 {
f.at(Path::at_term(Group::Line, i, BtId(158)));
}
}
});
cvd_rule!(BR_TMP_CVD_01, "BR-TMP-CVD-01", terms: [BtId(158)],
"Das Bildungsschema für \"Item classification identifier\" (BT-158) ist aus der Codeliste \
UNTDID 7143, erweitert um 'CVD'.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
for id in &line.item.classification_identifiers {
if let Some(scheme) = id.scheme()
&& scheme != CVD_SCHEME
&& !crate::codes::contains(
crate::codes::generated::ITEM_CLASSIFICATION_SCHEMES,
scheme,
)
{
f.at(Path::at_term(Group::Line, i, BtId(158)));
}
}
}
});
pub static ALL: &[&Rule] = &[
&BR_DE_CVD_01,
&BR_DE_CVD_02,
&BR_DE_CVD_03,
&BR_DE_CVD_04,
&BR_DE_CVD_05,
&BR_DE_CVD_06_A,
&BR_DE_CVD_06_B,
&BR_TMP_CVD_01,
];
}
pub mod extension {
use super::{Findings, Group, Invoice, Path, Rule, RuleId, Severity, Source};
use crate::bt::BtId;
use crate::extensions::SubInvoiceLine;
pub const DIGA_SCHEMES: &[&str] = &["XR01", "XR02", "XR03"];
pub const EXTENSION_MIME_CODES: &[&str] = &[
"application/pdf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/xml",
"image/jpeg",
"image/png",
"text/csv",
];
fn icd_or_diga(scheme: &str) -> bool {
DIGA_SCHEMES.contains(&scheme)
|| crate::codes::contains(crate::codes::generated::ICD_SCHEMES, scheme)
}
fn eas_or_diga(scheme: &str) -> bool {
DIGA_SCHEMES.contains(&scheme)
|| crate::codes::contains(crate::codes::generated::EAS_SCHEMES, scheme)
}
macro_rules! dex {
($konst:ident, $id:literal, $sev:ident, terms: [$($t:expr),* $(,)?], $text:literal,
|$inv:ident, $f:ident| $body:block) => {
#[doc = $text]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::$sev,
text: $text,
terms: &[$($t),*],
source: Source::ArtefactOnly,
eval: |$inv: &Invoice, $f: &mut Findings<'_>| $body,
};
};
}
dex!(BR_DEX_01, "BR-DEX-01", Fatal, terms: [BtId(125)],
"Das Element \"Attached Document\" (BT-125) benutzt einen nicht zulässigen MIME-Code. Im Falle \
einer Extension ist zusätzlich 'application/xml' zulässig.",
|inv, f| {
for (i, doc) in inv.attachments.iter().enumerate() {
if let Some(a) = &doc.attachment
&& !EXTENSION_MIME_CODES.contains(&a.mime_code())
{
f.at(Path::at_term(Group::Attachment, i, BtId(125)));
}
}
});
dex!(BR_DEX_02, "BR-DEX-02", Warning, terms: [BtId(131)],
"Der Wert von \"Invoice line net amount\" (BT-131) einer INVOICE LINE (BG-25) oder einer SUB \
INVOICE LINE (BG-DEX-01) soll der Summe der Beträge ihrer Sub Invoice Lines entsprechen.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
let subs = inv.extensions.sub_lines(i);
if subs.is_empty() {
continue;
}
check_sum(line.net_amount, subs, Path::at_term(Group::Line, i, BtId(131)), f);
for s in subs {
check_subtree(s, Path::at_term(Group::Line, i, BtId(131)), f);
}
}
});
fn check_sum(
stated: crate::InvoiceAmount,
children: &[SubInvoiceLine],
path: Path,
f: &mut Findings<'_>,
) {
let Ok(sum) = crate::InvoiceAmount::checked_sum(children.iter().map(|c| c.line.net_amount))
else {
return;
};
if sum != stated {
f.arithmetic(path, sum, stated);
}
}
fn check_subtree(node: &SubInvoiceLine, path: Path, f: &mut Findings<'_>) {
if node.children.is_empty() {
return;
}
check_sum(node.line.net_amount, &node.children, path, f);
for c in &node.children {
check_subtree(c, path, f);
}
}
dex!(BR_DEX_03, "BR-DEX-03", Fatal, terms: [BtId(151)],
"Eine Sub Invoice Line (BG-DEX-01) muss genau eine SUB INVOICE LINE VAT INFORMATION \
(BG-DEX-06) enthalten.",
|inv, f| {
fn walk(nodes: &[SubInvoiceLine], i: usize, f: &mut Findings<'_>) {
for n in nodes {
if n.vat.is_none() {
f.at(Path::at_term(Group::Line, i, BtId(151)));
}
walk(&n.children, i, f);
}
}
for i in 0..inv.lines.len() {
walk(inv.extensions.sub_lines(i), i, f);
}
});
dex!(BR_DEX_04, "BR-DEX-04", Fatal, terms: [BtId(29), BtId(46)],
"Any scheme identifier in cac:PartyIdentification MUST be coded using one of the ISO 6523 ICD \
list, extended by the DiGA codes.",
|inv, f| {
for (g, party) in [(Group::Seller, &inv.seller), (Group::Buyer, &inv.buyer)] {
for id in &party.identifiers {
if id.scheme().is_some_and(|s| !icd_or_diga(s)) {
f.at(Path::group(g));
}
}
}
});
dex!(BR_DEX_05, "BR-DEX-05", Fatal, terms: [BtId(30), BtId(47)],
"Any scheme identifier in cac:PartyLegalEntity MUST be coded using one of the ISO 6523 ICD \
list, extended by the DiGA codes.",
|inv, f| {
for (g, party) in [(Group::Seller, &inv.seller), (Group::Buyer, &inv.buyer)] {
if party
.legal_registration
.as_ref()
.and_then(crate::Identifier::scheme)
.is_some_and(|s| !icd_or_diga(s))
{
f.at(Path::group(g));
}
}
});
dex!(BR_DEX_06, "BR-DEX-06", Fatal, terms: [BtId(157)],
"Any scheme identifier in cac:StandardItemIdentification MUST be coded using one of the ISO \
6523 ICD list, extended by the DiGA codes.",
|inv, f| {
for (i, line) in inv.lines.iter().enumerate() {
if line
.item
.standard_identifier
.as_ref()
.and_then(crate::Identifier::scheme)
.is_some_and(|s| !icd_or_diga(s))
{
f.at(Path::at_term(Group::Line, i, BtId(157)));
}
}
});
dex!(BR_DEX_07, "BR-DEX-07", Fatal, terms: [BtId(34), BtId(49)],
"Any scheme identifier for an Endpoint Identifier MUST belong to the CEF EAS code list, \
extended by the DiGA codes.",
|inv, f| {
for (g, party) in [(Group::Seller, &inv.seller), (Group::Buyer, &inv.buyer)] {
if party
.electronic_address
.as_ref()
.and_then(crate::Identifier::scheme)
.is_some_and(|s| !eas_or_diga(s))
{
f.at(Path::group(g));
}
}
});
dex!(BR_DEX_08, "BR-DEX-08", Fatal, terms: [BtId(71)],
"Any scheme identifier for a Delivery location identifier MUST be coded using one of the ISO \
6523 ICD list, extended by the DiGA codes.",
|inv, f| {
if inv
.delivery
.as_ref()
.and_then(|d| d.location.as_ref())
.and_then(crate::Identifier::scheme)
.is_some_and(|s| !icd_or_diga(s))
{
f.at(Path::at_term(Group::Delivery, 0, BtId(71)));
}
});
dex!(BR_DEX_09, "BR-DEX-09", Fatal, terms: [BtId(115), BtId(112), BtId(113), BtId(114)],
"Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) - Paid amount \
(BT-113) + Rounding amount (BT-114) + Σ Third party payment amount (BT-DEX-002).",
|inv, f| {
let t = &inv.totals;
let zero = crate::InvoiceAmount::ZERO;
let Ok(third_party) = inv.extensions.third_party_total() else {
return;
};
let expected = t
.gross_total
.checked_sub(t.paid.unwrap_or(zero))
.and_then(|v| v.checked_add(t.rounding.unwrap_or(zero)))
.and_then(|v| v.checked_add(third_party));
let Ok(expected) = expected else { return };
if expected != t.due {
f.arithmetic(Path::term(BtId(115)), expected, t.due);
}
});
macro_rules! third_party_term {
($konst:ident, $id:literal, $bt:literal, $field:ident, $text:literal) => {
dex!($konst, $id, Fatal, terms: [], $text, |inv, f| {
for p in &inv.extensions.third_party_payments {
if p.$field.is_none() {
f.at(Path::group(Group::Totals));
}
}
});
};
}
third_party_term!(
BR_DEX_10,
"BR-DEX-10",
"BT-DEX-001",
payment_type,
"Das Element \"Third party payment type\" (BT-DEX-001) muss übermittelt werden, wenn die \
Gruppe THIRD PARTY PAYMENT (BG-DEX-09) übermittelt wird."
);
third_party_term!(
BR_DEX_11,
"BR-DEX-11",
"BT-DEX-002",
amount,
"Das Element \"Third party payment amount\" (BT-DEX-002) muss übermittelt werden, wenn die \
Gruppe THIRD PARTY PAYMENT (BG-DEX-09) übermittelt wird."
);
third_party_term!(
BR_DEX_12,
"BR-DEX-12",
"BT-DEX-003",
description,
"Das Element \"Third party payment description\" (BT-DEX-003) muss übermittelt werden, \
wenn die Gruppe THIRD PARTY PAYMENT (BG-DEX-09) übermittelt wird."
);
macro_rules! dex_by_type {
($konst:ident, $id:literal, $text:literal, $why:literal) => {
#[doc = $text]
#[doc = ""]
#[doc = $why]
pub static $konst: Rule = Rule {
id: RuleId::new($id),
severity: Severity::Fatal,
text: $text,
terms: &[],
source: Source::ArtefactOnly,
eval: |_, _| {},
};
};
}
dex_by_type!(
BR_DEX_13,
"BR-DEX-13",
"Die maximale Anzahl zulässiger Nachkommastellen für BT-DEX-002 ist 2.",
"`InvoiceAmount` is `i64` minor units — a third decimal cannot be written down. Same \
disposition as the `BR-DEC-*` family."
);
dex_by_type!(
BR_DEX_14,
"BR-DEX-14",
"Die Währungsangabe von BT-DEX-002 muss BT-5 entsprechen.",
"Every amount in the model is implicitly in BT-5; there is no per-amount `@currencyID`. \
Same disposition as `BR-CL-03`."
);
pub static ALL: &[&Rule] = &[
&BR_DEX_01, &BR_DEX_02, &BR_DEX_03, &BR_DEX_04, &BR_DEX_05, &BR_DEX_06, &BR_DEX_07,
&BR_DEX_08, &BR_DEX_09, &BR_DEX_10, &BR_DEX_11, &BR_DEX_12, &BR_DEX_13, &BR_DEX_14,
];
}
#[cfg(test)]
mod sepa_tests {
use super::*;
#[test]
fn the_registry_check_is_strictly_stronger() {
for good in [
"DE89370400440532013000",
"NL91ABNA0417164300",
"GB29NWBK60161331926819",
] {
assert!(is_valid_iban_checksum(good), "{good}");
assert!(is_valid_iban(good), "{good}");
}
assert!(!is_valid_iban_checksum("DE89370400440532013001"));
assert!(!is_valid_iban("DE89370400440532013001"));
}
#[test]
#[cfg(feature = "sepa")]
fn the_registry_catches_a_wrong_length_iban() {
let short = "DE29100000001234567";
if is_valid_iban_checksum(short) {
assert!(
!is_valid_iban(short),
"the ISO 13616 registry must reject a 19-character DE IBAN"
);
}
assert!(is_valid_iban("DE89370400440532013000"));
}
#[test]
fn a_malformed_creditor_identifier_is_rejected_either_way() {
assert!(!is_valid_creditor_identifier("not a creditor id"));
assert!(!is_valid_creditor_identifier(""));
assert!(is_valid_creditor_identifier("DE98ZZZ09999999999"));
}
}