Skip to main content

paykit_lib/
payment_reference.rs

1use crate::{PaykitError, Result};
2
3/// Payee-visible correlation reference used to connect payments with Paykit artifacts.
4#[derive(Clone, PartialEq, Eq, Hash)]
5pub struct PaymentReference(String);
6
7/// Maximum Payment Reference length in Unicode scalar values.
8pub const PAYMENT_REFERENCE_MAX_LEN: usize = 256;
9
10impl PaymentReference {
11    /// Create a Payment Reference after validating that the input is non-empty,
12    /// bounded, and free of control characters.
13    pub fn new(reference: impl Into<String>) -> Result<Self> {
14        let reference = reference.into();
15        if reference.is_empty() {
16            return Err(PaykitError::Validation(
17                "Payment Reference must not be empty".into(),
18            ));
19        }
20        let char_count = reference.chars().count();
21        if char_count > PAYMENT_REFERENCE_MAX_LEN {
22            return Err(PaykitError::Validation(format!(
23                "Payment Reference must not exceed {PAYMENT_REFERENCE_MAX_LEN} characters, got {char_count}"
24            )));
25        }
26        if let Some((pos, ch)) = reference.char_indices().find(|&(_, ch)| ch.is_control()) {
27            return Err(PaykitError::Validation(format!(
28                "Payment Reference must not contain control character U+{:04X} at byte {pos}",
29                ch as u32
30            )));
31        }
32        Ok(Self(reference))
33    }
34
35    /// Access the inner reference string.
36    pub fn as_str(&self) -> &str {
37        &self.0
38    }
39}
40
41impl std::fmt::Display for PaymentReference {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str(&self.0)
44    }
45}
46
47impl std::fmt::Debug for PaymentReference {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.write_str("PaymentReference(<redacted>)")
50    }
51}
52
53impl AsRef<str> for PaymentReference {
54    fn as_ref(&self) -> &str {
55        &self.0
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::PaykitError;
63
64    #[test]
65    fn test_payment_reference_accepts_text() {
66        let reference = PaymentReference::new("invoice 2026/0001").unwrap();
67        assert_eq!(reference.as_str(), "invoice 2026/0001");
68        assert_eq!(format!("{reference}"), "invoice 2026/0001");
69        assert_eq!(format!("{reference:?}"), "PaymentReference(<redacted>)");
70    }
71
72    #[test]
73    fn test_payment_reference_rejects_empty() {
74        let err = PaymentReference::new("").unwrap_err();
75        assert!(
76            matches!(err, PaykitError::Validation(ref msg) if msg.contains("must not be empty"))
77        );
78    }
79
80    #[test]
81    fn test_payment_reference_rejects_control_characters() {
82        let err = PaymentReference::new("invoice\n2026").unwrap_err();
83        assert!(
84            matches!(err, PaykitError::Validation(ref msg) if msg.contains("control character"))
85        );
86    }
87
88    #[test]
89    fn test_payment_reference_rejects_over_max_length() {
90        let reference = "a".repeat(PAYMENT_REFERENCE_MAX_LEN + 1);
91        let err = PaymentReference::new(reference).unwrap_err();
92        assert!(matches!(err, PaykitError::Validation(ref msg) if msg.contains("must not exceed")));
93    }
94}