From e6e4f5786dbc2530e2b63445e6dd8b9beeef21f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 08:21:14 +0200 Subject: [PATCH] Add finance module (v0.5.0) 9 validated value objects: CurrencyCode (ISO 4217), Money, Iban (mod-97), Bic (8/11-char SWIFT), VatNumber (EU prefixes), Percentage (0-100 f64), ExchangeRate, CreditCardNumber (Luhn, masked display), CardExpiryDate (MM/YY). Closes #21, #22, #23, #24, #25, #26, #28, #29, #30 Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 + README.md | 8 +- ROADMAP.md | 22 +-- docs/finance.md | 305 ++++++++++++++++++++++++++++++ src/finance/bic.rs | 208 ++++++++++++++++++++ src/finance/card_expiry_date.rs | 191 +++++++++++++++++++ src/finance/credit_card_number.rs | 182 ++++++++++++++++++ src/finance/currency_code.rs | 154 +++++++++++++++ src/finance/exchange_rate.rs | 223 ++++++++++++++++++++++ src/finance/iban.rs | 199 +++++++++++++++++++ src/finance/mod.rs | 19 ++ src/finance/money.rs | 185 ++++++++++++++++++ src/finance/percentage.rs | 119 ++++++++++++ src/finance/vat_number.rs | 172 +++++++++++++++++ src/lib.rs | 3 + 15 files changed, 1979 insertions(+), 13 deletions(-) create mode 100644 docs/finance.md create mode 100644 src/finance/bic.rs create mode 100644 src/finance/card_expiry_date.rs create mode 100644 src/finance/credit_card_number.rs create mode 100644 src/finance/currency_code.rs create mode 100644 src/finance/exchange_rate.rs create mode 100644 src/finance/iban.rs create mode 100644 src/finance/mod.rs create mode 100644 src/finance/money.rs create mode 100644 src/finance/percentage.rs create mode 100644 src/finance/vat_number.rs diff --git a/Cargo.toml b/Cargo.toml index c6e8026..d8add4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ default = [] # Domain modules — opt-in so you only pay for what you use contact = ["dep:once_cell", "dep:regex", "dep:url"] +finance = ["dep:rust_decimal", "dep:chrono"] identifiers = [] primitives = ["dep:rust_decimal", "dep:base64"] @@ -29,6 +30,7 @@ sql = ["dep:sqlx"] # Everything at once full = [ "contact", + "finance", "identifiers", "primitives", ] diff --git a/README.md b/README.md index bfe44b9..4169392 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ let email: EmailAddress = "user@example.com".try_into()?; | [docs/value-objects.md](docs/value-objects.md) | What value objects are, simple vs composite, normalisation | | [docs/implementing.md](docs/implementing.md) | How to implement the `ValueObject` trait for custom types | | [docs/contact.md](docs/contact.md) | Reference for all `contact` module types | +| [docs/finance.md](docs/finance.md) | Reference for all `finance` module types | --- @@ -63,7 +64,10 @@ Enable only the modules you need — unused features add zero dependencies. | Feature | What you get | Extra deps | |:---|:---|:---| -| `contact` | `EmailAddress`, `CountryCode`, `PhoneNumber`, `Website` | `once_cell`, `regex`, `url` | +| `contact` | `EmailAddress`, `CountryCode`, `PhoneNumber`, `PostalAddress`, `Website` | `once_cell`, `regex`, `url` | +| `finance` | `Money`, `CurrencyCode`, `Iban`, `Bic`, `VatNumber`, `Percentage`, `ExchangeRate`, `CreditCardNumber`, `CardExpiryDate` | `rust_decimal`, `chrono` | +| `identifiers` | `Slug`, `Ean13`, `Ean8`, `Isbn13`, `Isbn10`, `Issn`, `Vin` | — | +| `primitives` | `NonEmptyString`, `BoundedString`, `PositiveInt`, `NonNegativeInt`, `PositiveDecimal`, `NonNegativeDecimal`, `Probability`, `HexColor`, `Locale`, `Base64String` | `rust_decimal`, `base64` | | `serde` | `Serialize` / `Deserialize` on all types | `serde` | | `full` | All domain modules | all of the above | @@ -197,7 +201,7 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; |:---|:---|:---:|:---:| | `contact` | `EmailAddress`, `PhoneNumber`, `CountryCode`, `PostalAddress`, `Website` | 5 | 5 / 5 ✅ | | `identifiers` | `Slug`, `Ean13`, `Isbn13`, `Vin` | 7 | 7 / 7 ✅ | -| `finance` | `Money`, `Iban`, `Bic`, `VatNumber`, `CreditCardNumber` | 9 | 0 / 9 | +| `finance` | `Money`, `CurrencyCode`, `Iban`, `Bic`, `VatNumber`, `Percentage`, `ExchangeRate`, `CreditCardNumber`, `CardExpiryDate` | 9 | 9 / 9 ✅ | | `temporal` | `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | 5 | 0 / 5 | | `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone` | 6 | 0 / 6 | | `net` | `Url`, `IpAddress`, `MacAddress`, `ApiKey`, `Port` | 10 | 0 / 10 | diff --git a/ROADMAP.md b/ROADMAP.md index ac8bffc..f31f70a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,15 +38,15 @@ | Type | Status | Notes | |---|---|---| -| `Money` | ⬜ | `Decimal` amount + `CurrencyCode`; immutable arithmetic helpers | -| `CurrencyCode` | ⬜ | ISO 4217 alpha-3 (EUR, USD, CZK…) | -| `Iban` | ⬜ | IBAN with mod-97 checksum | -| `Bic` | ⬜ | BIC/SWIFT, 8 or 11 chars | -| `VatNumber` | ⬜ | EU VAT number with country-prefix + format validation | -| `Percentage` | ⬜ | `Decimal` in range 0–100 | -| `ExchangeRate` | ⬜ | positive `Decimal`, from/to `CurrencyCode` pair | -| `CreditCardNumber` | ⬜ | Luhn algorithm validation; masked `Display` (shows only last 4 digits) | -| `CardExpiryDate` | ⬜ | MM/YY; rejected if in the past at construction time | +| `Money` | ✅ | `Decimal` amount + `CurrencyCode`; immutable arithmetic helpers | +| `CurrencyCode` | ✅ | ISO 4217 alpha-3 (EUR, USD, CZK…) | +| `Iban` | ✅ | IBAN with mod-97 checksum | +| `Bic` | ✅ | BIC/SWIFT, 8 or 11 chars | +| `VatNumber` | ✅ | EU VAT number with country-prefix + format validation | +| `Percentage` | ✅ | `f64` in range 0–100 | +| `ExchangeRate` | ✅ | positive `Decimal`, from/to `CurrencyCode` pair | +| `CreditCardNumber` | ✅ | Luhn algorithm validation; masked `Display` (shows only last 4 digits) | +| `CardExpiryDate` | ✅ | MM/YY; rejected if in the past at construction time | --- @@ -136,10 +136,10 @@ |---|---|---|---| | `contact` | 5 | 5 | 0 | | `identifiers` | 7 | 7 | 0 | -| `finance` | 9 | 0 | 9 | +| `finance` | 9 | 9 | 0 | | `temporal` | 5 | 0 | 5 | | `geo` | 6 | 0 | 6 | | `net` | 10 | 0 | 10 | | `measurement` | 10 | 0 | 10 | | `primitives` | 10 | 10 | 0 | -| **Total** | **62** | **22** | **40** | +| **Total** | **62** | **31** | **31** | diff --git a/docs/finance.md b/docs/finance.md new file mode 100644 index 0000000..7c25e25 --- /dev/null +++ b/docs/finance.md @@ -0,0 +1,305 @@ +# finance module + +Feature flag: `finance` + +```toml +[dependencies] +arvo = { version = "0.5", features = ["finance"] } +``` + +--- + +## CurrencyCode + +A validated ISO 4217 alphabetic currency code. + +**Normalisation:** trimmed, uppercased. +**Validation:** exactly 3 ASCII letters; must be a known active ISO 4217 code. + +```rust,ignore +use arvo::finance::CurrencyCode; +use arvo::traits::ValueObject; + +let code = CurrencyCode::new("eur".into())?; +assert_eq!(code.value(), "EUR"); + +assert!(CurrencyCode::new("XYZ".into()).is_err()); + +let c: CurrencyCode = "CZK".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"EUR"` | +| `into_inner()` | `String` | `"EUR"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| `"US"` | `ValidationError::InvalidFormat` | +| `"XYZ"` | `ValidationError::InvalidFormat` | + +--- + +## Money + +A validated monetary amount with an associated currency. + +**Normalisation:** none (amount stored as-is; output formatted with 2 decimal places). +**Validation:** `amount` may be any finite `Decimal`; `currency` must be a valid `CurrencyCode`. + +```rust,ignore +use arvo::finance::{CurrencyCode, Money, MoneyInput}; +use arvo::traits::ValueObject; + +let money = Money::new(MoneyInput { + amount: "10.50".parse()?, + currency: CurrencyCode::new("EUR".into())?, +})?; +assert_eq!(money.value(), "10.50 EUR"); +assert_eq!(money.currency().value(), "EUR"); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"10.50 EUR"` | +| `amount()` | `&Decimal` | `10.50` | +| `currency()` | `&CurrencyCode` | `CurrencyCode("EUR")` | +| `into_inner()` | `MoneyInput` | — | + +--- + +## Iban + +A validated IBAN (International Bank Account Number) using the mod-97 algorithm. + +**Normalisation:** spaces stripped, uppercased. +**Validation:** 15–34 characters; starts with 2-letter country code and 2 check digits; all remaining characters alphanumeric; mod-97 checksum equals 1. + +```rust,ignore +use arvo::finance::Iban; +use arvo::traits::ValueObject; + +let iban = Iban::new("GB82 WEST 1234 5698 7654 32".into())?; +assert_eq!(iban.value(), "GB82WEST12345698765432"); +assert_eq!(iban.country_code(), "GB"); +assert_eq!(iban.bban(), "WEST12345698765432"); + +let i: Iban = "DE89370400440532013000".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"GB82WEST12345698765432"` | +| `country_code()` | `&str` | `"GB"` | +| `check_digits()` | `&str` | `"82"` | +| `bban()` | `&str` | `"WEST12345698765432"` | +| `into_inner()` | `String` | — | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| too short / too long | `ValidationError::InvalidFormat` | +| wrong checksum | `ValidationError::InvalidFormat` | + +--- + +## Bic + +A validated BIC (Bank Identifier Code / SWIFT code). + +**Normalisation:** trimmed, uppercased. +**Validation:** 8 or 11 alphanumeric characters; positions 1–4 are letters (bank code); positions 5–6 are letters (country code); positions 7–8 are alphanumeric (location code); optional positions 9–11 are alphanumeric (branch code). + +```rust,ignore +use arvo::finance::Bic; +use arvo::traits::ValueObject; + +let bic = Bic::new("DEUTDEDB".into())?; +assert_eq!(bic.bank_code(), "DEUT"); +assert_eq!(bic.country_code(), "DE"); +assert_eq!(bic.branch_code(), None); + +let bic11 = Bic::new("DEUTDEDBBER".into())?; +assert_eq!(bic11.branch_code(), Some("BER")); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"DEUTDEDB"` | +| `bank_code()` | `&str` | `"DEUT"` | +| `country_code()` | `&str` | `"DE"` | +| `location_code()` | `&str` | `"DB"` | +| `branch_code()` | `Option<&str>` | `None` / `Some("BER")` | +| `into_inner()` | `String` | — | + +--- + +## VatNumber + +A validated EU VAT number. + +**Normalisation:** trimmed, uppercased, internal spaces stripped. +**Validation:** starts with a known EU country prefix (AT, BE, BG, CY, CZ, DE, DK, EE, EL, ES, FI, FR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK, XI); followed by 2–13 alphanumeric characters. + +```rust,ignore +use arvo::finance::VatNumber; +use arvo::traits::ValueObject; + +let vat = VatNumber::new("CZ 1234 5678".into())?; +assert_eq!(vat.value(), "CZ12345678"); +assert_eq!(vat.country_prefix(), "CZ"); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"CZ12345678"` | +| `country_prefix()` | `&str` | `"CZ"` | +| `into_inner()` | `String` | — | + +--- + +## Percentage + +A validated percentage in the range `0.0..=100.0`. + +**Normalisation:** none. +**Validation:** finite (not NaN, not infinite); in range `0.0..=100.0` inclusive. + +```rust,ignore +use arvo::finance::Percentage; +use arvo::traits::ValueObject; + +let p = Percentage::new(42.5)?; +assert_eq!(*p.value(), 42.5); + +assert!(Percentage::new(-1.0).is_err()); +assert!(Percentage::new(f64::NAN).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&f64` | `42.5` | +| `into_inner()` | `f64` | `42.5` | + +--- + +## ExchangeRate + +A validated currency exchange rate between two different currencies. + +**Normalisation:** none. +**Validation:** `rate` must be strictly positive (> 0); `from` and `to` must be different currencies. + +```rust,ignore +use arvo::finance::{CurrencyCode, ExchangeRate, ExchangeRateInput}; +use arvo::traits::ValueObject; + +let rate = ExchangeRate::new(ExchangeRateInput { + from: CurrencyCode::new("EUR".into())?, + to: CurrencyCode::new("USD".into())?, + rate: "1.0850".parse()?, +})?; +assert_eq!(rate.value(), "EUR/USD 1.0850"); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"EUR/USD 1.0850"` | +| `from()` | `&CurrencyCode` | `CurrencyCode("EUR")` | +| `to()` | `&CurrencyCode` | `CurrencyCode("USD")` | +| `rate()` | `&Decimal` | `1.0850` | +| `into_inner()` | `ExchangeRateInput` | — | + +### Errors + +| Condition | Error | +|---|---| +| `rate <= 0` | `ValidationError::InvalidFormat` | +| `from == to` | `ValidationError::InvalidFormat` | + +--- + +## CreditCardNumber + +A validated credit card number using the Luhn algorithm. + +**Normalisation:** spaces and hyphens stripped; only digits kept. +**Validation:** 13–19 digits after stripping; must pass the Luhn checksum. +**Display:** masked — only last 4 digits visible, e.g. `"**** **** **** 0366"`. + +```rust,ignore +use arvo::finance::CreditCardNumber; +use arvo::traits::ValueObject; + +let card = CreditCardNumber::new("4532 0151 1283 0366".into())?; +assert_eq!(card.last_four(), "0366"); +assert_eq!(card.masked(), "**** **** **** 0366"); +// value() returns the full digit string — treat as sensitive +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"4532015112830366"` (sensitive) | +| `last_four()` | `&str` | `"0366"` | +| `masked()` | `String` | `"**** **** **** 0366"` | +| `into_inner()` | `String` | — | + +--- + +## CardExpiryDate + +A validated credit/debit card expiry date that is not in the past. + +**Normalisation:** parsed and stored as `"MM/YY"`. +**Input:** accepts `"MM/YY"` or `"MM/YYYY"`. +**Validation:** month 01–12; expiry month/year must be ≥ current month/year (card valid through entire expiry month). + +```rust,ignore +use arvo::finance::CardExpiryDate; +use arvo::traits::ValueObject; + +let exp = CardExpiryDate::new("12/28".into())?; +assert_eq!(exp.value(), "12/28"); +assert_eq!(exp.month(), 12); +assert_eq!(exp.year(), 2028); + +assert!(CardExpiryDate::new("01/20".into()).is_err()); // past +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"12/28"` | +| `month()` | `u8` | `12` | +| `year()` | `u16` | `2028` | +| `into_inner()` | `String` | — | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| invalid month | `ValidationError::InvalidFormat` | +| expired | `ValidationError::InvalidFormat` | diff --git a/src/finance/bic.rs b/src/finance/bic.rs new file mode 100644 index 0000000..2aa50d6 --- /dev/null +++ b/src/finance/bic.rs @@ -0,0 +1,208 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Bic`]. +pub type BicInput = String; + +/// Output type for [`Bic`] — canonical uppercase string. +pub type BicOutput = String; + +/// A validated BIC (Bank Identifier Code), also known as SWIFT code. +/// +/// On construction the input is trimmed and uppercased. A BIC is either 8 or +/// 11 alphanumeric characters with the following structure: +/// - positions 1–4: bank code (4 letters) +/// - positions 5–6: country code (2 letters) +/// - positions 7–8: location code (2 alphanumeric characters) +/// - positions 9–11: optional branch code (3 alphanumeric characters) +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::Bic; +/// use arvo::traits::ValueObject; +/// +/// let bic = Bic::new("DEUTDEDB".into()).unwrap(); +/// assert_eq!(bic.value(), "DEUTDEDB"); +/// assert_eq!(bic.bank_code(), "DEUT"); +/// assert_eq!(bic.country_code(), "DE"); +/// assert_eq!(bic.branch_code(), None); +/// +/// let bic11 = Bic::new("DEUTDEDBBER".into()).unwrap(); +/// assert_eq!(bic11.branch_code(), Some("BER")); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Bic(String); + +impl ValueObject for Bic { + type Input = BicInput; + type Output = BicOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let upper = value.trim().to_uppercase(); + + if upper.is_empty() { + return Err(ValidationError::empty("Bic")); + } + + let len = upper.len(); + if len != 8 && len != 11 { + return Err(ValidationError::invalid("Bic", &upper)); + } + + if !upper.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(ValidationError::invalid("Bic", &upper)); + } + + // Positions 0–3: bank code — 4 letters + if !upper[..4].chars().all(|c| c.is_ascii_alphabetic()) { + return Err(ValidationError::invalid("Bic", &upper)); + } + + // Positions 4–5: country code — 2 letters + if !upper[4..6].chars().all(|c| c.is_ascii_alphabetic()) { + return Err(ValidationError::invalid("Bic", &upper)); + } + + // Positions 6–7: location code — already validated as alphanumeric above + + Ok(Self(upper)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl Bic { + /// Returns the 4-letter bank code (positions 1–4). + pub fn bank_code(&self) -> &str { + &self.0[..4] + } + + /// Returns the 2-letter country code (positions 5–6). + pub fn country_code(&self) -> &str { + &self.0[4..6] + } + + /// Returns the 2-character location code (positions 7–8). + pub fn location_code(&self) -> &str { + &self.0[6..8] + } + + /// Returns the 3-character branch code (positions 9–11) if present. + pub fn branch_code(&self) -> Option<&str> { + if self.0.len() == 11 { + Some(&self.0[8..]) + } else { + None + } + } +} + +impl TryFrom<&str> for Bic { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for Bic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_8_char_bic() { + let b = Bic::new("DEUTDEDB".into()).unwrap(); + assert_eq!(b.value(), "DEUTDEDB"); + } + + #[test] + fn accepts_11_char_bic() { + let b = Bic::new("DEUTDEDBBER".into()).unwrap(); + assert_eq!(b.value(), "DEUTDEDBBER"); + } + + #[test] + fn normalises_to_uppercase() { + let b = Bic::new("deutdedb".into()).unwrap(); + assert_eq!(b.value(), "DEUTDEDB"); + } + + #[test] + fn trims_whitespace() { + let b = Bic::new(" DEUTDEDB ".into()).unwrap(); + assert_eq!(b.value(), "DEUTDEDB"); + } + + #[test] + fn bank_code_accessor() { + let b = Bic::new("DEUTDEDB".into()).unwrap(); + assert_eq!(b.bank_code(), "DEUT"); + } + + #[test] + fn country_code_accessor() { + let b = Bic::new("DEUTDEDB".into()).unwrap(); + assert_eq!(b.country_code(), "DE"); + } + + #[test] + fn location_code_accessor() { + let b = Bic::new("DEUTDEDB".into()).unwrap(); + assert_eq!(b.location_code(), "DB"); + } + + #[test] + fn branch_code_none_for_8_char() { + let b = Bic::new("DEUTDEDB".into()).unwrap(); + assert_eq!(b.branch_code(), None); + } + + #[test] + fn branch_code_some_for_11_char() { + let b = Bic::new("DEUTDEDBBER".into()).unwrap(); + assert_eq!(b.branch_code(), Some("BER")); + } + + #[test] + fn rejects_empty() { + assert!(Bic::new(String::new()).is_err()); + } + + #[test] + fn rejects_wrong_length() { + assert!(Bic::new("DEUTDED".into()).is_err()); + assert!(Bic::new("DEUTDEDB1".into()).is_err()); + } + + #[test] + fn rejects_digits_in_bank_code() { + assert!(Bic::new("1EUTDEDB".into()).is_err()); + } + + #[test] + fn rejects_digits_in_country_code() { + assert!(Bic::new("DEUT1EDB".into()).is_err()); + } + + #[test] + fn try_from_str() { + let b: Bic = "DEUTDEDB".try_into().unwrap(); + assert_eq!(b.value(), "DEUTDEDB"); + } +} diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs new file mode 100644 index 0000000..6fc9dbb --- /dev/null +++ b/src/finance/card_expiry_date.rs @@ -0,0 +1,191 @@ +use chrono::{Datelike, Local}; + +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`CardExpiryDate`] — accepts `"MM/YY"` or `"MM/YYYY"`. +pub type CardExpiryDateInput = String; + +/// Output type for [`CardExpiryDate`] — normalised `"MM/YY"` string. +pub type CardExpiryDateOutput = String; + +/// A validated credit/debit card expiry date. +/// +/// Accepts `"MM/YY"` or `"MM/YYYY"` format. The month must be 01–12 and the +/// expiry must not have already passed — a card is valid through the entire +/// month of its expiry date. Output is normalised to `"MM/YY"`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::CardExpiryDate; +/// use arvo::traits::ValueObject; +/// +/// let exp = CardExpiryDate::new("12/28".into()).unwrap(); +/// assert_eq!(exp.value(), "12/28"); +/// assert_eq!(exp.month(), 12); +/// assert_eq!(exp.year(), 2028); +/// +/// // A date in the past is rejected +/// assert!(CardExpiryDate::new("01/20".into()).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct CardExpiryDate(String); + +impl ValueObject for CardExpiryDate { + type Input = CardExpiryDateInput; + type Output = CardExpiryDateOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(ValidationError::empty("CardExpiryDate")); + } + + let parts: Vec<&str> = trimmed.split('/').collect(); + if parts.len() != 2 { + return Err(ValidationError::invalid("CardExpiryDate", trimmed)); + } + + let month: u8 = parts[0] + .parse() + .map_err(|_| ValidationError::invalid("CardExpiryDate", trimmed))?; + + if !(1..=12).contains(&month) { + return Err(ValidationError::invalid("CardExpiryDate", trimmed)); + } + + let year_str = parts[1]; + let full_year: u16 = match year_str.len() { + 2 => { + let yy: u16 = year_str + .parse() + .map_err(|_| ValidationError::invalid("CardExpiryDate", trimmed))?; + 2000 + yy + } + 4 => year_str + .parse() + .map_err(|_| ValidationError::invalid("CardExpiryDate", trimmed))?, + _ => return Err(ValidationError::invalid("CardExpiryDate", trimmed)), + }; + + let now = Local::now(); + let current_year = now.year() as u16; + let current_month = now.month() as u8; + + if full_year < current_year || (full_year == current_year && month < current_month) { + return Err(ValidationError::invalid("CardExpiryDate", trimmed)); + } + + let canonical = format!("{:02}/{:02}", month, full_year % 100); + Ok(Self(canonical)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl CardExpiryDate { + /// Returns the expiry month as a number (1–12). + pub fn month(&self) -> u8 { + self.0[..2].parse().unwrap() + } + + /// Returns the 4-digit expiry year, e.g. `2028`. + pub fn year(&self) -> u16 { + let yy: u16 = self.0[3..].parse().unwrap(); + 2000 + yy + } +} + +impl TryFrom<&str> for CardExpiryDate { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for CardExpiryDate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_future_mm_yy() { + let e = CardExpiryDate::new("12/28".into()).unwrap(); + assert_eq!(e.value(), "12/28"); + } + + #[test] + fn accepts_future_mm_yyyy() { + let e = CardExpiryDate::new("06/2030".into()).unwrap(); + assert_eq!(e.value(), "06/30"); + } + + #[test] + fn accepts_current_month() { + // April 2026 — current month, still valid + let e = CardExpiryDate::new("04/26".into()).unwrap(); + assert_eq!(e.value(), "04/26"); + } + + #[test] + fn month_accessor() { + let e = CardExpiryDate::new("12/28".into()).unwrap(); + assert_eq!(e.month(), 12); + } + + #[test] + fn year_accessor() { + let e = CardExpiryDate::new("12/28".into()).unwrap(); + assert_eq!(e.year(), 2028); + } + + #[test] + fn rejects_past_date() { + assert!(CardExpiryDate::new("01/25".into()).is_err()); + } + + #[test] + fn rejects_previous_month() { + assert!(CardExpiryDate::new("03/26".into()).is_err()); + } + + #[test] + fn rejects_empty() { + assert!(CardExpiryDate::new(String::new()).is_err()); + } + + #[test] + fn rejects_invalid_month() { + assert!(CardExpiryDate::new("13/28".into()).is_err()); + assert!(CardExpiryDate::new("00/28".into()).is_err()); + } + + #[test] + fn rejects_invalid_format() { + assert!(CardExpiryDate::new("12-28".into()).is_err()); + assert!(CardExpiryDate::new("1228".into()).is_err()); + } + + #[test] + fn try_from_str() { + let e: CardExpiryDate = "12/28".try_into().unwrap(); + assert_eq!(e.value(), "12/28"); + } +} diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs new file mode 100644 index 0000000..af51b9c --- /dev/null +++ b/src/finance/credit_card_number.rs @@ -0,0 +1,182 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`CreditCardNumber`]. +pub type CreditCardNumberInput = String; + +/// Output type for [`CreditCardNumber`] — digits only, no separators. +pub type CreditCardNumberOutput = String; + +/// A validated credit card number using the Luhn algorithm. +/// +/// On construction spaces and hyphens are stripped; only digits are kept. +/// The Luhn algorithm is then applied: every second digit from the right is +/// doubled; if the result exceeds 9, subtract 9; the total must be divisible +/// by 10. Valid cards have 13–19 digits. +/// +/// `Display` renders the masked form (last 4 digits visible); `value()` returns +/// the full digit string — treat it as sensitive data. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::CreditCardNumber; +/// use arvo::traits::ValueObject; +/// +/// let card = CreditCardNumber::new("4532015112830366".into()).unwrap(); +/// assert_eq!(card.last_four(), "0366"); +/// assert_eq!(card.masked(), "**** **** **** 0366"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct CreditCardNumber(String); + +impl ValueObject for CreditCardNumber { + type Input = CreditCardNumberInput; + type Output = CreditCardNumberOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); + + if digits.is_empty() { + return Err(ValidationError::empty("CreditCardNumber")); + } + + let len = digits.len(); + if !(13..=19).contains(&len) { + return Err(ValidationError::invalid("CreditCardNumber", &digits)); + } + + if !luhn_valid(&digits) { + return Err(ValidationError::invalid("CreditCardNumber", &digits)); + } + + Ok(Self(digits)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl CreditCardNumber { + /// Returns the last 4 digits, e.g. `"0366"`. + pub fn last_four(&self) -> &str { + let len = self.0.len(); + &self.0[len - 4..] + } + + /// Returns a masked representation with only the last 4 digits visible. + /// + /// Digits are grouped in blocks of 4 separated by spaces, e.g. + /// `"**** **** **** 0366"`. + pub fn masked(&self) -> String { + let len = self.0.len(); + let masked_count = len - 4; + let full: String = "*".repeat(masked_count) + &self.0[masked_count..]; + full.chars().enumerate().fold( + String::with_capacity(full.len() + full.len() / 4), + |mut s, (i, c)| { + if i > 0 && i % 4 == 0 { + s.push(' '); + } + s.push(c); + s + }, + ) + } +} + +impl std::fmt::Display for CreditCardNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.masked()) + } +} + +fn luhn_valid(digits: &str) -> bool { + let sum: u32 = digits + .chars() + .rev() + .enumerate() + .map(|(i, c)| { + let mut d = (c as u8 - b'0') as u32; + if i % 2 == 1 { + d *= 2; + if d > 9 { + d -= 9; + } + } + d + }) + .sum(); + sum % 10 == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_visa() { + // 4532015112830366 — valid Luhn + let c = CreditCardNumber::new("4532015112830366".into()).unwrap(); + assert_eq!(c.value(), "4532015112830366"); + } + + #[test] + fn accepts_with_spaces() { + let c = CreditCardNumber::new("4532 0151 1283 0366".into()).unwrap(); + assert_eq!(c.value(), "4532015112830366"); + } + + #[test] + fn accepts_with_hyphens() { + let c = CreditCardNumber::new("4532-0151-1283-0366".into()).unwrap(); + assert_eq!(c.value(), "4532015112830366"); + } + + #[test] + fn last_four() { + let c = CreditCardNumber::new("4532015112830366".into()).unwrap(); + assert_eq!(c.last_four(), "0366"); + } + + #[test] + fn masked_16_digit() { + let c = CreditCardNumber::new("4532015112830366".into()).unwrap(); + assert_eq!(c.masked(), "**** **** **** 0366"); + } + + #[test] + fn display_is_masked() { + let c = CreditCardNumber::new("4532015112830366".into()).unwrap(); + assert_eq!(c.to_string(), "**** **** **** 0366"); + } + + #[test] + fn rejects_empty() { + assert!(CreditCardNumber::new(String::new()).is_err()); + } + + #[test] + fn rejects_too_short() { + assert!(CreditCardNumber::new("123456789012".into()).is_err()); + } + + #[test] + fn rejects_invalid_luhn() { + // Change last digit to break Luhn + assert!(CreditCardNumber::new("4532015112830367".into()).is_err()); + } + + #[test] + fn rejects_too_long() { + assert!(CreditCardNumber::new("45320151128303660000".into()).is_err()); + } +} diff --git a/src/finance/currency_code.rs b/src/finance/currency_code.rs new file mode 100644 index 0000000..d5bf968 --- /dev/null +++ b/src/finance/currency_code.rs @@ -0,0 +1,154 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`CurrencyCode`]. +pub type CurrencyCodeInput = String; + +/// Output type for [`CurrencyCode`]. +pub type CurrencyCodeOutput = String; + +/// Active ISO 4217 alphabetic currency codes, sorted for binary search. +static ISO_4217: &[&str] = &[ + "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", + "BGN", "BHD", "BMD", "BND", "BOB", "BOV", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", + "CDF", "CHE", "CHF", "CHW", "CLF", "CLP", "CNY", "COP", "COU", "CRC", "CUP", "CVE", "CZK", + "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", + "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", + "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", + "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", + "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MXV", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", + "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", + "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SLL", "SOS", "SRD", + "SSP", "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", + "TZS", "UAH", "UGX", "USD", "USN", "UYI", "UYU", "UYW", "UZS", "VED", "VES", "VND", "VUV", + "WST", "XAF", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XCD", "XDR", "XOF", "XPD", "XPF", + "XPT", "XSU", "XTS", "XUA", "XXX", "YER", "ZAR", "ZMW", "ZWG", "ZWL", +]; + +/// A validated ISO 4217 alphabetic currency code. +/// +/// On construction the input is trimmed and uppercased. Only active ISO 4217 +/// codes (e.g. `"EUR"`, `"USD"`, `"CZK"`) are accepted. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::CurrencyCode; +/// use arvo::traits::ValueObject; +/// +/// let code = CurrencyCode::new("eur".into()).unwrap(); +/// assert_eq!(code.value(), "EUR"); +/// +/// assert!(CurrencyCode::new("XYZ".into()).is_err()); +/// assert!(CurrencyCode::new("US".into()).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct CurrencyCode(String); + +impl ValueObject for CurrencyCode { + type Input = CurrencyCodeInput; + type Output = CurrencyCodeOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let upper = value.trim().to_uppercase(); + + if upper.is_empty() { + return Err(ValidationError::empty("CurrencyCode")); + } + + if upper.len() != 3 || !upper.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(ValidationError::invalid("CurrencyCode", &upper)); + } + + if ISO_4217.binary_search(&upper.as_str()).is_err() { + return Err(ValidationError::invalid("CurrencyCode", &upper)); + } + + Ok(Self(upper)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl TryFrom<&str> for CurrencyCode { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for CurrencyCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_code() { + let c = CurrencyCode::new("EUR".into()).unwrap(); + assert_eq!(c.value(), "EUR"); + } + + #[test] + fn normalises_to_uppercase() { + let c = CurrencyCode::new("eur".into()).unwrap(); + assert_eq!(c.value(), "EUR"); + } + + #[test] + fn trims_whitespace() { + let c = CurrencyCode::new(" USD ".into()).unwrap(); + assert_eq!(c.value(), "USD"); + } + + #[test] + fn accepts_czk() { + assert!(CurrencyCode::new("CZK".into()).is_ok()); + } + + #[test] + fn accepts_jpy() { + assert!(CurrencyCode::new("JPY".into()).is_ok()); + } + + #[test] + fn rejects_empty() { + assert!(CurrencyCode::new(String::new()).is_err()); + } + + #[test] + fn rejects_unknown_code() { + assert!(CurrencyCode::new("XYZ".into()).is_err()); + } + + #[test] + fn rejects_wrong_length() { + assert!(CurrencyCode::new("US".into()).is_err()); + assert!(CurrencyCode::new("USDX".into()).is_err()); + } + + #[test] + fn rejects_digits() { + assert!(CurrencyCode::new("U5D".into()).is_err()); + } + + #[test] + fn try_from_str() { + let c: CurrencyCode = "GBP".try_into().unwrap(); + assert_eq!(c.value(), "GBP"); + } +} diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs new file mode 100644 index 0000000..d1d2167 --- /dev/null +++ b/src/finance/exchange_rate.rs @@ -0,0 +1,223 @@ +use rust_decimal::Decimal; + +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +use super::currency_code::CurrencyCode; + +/// Input type for [`ExchangeRate`] construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ExchangeRateInput { + /// Source currency. + pub from: CurrencyCode, + /// Target currency. + pub to: CurrencyCode, + /// Exchange rate — must be strictly positive. + pub rate: Decimal, +} + +/// Output type for [`ExchangeRate`] — canonical `"/ "` string. +pub type ExchangeRateOutput = String; + +/// A validated currency exchange rate. +/// +/// The `rate` must be strictly positive (> 0) and `from` must differ from `to`. +/// The canonical output is formatted as `"/ "`, +/// e.g. `"EUR/USD 1.0850"`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::{CurrencyCode, ExchangeRate, ExchangeRateInput}; +/// use arvo::traits::ValueObject; +/// +/// let rate = ExchangeRate::new(ExchangeRateInput { +/// from: CurrencyCode::new("EUR".into()).unwrap(), +/// to: CurrencyCode::new("USD".into()).unwrap(), +/// rate: "1.0850".parse().unwrap(), +/// }).unwrap(); +/// +/// assert_eq!(rate.value(), "EUR/USD 1.0850"); +/// assert_eq!(rate.from().value(), "EUR"); +/// assert_eq!(rate.to().value(), "USD"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ExchangeRate { + from: CurrencyCode, + to: CurrencyCode, + rate: Decimal, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for ExchangeRate { + type Input = ExchangeRateInput; + type Output = ExchangeRateOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if value.from == value.to { + return Err(ValidationError::invalid( + "ExchangeRate", + &format!("{}/{}", value.from, value.to), + )); + } + + if value.rate <= Decimal::ZERO { + return Err(ValidationError::invalid( + "ExchangeRate", + &value.rate.to_string(), + )); + } + + let canonical = format!("{}/{} {}", value.from, value.to, value.rate); + Ok(Self { + from: value.from, + to: value.to, + rate: value.rate, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + ExchangeRateInput { + from: self.from, + to: self.to, + rate: self.rate, + } + } +} + +impl ExchangeRate { + /// Returns the source currency. + pub fn from(&self) -> &CurrencyCode { + &self.from + } + + /// Returns the target currency. + pub fn to(&self) -> &CurrencyCode { + &self.to + } + + /// Returns the exchange rate. + pub fn rate(&self) -> &Decimal { + &self.rate + } +} + +impl std::fmt::Display for ExchangeRate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::ValueObject; + + fn eur() -> CurrencyCode { + CurrencyCode::new("EUR".into()).unwrap() + } + + fn usd() -> CurrencyCode { + CurrencyCode::new("USD".into()).unwrap() + } + + #[test] + fn constructs_valid_rate() { + let r = ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: "1.0850".parse().unwrap(), + }) + .unwrap(); + assert_eq!(r.value(), "EUR/USD 1.0850"); + } + + #[test] + fn from_accessor() { + let r = ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: "1.0850".parse().unwrap(), + }) + .unwrap(); + assert_eq!(r.from().value(), "EUR"); + } + + #[test] + fn to_accessor() { + let r = ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: "1.0850".parse().unwrap(), + }) + .unwrap(); + assert_eq!(r.to().value(), "USD"); + } + + #[test] + fn rate_accessor() { + let rate_val: Decimal = "1.0850".parse().unwrap(); + let r = ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: rate_val, + }) + .unwrap(); + assert_eq!(*r.rate(), "1.0850".parse::().unwrap()); + } + + #[test] + fn rejects_zero_rate() { + assert!( + ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: Decimal::ZERO, + }) + .is_err() + ); + } + + #[test] + fn rejects_negative_rate() { + assert!( + ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: "-1".parse().unwrap(), + }) + .is_err() + ); + } + + #[test] + fn rejects_same_currency() { + assert!( + ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: eur(), + rate: "1".parse().unwrap(), + }) + .is_err() + ); + } + + #[test] + fn display_matches_value() { + let r = ExchangeRate::new(ExchangeRateInput { + from: eur(), + to: usd(), + rate: "1.0850".parse().unwrap(), + }) + .unwrap(); + assert_eq!(r.to_string(), r.value().to_owned()); + } +} diff --git a/src/finance/iban.rs b/src/finance/iban.rs new file mode 100644 index 0000000..59d6dc4 --- /dev/null +++ b/src/finance/iban.rs @@ -0,0 +1,199 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Iban`]. +pub type IbanInput = String; + +/// Output type for [`Iban`] — canonical uppercase string without spaces. +pub type IbanOutput = String; + +/// A validated IBAN (International Bank Account Number). +/// +/// On construction all spaces are stripped and the value is uppercased. The +/// mod-97 algorithm is used to validate the check digits: the first four +/// characters are moved to the end, each letter is replaced by its numeric +/// value (`A=10` … `Z=35`), and the resulting number mod 97 must equal 1. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::Iban; +/// use arvo::traits::ValueObject; +/// +/// let iban = Iban::new("GB82 WEST 1234 5698 7654 32".into()).unwrap(); +/// assert_eq!(iban.value(), "GB82WEST12345698765432"); +/// assert_eq!(iban.country_code(), "GB"); +/// assert_eq!(iban.check_digits(), "82"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Iban(String); + +impl ValueObject for Iban { + type Input = IbanInput; + type Output = IbanOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let stripped: String = value + .chars() + .filter(|c| !c.is_whitespace()) + .map(|c| c.to_ascii_uppercase()) + .collect(); + + if stripped.is_empty() { + return Err(ValidationError::empty("Iban")); + } + + let len = stripped.len(); + if !(15..=34).contains(&len) { + return Err(ValidationError::invalid("Iban", &stripped)); + } + + let bytes = stripped.as_bytes(); + if !bytes[0].is_ascii_alphabetic() || !bytes[1].is_ascii_alphabetic() { + return Err(ValidationError::invalid("Iban", &stripped)); + } + if !bytes[2].is_ascii_digit() || !bytes[3].is_ascii_digit() { + return Err(ValidationError::invalid("Iban", &stripped)); + } + if !stripped[4..].chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(ValidationError::invalid("Iban", &stripped)); + } + + if iban_mod97(&stripped) != 1 { + return Err(ValidationError::invalid("Iban", &stripped)); + } + + Ok(Self(stripped)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl Iban { + /// Returns the 2-letter country code, e.g. `"GB"`. + pub fn country_code(&self) -> &str { + &self.0[..2] + } + + /// Returns the 2-digit check digits, e.g. `"82"`. + pub fn check_digits(&self) -> &str { + &self.0[2..4] + } + + /// Returns the Basic Bank Account Number (BBAN), characters 5 onwards. + pub fn bban(&self) -> &str { + &self.0[4..] + } +} + +impl TryFrom<&str> for Iban { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for Iban { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +fn iban_mod97(iban: &str) -> u64 { + // Rearrange: move first 4 characters to the end. + let rearranged: String = iban[4..].chars().chain(iban[..4].chars()).collect(); + + let mut remainder: u64 = 0; + for c in rearranged.chars() { + if c.is_ascii_digit() { + remainder = (remainder * 10 + (c as u64 - b'0' as u64)) % 97; + } else { + // Letter: A=10, B=11, ..., Z=35 (always 2 digits) + let val = c as u64 - b'A' as u64 + 10; + remainder = (remainder * 100 + val) % 97; + } + } + remainder +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_gb_iban() { + let i = Iban::new("GB82WEST12345698765432".into()).unwrap(); + assert_eq!(i.value(), "GB82WEST12345698765432"); + } + + #[test] + fn strips_spaces() { + let i = Iban::new("GB82 WEST 1234 5698 7654 32".into()).unwrap(); + assert_eq!(i.value(), "GB82WEST12345698765432"); + } + + #[test] + fn normalises_to_uppercase() { + let i = Iban::new("gb82west12345698765432".into()).unwrap(); + assert_eq!(i.value(), "GB82WEST12345698765432"); + } + + #[test] + fn country_code_accessor() { + let i = Iban::new("GB82WEST12345698765432".into()).unwrap(); + assert_eq!(i.country_code(), "GB"); + } + + #[test] + fn check_digits_accessor() { + let i = Iban::new("GB82WEST12345698765432".into()).unwrap(); + assert_eq!(i.check_digits(), "82"); + } + + #[test] + fn bban_accessor() { + let i = Iban::new("GB82WEST12345698765432".into()).unwrap(); + assert_eq!(i.bban(), "WEST12345698765432"); + } + + #[test] + fn accepts_german_iban() { + assert!(Iban::new("DE89370400440532013000".into()).is_ok()); + } + + #[test] + fn accepts_czech_iban() { + assert!(Iban::new("CZ6508000000192000145399".into()).is_ok()); + } + + #[test] + fn rejects_empty() { + assert!(Iban::new(String::new()).is_err()); + } + + #[test] + fn rejects_too_short() { + assert!(Iban::new("GB82WEST123".into()).is_err()); + } + + #[test] + fn rejects_invalid_checksum() { + assert!(Iban::new("GB83WEST12345698765432".into()).is_err()); + } + + #[test] + fn try_from_str() { + let i: Iban = "GB82WEST12345698765432".try_into().unwrap(); + assert_eq!(i.value(), "GB82WEST12345698765432"); + } +} diff --git a/src/finance/mod.rs b/src/finance/mod.rs new file mode 100644 index 0000000..666a85a --- /dev/null +++ b/src/finance/mod.rs @@ -0,0 +1,19 @@ +mod bic; +mod card_expiry_date; +mod credit_card_number; +mod currency_code; +mod exchange_rate; +mod iban; +mod money; +mod percentage; +mod vat_number; + +pub use bic::{Bic, BicInput, BicOutput}; +pub use card_expiry_date::{CardExpiryDate, CardExpiryDateInput, CardExpiryDateOutput}; +pub use credit_card_number::{CreditCardNumber, CreditCardNumberInput, CreditCardNumberOutput}; +pub use currency_code::{CurrencyCode, CurrencyCodeInput, CurrencyCodeOutput}; +pub use exchange_rate::{ExchangeRate, ExchangeRateInput, ExchangeRateOutput}; +pub use iban::{Iban, IbanInput, IbanOutput}; +pub use money::{Money, MoneyInput, MoneyOutput}; +pub use percentage::{Percentage, PercentageInput, PercentageOutput}; +pub use vat_number::{VatNumber, VatNumberInput, VatNumberOutput}; diff --git a/src/finance/money.rs b/src/finance/money.rs new file mode 100644 index 0000000..e79aa55 --- /dev/null +++ b/src/finance/money.rs @@ -0,0 +1,185 @@ +use rust_decimal::Decimal; + +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +use super::currency_code::CurrencyCode; + +/// Input type for [`Money`] construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MoneyInput { + /// Monetary amount. Negative values represent debts. + pub amount: Decimal, + /// ISO 4217 currency code. + pub currency: CurrencyCode, +} + +/// Output type for [`Money`] — canonical `" "` string. +pub type MoneyOutput = String; + +/// A validated monetary amount with an associated currency. +/// +/// `amount` may be any finite `Decimal` value; negative amounts represent debts. +/// The `currency` must be a valid [`CurrencyCode`]. The canonical output is +/// formatted as `" "` with two decimal places, e.g. `"10.00 EUR"`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::{CurrencyCode, Money, MoneyInput}; +/// use arvo::traits::ValueObject; +/// +/// let money = Money::new(MoneyInput { +/// amount: "10.50".parse().unwrap(), +/// currency: CurrencyCode::new("EUR".into()).unwrap(), +/// }).unwrap(); +/// +/// assert_eq!(money.value(), "10.50 EUR"); +/// assert_eq!(money.currency().value(), "EUR"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Money { + amount: Decimal, + currency: CurrencyCode, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Money { + type Input = MoneyInput; + type Output = MoneyOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let canonical = format!("{:.2} {}", value.amount, value.currency); + Ok(Self { + amount: value.amount, + currency: value.currency, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + MoneyInput { + amount: self.amount, + currency: self.currency, + } + } +} + +impl Money { + /// Returns the monetary amount. + pub fn amount(&self) -> &Decimal { + &self.amount + } + + /// Returns the currency code. + pub fn currency(&self) -> &CurrencyCode { + &self.currency + } +} + +impl std::fmt::Display for Money { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::ValueObject; + + fn eur() -> CurrencyCode { + CurrencyCode::new("EUR".into()).unwrap() + } + + fn usd() -> CurrencyCode { + CurrencyCode::new("USD".into()).unwrap() + } + + #[test] + fn constructs_valid_money() { + let m = Money::new(MoneyInput { + amount: "10.50".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + assert_eq!(m.value(), "10.50 EUR"); + } + + #[test] + fn formats_with_two_decimal_places() { + let m = Money::new(MoneyInput { + amount: "100".parse().unwrap(), + currency: usd(), + }) + .unwrap(); + assert_eq!(m.value(), "100.00 USD"); + } + + #[test] + fn allows_negative_amount() { + let m = Money::new(MoneyInput { + amount: "-5.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + assert_eq!(m.value(), "-5.00 EUR"); + } + + #[test] + fn allows_zero_amount() { + let m = Money::new(MoneyInput { + amount: Decimal::ZERO, + currency: eur(), + }) + .unwrap(); + assert_eq!(m.value(), "0.00 EUR"); + } + + #[test] + fn amount_accessor() { + let m = Money::new(MoneyInput { + amount: "42.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + assert_eq!(m.amount(), &"42.00".parse::().unwrap()); + } + + #[test] + fn currency_accessor() { + let m = Money::new(MoneyInput { + amount: Decimal::ZERO, + currency: eur(), + }) + .unwrap(); + assert_eq!(m.currency().value(), "EUR"); + } + + #[test] + fn display_matches_value() { + let m = Money::new(MoneyInput { + amount: "9.99".parse().unwrap(), + currency: usd(), + }) + .unwrap(); + assert_eq!(m.to_string(), m.value().to_owned()); + } + + #[test] + fn into_inner_roundtrip() { + let input = MoneyInput { + amount: "1.00".parse().unwrap(), + currency: eur(), + }; + let m = Money::new(input.clone()).unwrap(); + assert_eq!(m.into_inner(), input); + } +} diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs new file mode 100644 index 0000000..cda8302 --- /dev/null +++ b/src/finance/percentage.rs @@ -0,0 +1,119 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Percentage`]. +pub type PercentageInput = f64; + +/// Output type for [`Percentage`]. +pub type PercentageOutput = f64; + +/// A validated percentage value in the range `0.0..=100.0`. +/// +/// The value must be finite (not NaN, not infinite) and within the inclusive +/// range from `0.0` to `100.0`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::Percentage; +/// use arvo::traits::ValueObject; +/// +/// let p = Percentage::new(42.5).unwrap(); +/// assert_eq!(*p.value(), 42.5); +/// +/// assert!(Percentage::new(-1.0).is_err()); +/// assert!(Percentage::new(101.0).is_err()); +/// assert!(Percentage::new(f64::NAN).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Percentage(f64); + +impl ValueObject for Percentage { + type Input = PercentageInput; + type Output = PercentageOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if !value.is_finite() { + return Err(ValidationError::invalid("Percentage", &value.to_string())); + } + + if !(0.0..=100.0).contains(&value) { + return Err(ValidationError::invalid("Percentage", &value.to_string())); + } + + Ok(Self(value)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl std::fmt::Display for Percentage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}%", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_zero() { + let p = Percentage::new(0.0).unwrap(); + assert_eq!(*p.value(), 0.0); + } + + #[test] + fn accepts_hundred() { + let p = Percentage::new(100.0).unwrap(); + assert_eq!(*p.value(), 100.0); + } + + #[test] + fn accepts_midpoint() { + let p = Percentage::new(42.5).unwrap(); + assert_eq!(*p.value(), 42.5); + } + + #[test] + fn rejects_negative() { + assert!(Percentage::new(-0.001).is_err()); + } + + #[test] + fn rejects_above_hundred() { + assert!(Percentage::new(100.001).is_err()); + } + + #[test] + fn rejects_nan() { + assert!(Percentage::new(f64::NAN).is_err()); + } + + #[test] + fn rejects_infinity() { + assert!(Percentage::new(f64::INFINITY).is_err()); + assert!(Percentage::new(f64::NEG_INFINITY).is_err()); + } + + #[test] + fn display_appends_percent() { + let p = Percentage::new(50.0).unwrap(); + assert_eq!(p.to_string(), "50%"); + } + + #[test] + fn into_inner_roundtrip() { + let p = Percentage::new(33.3).unwrap(); + assert_eq!(p.into_inner(), 33.3); + } +} diff --git a/src/finance/vat_number.rs b/src/finance/vat_number.rs new file mode 100644 index 0000000..5de87f8 --- /dev/null +++ b/src/finance/vat_number.rs @@ -0,0 +1,172 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`VatNumber`]. +pub type VatNumberInput = String; + +/// Output type for [`VatNumber`] — canonical uppercase string without spaces. +pub type VatNumberOutput = String; + +/// EU VAT country prefixes (sorted for binary search). +static EU_PREFIXES: &[&str] = &[ + "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI", "FR", "HR", "HU", "IE", "IT", + "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK", "XI", +]; + +/// A validated EU VAT number. +/// +/// On construction the input is trimmed, uppercased, and internal spaces are +/// stripped. The value must start with a known EU country prefix (2 letters) +/// followed by 2–13 alphanumeric characters. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::finance::VatNumber; +/// use arvo::traits::ValueObject; +/// +/// let vat = VatNumber::new("CZ12345678".into()).unwrap(); +/// assert_eq!(vat.value(), "CZ12345678"); +/// assert_eq!(vat.country_prefix(), "CZ"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct VatNumber(String); + +impl ValueObject for VatNumber { + type Input = VatNumberInput; + type Output = VatNumberOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let normalised: String = value + .trim() + .to_uppercase() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + if normalised.is_empty() { + return Err(ValidationError::empty("VatNumber")); + } + + if normalised.len() < 4 { + return Err(ValidationError::invalid("VatNumber", &normalised)); + } + + let prefix = &normalised[..2]; + if !prefix.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(ValidationError::invalid("VatNumber", &normalised)); + } + + if EU_PREFIXES.binary_search(&prefix).is_err() { + return Err(ValidationError::invalid("VatNumber", &normalised)); + } + + let suffix = &normalised[2..]; + if suffix.len() < 2 || suffix.len() > 13 { + return Err(ValidationError::invalid("VatNumber", &normalised)); + } + + if !suffix.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(ValidationError::invalid("VatNumber", &normalised)); + } + + Ok(Self(normalised)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl VatNumber { + /// Returns the 2-letter EU country prefix, e.g. `"CZ"`. + pub fn country_prefix(&self) -> &str { + &self.0[..2] + } +} + +impl TryFrom<&str> for VatNumber { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for VatNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_czech_vat() { + let v = VatNumber::new("CZ12345678".into()).unwrap(); + assert_eq!(v.value(), "CZ12345678"); + } + + #[test] + fn accepts_german_vat() { + assert!(VatNumber::new("DE123456789".into()).is_ok()); + } + + #[test] + fn accepts_xi_prefix() { + assert!(VatNumber::new("XI123456789".into()).is_ok()); + } + + #[test] + fn normalises_to_uppercase() { + let v = VatNumber::new("cz12345678".into()).unwrap(); + assert_eq!(v.value(), "CZ12345678"); + } + + #[test] + fn strips_internal_spaces() { + let v = VatNumber::new("CZ 1234 5678".into()).unwrap(); + assert_eq!(v.value(), "CZ12345678"); + } + + #[test] + fn country_prefix_accessor() { + let v = VatNumber::new("CZ12345678".into()).unwrap(); + assert_eq!(v.country_prefix(), "CZ"); + } + + #[test] + fn rejects_empty() { + assert!(VatNumber::new(String::new()).is_err()); + } + + #[test] + fn rejects_unknown_prefix() { + assert!(VatNumber::new("US12345678".into()).is_err()); + } + + #[test] + fn rejects_suffix_too_short() { + assert!(VatNumber::new("CZ1".into()).is_err()); + } + + #[test] + fn rejects_suffix_too_long() { + assert!(VatNumber::new("CZ12345678901234".into()).is_err()); + } + + #[test] + fn try_from_str() { + let v: VatNumber = "DE123456789".try_into().unwrap(); + assert_eq!(v.value(), "DE123456789"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9415df7..0a0494a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,9 @@ pub mod traits; #[cfg(feature = "contact")] pub mod contact; +#[cfg(feature = "finance")] +pub mod finance; + #[cfg(feature = "identifiers")] pub mod identifiers;