#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
)
)]
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;
#[cfg(doctest)]
mod site_doctests {
macro_rules! guide {
($name:ident, $path:literal) => {
#[doc = include_str!($path)]
pub struct $name;
};
}
guide!(Landing, "../site/content/_index.md");
guide!(GettingStarted, "../site/content/docs/getting-started.md");
guide!(Identifiers, "../site/content/docs/identifiers.md");
guide!(CreditTransfers, "../site/content/docs/credit-transfers.md");
guide!(DirectDebits, "../site/content/docs/direct-debits.md");
guide!(Reversals, "../site/content/docs/reversals.md");
guide!(Recalls, "../site/content/docs/recalls.md");
guide!(StatusReports, "../site/content/docs/status-reports.md");
guide!(BankStatements, "../site/content/docs/bank-statements.md");
guide!(Addresses, "../site/content/docs/addresses.md");
guide!(Validation, "../site/content/docs/validation.md");
guide!(SchemaVersions, "../site/content/docs/schema-versions.md");
}
pub mod address;
pub mod bic;
pub mod camt;
pub mod camt029;
pub mod camt052;
pub mod camt053;
pub mod camt054;
pub mod camt055;
pub mod charset;
mod charset_table;
pub mod country;
pub mod creditor_id;
pub mod currency;
pub mod date;
pub mod iban;
pub mod pain001;
pub mod pain002;
pub mod pain007;
pub mod pain008;
pub mod party;
pub mod purpose;
pub mod reference;
pub mod validate;
mod xml;
mod xml_util;
pub use address::{AddressError, AddressFormat, PostalAddress};
pub use bic::{Bic, BicError, BicPattern, validate_bic};
pub use camt::{
AccountRef, BalanceType, BatchInfo, CashEntry, ChargeRecord, Charges, EntryDetail, EntryStatus,
StatementBalance,
};
pub use camt029::{
Camt029Document, Camt029ParseError, CancellationCount, CancellationStatus,
GroupCancellationStatus, PaymentInfoCancellationStatus, RejectionReason, ResolutionOutcome,
TransactionCancellationStatus, parse_camt029, parse_camt029_with_limits,
};
pub use camt052::{
Camt052Document, Camt052ParseError, Camt052Report, parse_camt052, parse_camt052_with_limits,
};
pub use camt053::{
Camt053Document, Camt053ParseError, Camt053Statement, parse_camt053, parse_camt053_with_limits,
};
pub use camt054::{
Camt054Document, Camt054Notification, Camt054ParseError, CreditDebitIndicator,
UnknownIndicator, parse_camt054, parse_camt054_with_limits,
};
pub use camt055::{
Camt055Builder, CancellationEntry, CancellationGroup, CancellationReason, CaseParty,
OriginalMessage,
};
pub use charset::{Transliteration, is_sepa_text, transliterate};
pub use country::is_country_code;
pub use creditor_id::{
CreditorId, CreditorIdError, creditor_id_check_digits, validate_creditor_id,
};
pub use currency::{Currency, CurrencyError};
#[cfg(any(feature = "time", feature = "chrono"))]
pub use date::ConversionError;
pub use date::{DateError, DateTimeError, IsoDate, IsoDateTime};
pub use iban::{
BbanCharClass, Iban, IbanError, iban_bban_format, iban_check_digits, iban_country_length,
is_sepa_country, validate_iban,
};
pub use pain001::{
ChargeBearer, CreditTransferEntry, CreditTransferGroup, CreditTransferKind,
CreditTransferSchema, ExecutionMoment, Pain001Builder,
};
pub use pain002::{
OriginalMessageType, Pain002Document, Pain002ParseError, PaymentInfoStatus, PaymentStatus,
ReasonCode, StatusCount, TransactionStatus, VerificationOutcome, parse_pain002,
parse_pain002_with_limits,
};
pub use pain007::{
OriginalCollection, Pain007Builder, ReversalEntry, ReversalGroup, ReversalReason,
};
pub use pain008::{
DirectDebitEntry, DirectDebitGroup, DirectDebitSchema, DirectDebitScheme, MandateAmendment,
Pain008Builder, SequenceType, UnknownSequenceType,
};
pub use party::{IdentifierKind, Party, PartyIdentifier};
pub use purpose::{CategoryPurpose, Purpose, PurposeCodeError};
pub use reference::{RemittanceInfo, RfReference, RfReferenceError};
pub use validate::{
BuildError, CharsetPolicy, Location, UnknownSchema, ValidationError, WriteError,
};
pub use xml::{ParseLimits, XmlError};
#[inline]
#[must_use]
pub fn ct_to_eur_str(ct: i64) -> String {
let sign = if ct < 0 { "-" } else { "" };
let abs = ct.unsigned_abs();
format!("{sign}{}.{:02}", abs / 100, abs % 100)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AmountError {
#[error("amount is empty")]
Empty,
#[error("{value:?} is not a decimal amount")]
Malformed {
value: String,
},
#[error("{value:?} does not fit in i64 cents")]
Overflow {
value: String,
},
#[error("{value:?} has {digits} significant fraction digits; ct holds 2")]
SubCentPrecision {
value: String,
digits: usize,
},
}
#[inline]
pub fn ct_from_eur_str(s: &str) -> Result<i64, AmountError> {
let malformed = || AmountError::Malformed {
value: s.to_owned(),
};
let overflow = || AmountError::Overflow {
value: s.to_owned(),
};
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(AmountError::Empty);
}
let (negative, magnitude) = match trimmed.split_at_checked(1) {
Some(("-", rest)) => (true, rest),
Some(("+", rest)) => (false, rest),
_ => (false, trimmed),
};
let (euro_str, frac_str) = magnitude.split_once('.').unwrap_or((magnitude, ""));
let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
if (euro_str.is_empty() && frac_str.is_empty()) || !digits(euro_str) || !digits(frac_str) {
return Err(malformed());
}
let euros: i64 = if euro_str.is_empty() {
0
} else {
euro_str.parse().map_err(|_| overflow())?
};
let significant = frac_str.trim_end_matches('0');
if significant.len() > 2 {
return Err(AmountError::SubCentPrecision {
value: s.to_owned(),
digits: significant.len(),
});
}
let cents: i64 = match significant.len() {
0 => 0,
1 => significant.parse::<i64>().map_err(|_| malformed())? * 10,
_ => significant.parse().map_err(|_| malformed())?,
};
if negative {
euros
.checked_mul(-100)
.and_then(|e| e.checked_sub(cents))
.ok_or_else(overflow)
} else {
euros
.checked_mul(100)
.and_then(|e| e.checked_add(cents))
.ok_or_else(overflow)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ct_to_eur_roundtrip() {
for ct in [
0i64,
1,
99,
100,
1234,
10_000,
-1,
-99,
-100,
i64::MAX,
i64::MIN,
i64::MIN + 1,
] {
let s = ct_to_eur_str(ct);
assert_eq!(ct_from_eur_str(&s), Ok(ct), "{ct} printed as {s:?}");
}
}
#[test]
fn one_past_each_end_still_overflows() {
assert!(matches!(
ct_from_eur_str("92233720368547758.08"),
Err(AmountError::Overflow { .. })
));
assert!(matches!(
ct_from_eur_str("-92233720368547758.09"),
Err(AmountError::Overflow { .. })
));
}
#[test]
fn ct_from_eur_negatives() {
assert_eq!(ct_from_eur_str("-5.00"), Ok(-500));
assert_eq!(ct_from_eur_str("-0.01"), Ok(-1));
}
#[test]
fn ct_from_eur_integer() {
assert_eq!(ct_from_eur_str("100"), Ok(10_000));
}
#[test]
fn ct_from_eur_never_panics_on_multibyte_fractions() {
for bad in ["1.€5", "1.5€", "0.ü9", "1.€€€", "-2.€1"] {
assert!(
matches!(ct_from_eur_str(bad), Err(AmountError::Malformed { .. })),
"{bad:?} must be rejected, not panic"
);
}
assert_eq!(ct_from_eur_str("1.23"), Ok(123));
}
#[test]
fn a_value_below_one_cent_is_refused_rather_than_truncated() {
for (bad, digits) in [("1.239", 3), ("0.001", 3), ("1.23456", 5), ("9.999", 3)] {
assert!(
matches!(
ct_from_eur_str(bad),
Err(AmountError::SubCentPrecision { digits: d, .. }) if d == digits
),
"{bad:?} must be refused, got {:?}",
ct_from_eur_str(bad)
);
}
for (ok, ct) in [
("1.500", 150),
("1.23000", 123),
("2.10", 210),
("7.0", 700),
] {
assert_eq!(ct_from_eur_str(ok), Ok(ct), "{ok:?} must parse");
}
}
#[test]
fn a_sign_is_accepted_once_and_only_at_the_front() {
for bad in [
"1.-5", "1.+5", "1.-50", "-1.-5", "--5", "-+5", "+-5", "5-", "-", "+",
] {
assert!(
matches!(ct_from_eur_str(bad), Err(AmountError::Malformed { .. })),
"{bad:?} must be rejected, got {:?}",
ct_from_eur_str(bad)
);
}
assert_eq!(ct_from_eur_str("+5"), Ok(500));
assert_eq!(ct_from_eur_str("+5.00"), Ok(500));
assert_eq!(ct_from_eur_str("+0.01"), Ok(1));
}
#[test]
fn trailing_junk_after_the_cents_is_rejected() {
for bad in ["1.50abc", "1.5x", "1.005 EUR", "100x", "1.2.3"] {
assert!(
matches!(ct_from_eur_str(bad), Err(AmountError::Malformed { .. })),
"{bad:?} must be rejected, got {:?}",
ct_from_eur_str(bad)
);
}
}
#[test]
fn ct_from_eur_reports_why_it_failed() {
assert_eq!(ct_from_eur_str(""), Err(AmountError::Empty));
assert_eq!(ct_from_eur_str(" "), Err(AmountError::Empty));
for bad in ["abc", "1.2.3", "1,50", "12 EUR", "-"] {
assert!(
matches!(ct_from_eur_str(bad), Err(AmountError::Malformed { .. })),
"{bad:?} must be malformed"
);
}
for big in ["92233720368547758.08", "99999999999999999999"] {
assert!(
matches!(ct_from_eur_str(big), Err(AmountError::Overflow { .. })),
"{big:?} must overflow"
);
}
}
}