Skip to main content

mx20022_validate/rules/
amount.rs

1//! ISO 20022 amount format validation rule.
2//!
3//! ISO 20022 decimal amounts (e.g. `ActiveCurrencyAndAmount`) must be:
4//! - A positive decimal number in string form
5//! - Greater than zero
6//! - At most 18 integer digits
7//! - At most 5 fractional digits
8
9use crate::error::{Severity, ValidationError};
10use crate::rules::Rule;
11
12/// Validates a decimal amount string per ISO 20022 constraints.
13///
14/// # Examples
15///
16/// ```
17/// use mx20022_validate::rules::amount::AmountFormatRule;
18/// use mx20022_validate::rules::Rule;
19///
20/// let rule = AmountFormatRule;
21///
22/// let errors = rule.validate("1000.00", "/path");
23/// assert!(errors.is_empty(), "Valid amount should produce no errors");
24///
25/// let errors = rule.validate("-1.00", "/path");
26/// assert!(!errors.is_empty(), "Negative amount should be rejected");
27///
28/// let errors = rule.validate("0", "/path");
29/// assert!(!errors.is_empty(), "Zero amount should be rejected");
30///
31/// let errors = rule.validate("1.123456", "/path");
32/// assert!(!errors.is_empty(), "Too many fractional digits should be rejected");
33/// ```
34pub struct AmountFormatRule;
35
36/// Maximum allowed integer digits (ISO 20022 constraint).
37const MAX_INTEGER_DIGITS: usize = 18;
38/// Maximum allowed fractional digits (ISO 20022 constraint).
39const MAX_FRACTIONAL_DIGITS: usize = 5;
40
41impl Rule for AmountFormatRule {
42    fn id(&self) -> &'static str {
43        "AMOUNT_FORMAT"
44    }
45
46    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
47        match validate_amount(value) {
48            Ok(()) => vec![],
49            Err(msg) => {
50                vec![ValidationError::new(
51                    path,
52                    Severity::Error,
53                    "AMOUNT_FORMAT",
54                    msg,
55                )]
56            }
57        }
58    }
59}
60
61fn validate_amount(value: &str) -> Result<(), String> {
62    if value.is_empty() {
63        return Err("Amount must not be empty".to_owned());
64    }
65
66    // Reject anything that doesn't look like a positive decimal number.
67    // Accept optional leading sign only if it's '+' (no '-' allowed).
68    let stripped = value.strip_prefix('+').unwrap_or(value);
69
70    // Must not start with '-'
71    if value.starts_with('-') {
72        return Err(format!("Amount must be positive (> 0), got: `{value}`"));
73    }
74
75    // Split into integer and fractional parts
76    let (integer_part, fractional_part) = match stripped.split_once('.') {
77        Some((int, frac)) => (int, Some(frac)),
78        None => (stripped, None),
79    };
80
81    // Validate integer part: must be non-empty and all digits
82    if integer_part.is_empty() {
83        return Err(format!("Amount has no integer part: `{value}`"));
84    }
85
86    if !integer_part.chars().all(|c| c.is_ascii_digit()) {
87        return Err(format!(
88            "Amount contains non-numeric characters in integer part: `{value}`"
89        ));
90    }
91
92    // Count significant integer digits (strip leading zeros for the count, but
93    // ISO 20022 measures total digits before the decimal point)
94    let integer_digits = integer_part.len();
95    if integer_digits > MAX_INTEGER_DIGITS {
96        return Err(format!(
97            "Amount integer part has {integer_digits} digits, maximum is {MAX_INTEGER_DIGITS}: `{value}`"
98        ));
99    }
100
101    // Validate fractional part if present
102    if let Some(frac) = fractional_part {
103        if frac.is_empty() {
104            return Err(format!("Amount has trailing decimal point: `{value}`"));
105        }
106        if !frac.chars().all(|c| c.is_ascii_digit()) {
107            return Err(format!(
108                "Amount contains non-numeric characters in fractional part: `{value}`"
109            ));
110        }
111        if frac.len() > MAX_FRACTIONAL_DIGITS {
112            return Err(format!(
113                "Amount has {} fractional digits, maximum is {MAX_FRACTIONAL_DIGITS}: `{value}`",
114                frac.len()
115            ));
116        }
117    }
118
119    // Must be > 0: parse as f64 and check (safe since we've already validated
120    // the format — we only need to distinguish zero from positive)
121    let parsed: f64 = value
122        .parse()
123        .map_err(|_| format!("Amount is not a valid decimal number: `{value}`"))?;
124    if parsed <= 0.0 {
125        return Err(format!("Amount must be greater than zero, got: `{value}`"));
126    }
127
128    Ok(())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::rules::Rule;
135
136    const VALID_AMOUNTS: &[&str] = &[
137        "1",
138        "1.0",
139        "1.00",
140        "1000.00",
141        "999999999999999999", // 18 integer digits
142        "0.12345",            // 5 fractional digits
143        "1000000000.12345",
144        "0.00001",
145        "+1.00", // explicit positive sign
146        "1.1",
147    ];
148
149    const INVALID_AMOUNTS: &[&str] = &[
150        "-1.00",               // negative
151        "0",                   // zero (integer)
152        "0.00",                // zero (decimal)
153        "0.00000",             // zero with max fractional
154        "1.123456",            // 6 fractional digits
155        "1234567890123456789", // 19 integer digits
156        "abc",                 // non-numeric
157        "1.2.3",               // multiple decimal points
158        "",                    // empty
159        "1.",                  // trailing decimal point
160        ".5",                  // no integer part
161        "-0.01",               // negative
162    ];
163
164    #[test]
165    fn valid_amounts_pass() {
166        let rule = AmountFormatRule;
167        for amount in VALID_AMOUNTS {
168            let errors = rule.validate(amount, "/test");
169            assert!(
170                errors.is_empty(),
171                "Expected no errors for valid amount `{amount}`, got: {errors:?}"
172            );
173        }
174    }
175
176    #[test]
177    fn invalid_amounts_fail() {
178        let rule = AmountFormatRule;
179        for amount in INVALID_AMOUNTS {
180            let errors = rule.validate(amount, "/test");
181            assert!(
182                !errors.is_empty(),
183                "Expected errors for invalid amount `{amount}`"
184            );
185        }
186    }
187
188    #[test]
189    fn error_has_correct_rule_id_and_path() {
190        let rule = AmountFormatRule;
191        let errors = rule.validate("-1.00", "/Document/Amt");
192        assert_eq!(errors.len(), 1);
193        assert_eq!(errors[0].rule_id, "AMOUNT_FORMAT");
194        assert_eq!(errors[0].path, "/Document/Amt");
195        assert_eq!(errors[0].severity, Severity::Error);
196    }
197
198    #[test]
199    fn rule_id_is_amount_format() {
200        assert_eq!(AmountFormatRule.id(), "AMOUNT_FORMAT");
201    }
202
203    #[test]
204    fn zero_is_rejected() {
205        let rule = AmountFormatRule;
206        let errors = rule.validate("0", "/test");
207        assert!(!errors.is_empty());
208        assert!(
209            errors[0].message.contains("greater than zero") || errors[0].message.contains("> 0"),
210            "Expected zero message, got: {}",
211            errors[0].message
212        );
213    }
214
215    #[test]
216    fn negative_is_rejected() {
217        let rule = AmountFormatRule;
218        let errors = rule.validate("-100.00", "/test");
219        assert!(!errors.is_empty());
220        assert!(
221            errors[0].message.contains("positive"),
222            "Expected positive message, got: {}",
223            errors[0].message
224        );
225    }
226
227    #[test]
228    fn too_many_fractional_digits_rejected() {
229        let rule = AmountFormatRule;
230        let errors = rule.validate("1.123456", "/test");
231        assert!(!errors.is_empty());
232        assert!(
233            errors[0].message.contains("fractional"),
234            "Expected fractional message, got: {}",
235            errors[0].message
236        );
237    }
238
239    #[test]
240    fn too_many_integer_digits_rejected() {
241        let rule = AmountFormatRule;
242        let errors = rule.validate("1234567890123456789", "/test"); // 19 digits
243        assert!(!errors.is_empty());
244        assert!(
245            errors[0].message.contains("integer"),
246            "Expected integer digits message, got: {}",
247            errors[0].message
248        );
249    }
250
251    #[test]
252    fn non_numeric_rejected() {
253        let rule = AmountFormatRule;
254        let errors = rule.validate("abc", "/test");
255        assert!(!errors.is_empty());
256    }
257
258    #[test]
259    fn empty_rejected() {
260        let rule = AmountFormatRule;
261        let errors = rule.validate("", "/test");
262        assert!(!errors.is_empty());
263        assert!(errors[0].message.contains("empty"));
264    }
265
266    #[test]
267    fn exactly_18_integer_digits_passes() {
268        let rule = AmountFormatRule;
269        let errors = rule.validate("999999999999999999", "/test"); // 18 digits
270        assert!(errors.is_empty(), "18 integer digits should be valid");
271    }
272
273    #[test]
274    fn exactly_5_fractional_digits_passes() {
275        let rule = AmountFormatRule;
276        let errors = rule.validate("1.12345", "/test"); // 5 fractional digits
277        assert!(errors.is_empty(), "5 fractional digits should be valid");
278    }
279}