#![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!(CreditTransfers, "../site/content/docs/credit-transfers.md");
guide!(DirectDebits, "../site/content/docs/direct-debits.md");
guide!(Reversals, "../site/content/docs/reversals.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 camt052;
pub mod camt053;
pub mod camt054;
pub mod charset;
mod charset_table;
pub mod country;
pub mod creditor_id;
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, validate_bic};
pub use camt::{
AccountRef, BalanceType, BatchInfo, CashEntry, EntryDetail, EntryStatus, StatementBalance,
};
pub use camt052::{Camt052Document, Camt052ParseError, Camt052Report, parse_camt052};
pub use camt053::{Camt053Document, Camt053ParseError, Camt053Statement, parse_camt053};
pub use camt054::{
Camt054Document, Camt054Notification, Camt054ParseError, CreditDebitIndicator,
UnknownIndicator, parse_camt054,
};
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 date::{DateError, DateTimeError, IsoDate, IsoDateTime};
pub use iban::{
BbanCharClass, Iban, IbanError, iban_bban_format, iban_country_length, is_sepa_country,
validate_iban,
};
pub use pain001::{
CreditTransferEntry, CreditTransferGroup, CreditTransferSchema, ExecutionMoment,
LocalInstrument, Pain001Builder,
};
pub use pain002::{
OriginalMessageType, Pain002Document, Pain002ParseError, PaymentInfoStatus, PaymentStatus,
ReasonCode, StatusCount, TransactionStatus, VerificationOutcome, parse_pain002,
};
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::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,
},
}
#[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.strip_prefix('-') {
Some(rest) => (true, rest),
None => (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 cents: i64 = match frac_str.len() {
0 => 0,
1 => frac_str.parse::<i64>().map_err(|_| malformed())? * 10,
_ => frac_str
.get(..2)
.ok_or_else(malformed)?
.parse()
.map_err(|_| malformed())?,
};
let ct = euros
.checked_mul(100)
.and_then(|e| e.checked_add(cents))
.ok_or_else(overflow)?;
if negative {
ct.checked_neg().ok_or_else(overflow)
} else {
Ok(ct)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ct_to_eur_roundtrip() {
for ct in [0i64, 1, 99, 100, 1234, 10_000, i64::MAX / 100] {
let s = ct_to_eur_str(ct);
assert_eq!(ct_from_eur_str(&s), Ok(ct));
}
}
#[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.239"), Ok(123));
}
#[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.00", "5-", "-",
] {
assert!(
matches!(ct_from_eur_str(bad), Err(AmountError::Malformed { .. })),
"{bad:?} must be rejected, got {:?}",
ct_from_eur_str(bad)
);
}
}
#[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"
);
}
}
}