Skip to main content

paykit_lib/
payment_amount.rs

1use crate::{
2    validation::{validate_asset_text, validate_decimal_text},
3    Result,
4};
5
6/// Amount of value in a payment flow, expressed as decimal text plus asset.
7///
8/// # Validation
9///
10/// [`PaymentAmount::new`] accepts a `value` consisting only of ASCII digits and
11/// at most one decimal point, with at least one digit. Leading `+` and `-` signs
12/// are rejected. Values such as `.5` and `10.` are accepted. The `asset` must be
13/// non-empty and contain no control characters. Beyond these checks, Paykit
14/// defines no range, precision, scale, normalization, or asset-registry policy.
15///
16/// Fields are private so validation survives construction and cloning.
17///
18/// ```compile_fail,E0616
19/// let mut amount = paykit_lib::PaymentAmount::new("1", "btc").unwrap();
20/// amount.value = "invalid".into();
21/// ```
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct PaymentAmount {
24    /// Decimal string, such as `10.00`.
25    value: String,
26    /// Asset code or unit, such as `usd`, `btc`, or `usdt`.
27    asset: String,
28}
29
30impl PaymentAmount {
31    /// Access the preserved decimal spelling.
32    pub fn value(&self) -> &str {
33        &self.value
34    }
35    /// Access the exact asset text.
36    pub fn asset(&self) -> &str {
37        &self.asset
38    }
39
40    /// Create a Payment Amount after validating its value and asset fields.
41    pub fn new(value: impl Into<String>, asset: impl Into<String>) -> Result<Self> {
42        Self::new_with_label(value.into(), asset.into(), "Payment Amount")
43    }
44
45    pub(crate) fn new_with_label(
46        value: String,
47        asset: String,
48        label: &'static str,
49    ) -> Result<Self> {
50        let amount = Self { value, asset };
51        amount.validate_with_label(label)?;
52        Ok(amount)
53    }
54
55    pub(crate) fn validate_with_label(&self, label: &'static str) -> Result<()> {
56        validate_decimal_text(&self.value, format_args!("{label}.value"))?;
57        validate_asset_text(&self.asset, "Payment Amount asset")
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::PaykitError;
65
66    #[test]
67    fn payment_amount_accepts_decimal_value_and_asset() {
68        let amount = PaymentAmount::new("10.00", "usd").unwrap();
69        assert_eq!(amount.value, "10.00");
70        assert_eq!(amount.asset, "usd");
71    }
72
73    #[test]
74    fn payment_amount_rejects_invalid_value() {
75        let err = PaymentAmount::new("ten", "usd").unwrap_err();
76        assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("decimal string")));
77    }
78
79    #[test]
80    fn payment_amount_rejects_empty_asset() {
81        let err = PaymentAmount::new("10.00", "").unwrap_err();
82        assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("must not be empty")));
83    }
84
85    #[test]
86    fn payment_amount_rejects_control_character_asset() {
87        let err = PaymentAmount::new("10.00", "usd\n").unwrap_err();
88        assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("control characters")));
89    }
90
91    #[test]
92    fn payment_amount_rejects_multiple_decimal_points() {
93        let err = PaymentAmount::new("1.2.3", "usd").unwrap_err();
94        assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("decimal string")));
95    }
96
97    #[test]
98    fn payment_amount_rejects_lone_decimal_point() {
99        let err = PaymentAmount::new(".", "usd").unwrap_err();
100        assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("at least one digit")));
101    }
102
103    // Pin of currently-accepted behavior: values with a bare leading or
104    // trailing decimal point (".5", "10.") pass validation today. Tightening
105    // this is a protocol decision; this test makes any such change a visible
106    // diff rather than a silent one.
107    #[test]
108    fn test_payment_amount_bare_decimal_point_currently_accepted() {
109        let leading = PaymentAmount::new(".5", "usd").unwrap();
110        assert_eq!(leading.value, ".5");
111        let trailing = PaymentAmount::new("10.", "usd").unwrap();
112        assert_eq!(trailing.value, "10.");
113    }
114}