Skip to main content

a2a_ap2/
validation.rs

1//! Validation for AP2 types.
2
3use crate::error::{Ap2Error, Result};
4use crate::types::{
5    CartContents, CartMandate, IntentMandate, PaymentCurrencyAmount, PaymentDetailsInit,
6    PaymentItem, PaymentMandate, PaymentMandateContents, PaymentMethodData, PaymentReceipt,
7    PaymentRequest, PaymentResponse, PaymentShippingOption,
8};
9
10/// Trait for validating AP2 types.
11pub trait Validate {
12    /// Check this value for structural correctness.
13    fn validate(&self) -> Result<()>;
14}
15
16impl Validate for PaymentCurrencyAmount {
17    fn validate(&self) -> Result<()> {
18        if self.currency.len() != 3 || !self.currency.chars().all(|c| c.is_ascii_uppercase()) {
19            return Err(Ap2Error::ValidationError {
20                field: "currency".into(),
21                message: format!("must be 3-letter ISO 4217 code, got {:?}", self.currency),
22            });
23        }
24        Ok(())
25    }
26}
27
28impl Validate for PaymentItem {
29    fn validate(&self) -> Result<()> {
30        if self.label.trim().is_empty() {
31            return Err(Ap2Error::MissingField("PaymentItem.label".into()));
32        }
33        self.amount.validate()?;
34        if self.refund_period < 0 {
35            return Err(Ap2Error::ValidationError {
36                field: "refund_period".into(),
37                message: "must be non-negative".into(),
38            });
39        }
40        Ok(())
41    }
42}
43
44impl Validate for PaymentShippingOption {
45    fn validate(&self) -> Result<()> {
46        if self.id.trim().is_empty() {
47            return Err(Ap2Error::MissingField("PaymentShippingOption.id".into()));
48        }
49        if self.label.trim().is_empty() {
50            return Err(Ap2Error::MissingField("PaymentShippingOption.label".into()));
51        }
52        self.amount.validate()
53    }
54}
55
56impl Validate for PaymentMethodData {
57    fn validate(&self) -> Result<()> {
58        if self.supported_methods.trim().is_empty() {
59            return Err(Ap2Error::MissingField(
60                "PaymentMethodData.supported_methods".into(),
61            ));
62        }
63        Ok(())
64    }
65}
66
67impl Validate for PaymentDetailsInit {
68    fn validate(&self) -> Result<()> {
69        if self.id.trim().is_empty() {
70            return Err(Ap2Error::MissingField("PaymentDetailsInit.id".into()));
71        }
72        self.total.validate()?;
73        for item in &self.display_items {
74            item.validate()?;
75        }
76        Ok(())
77    }
78}
79
80impl Validate for PaymentRequest {
81    fn validate(&self) -> Result<()> {
82        if self.method_data.is_empty() {
83            return Err(Ap2Error::MissingField("PaymentRequest.method_data".into()));
84        }
85        for md in &self.method_data {
86            md.validate()?;
87        }
88        self.details.validate()
89    }
90}
91
92impl Validate for PaymentResponse {
93    fn validate(&self) -> Result<()> {
94        if self.request_id.trim().is_empty() {
95            return Err(Ap2Error::MissingField("PaymentResponse.request_id".into()));
96        }
97        if self.method_name.trim().is_empty() {
98            return Err(Ap2Error::MissingField("PaymentResponse.method_name".into()));
99        }
100        Ok(())
101    }
102}
103
104impl Validate for IntentMandate {
105    fn validate(&self) -> Result<()> {
106        if self.natural_language_description.trim().is_empty() {
107            return Err(Ap2Error::MissingField(
108                "IntentMandate.natural_language_description".into(),
109            ));
110        }
111        if self.intent_expiry.trim().is_empty() {
112            return Err(Ap2Error::MissingField("IntentMandate.intent_expiry".into()));
113        }
114        Ok(())
115    }
116}
117
118impl Validate for CartContents {
119    fn validate(&self) -> Result<()> {
120        if self.id.trim().is_empty() {
121            return Err(Ap2Error::MissingField("CartContents.id".into()));
122        }
123        if self.cart_expiry.trim().is_empty() {
124            return Err(Ap2Error::MissingField("CartContents.cart_expiry".into()));
125        }
126        if self.merchant_name.trim().is_empty() {
127            return Err(Ap2Error::MissingField("CartContents.merchant_name".into()));
128        }
129        self.payment_request.validate()
130    }
131}
132
133impl Validate for CartMandate {
134    fn validate(&self) -> Result<()> {
135        self.contents.validate()
136    }
137}
138
139impl Validate for PaymentMandateContents {
140    fn validate(&self) -> Result<()> {
141        if self.payment_mandate_id.trim().is_empty() {
142            return Err(Ap2Error::MissingField(
143                "PaymentMandateContents.payment_mandate_id".into(),
144            ));
145        }
146        if self.payment_details_id.trim().is_empty() {
147            return Err(Ap2Error::MissingField(
148                "PaymentMandateContents.payment_details_id".into(),
149            ));
150        }
151        if self.merchant_agent.trim().is_empty() {
152            return Err(Ap2Error::MissingField(
153                "PaymentMandateContents.merchant_agent".into(),
154            ));
155        }
156        if self.timestamp.trim().is_empty() {
157            return Err(Ap2Error::MissingField(
158                "PaymentMandateContents.timestamp".into(),
159            ));
160        }
161        self.payment_details_total.validate()?;
162        self.payment_response.validate()
163    }
164}
165
166impl Validate for PaymentMandate {
167    fn validate(&self) -> Result<()> {
168        self.payment_mandate_contents.validate()
169    }
170}
171
172impl Validate for PaymentReceipt {
173    fn validate(&self) -> Result<()> {
174        if self.payment_mandate_id.trim().is_empty() {
175            return Err(Ap2Error::MissingField(
176                "PaymentReceipt.payment_mandate_id".into(),
177            ));
178        }
179        if self.payment_id.trim().is_empty() {
180            return Err(Ap2Error::MissingField("PaymentReceipt.payment_id".into()));
181        }
182        if self.timestamp.trim().is_empty() {
183            return Err(Ap2Error::MissingField("PaymentReceipt.timestamp".into()));
184        }
185        self.amount.validate()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::types::{PaymentCurrencyAmount, PaymentStatus, Success};
193
194    #[test]
195    fn valid_currency() {
196        let amt = PaymentCurrencyAmount {
197            currency: "USD".into(),
198            value: 10.0,
199        };
200        assert!(amt.validate().is_ok());
201    }
202
203    #[test]
204    fn invalid_currency() {
205        let amt = PaymentCurrencyAmount {
206            currency: "us".into(),
207            value: 10.0,
208        };
209        assert!(amt.validate().is_err());
210    }
211
212    #[test]
213    fn empty_label_fails() {
214        let item = PaymentItem {
215            label: "".into(),
216            amount: PaymentCurrencyAmount {
217                currency: "USD".into(),
218                value: 1.0,
219            },
220            pending: None,
221            refund_period: 30,
222        };
223        assert!(item.validate().is_err());
224    }
225
226    #[test]
227    fn valid_intent_mandate() {
228        let im = IntentMandate {
229            user_cart_confirmation_required: true,
230            natural_language_description: "Buy shoes".into(),
231            merchants: None,
232            skus: None,
233            requires_refundability: None,
234            intent_expiry: "2026-12-31T23:59:59Z".into(),
235        };
236        assert!(im.validate().is_ok());
237    }
238
239    #[test]
240    fn empty_description_fails() {
241        let im = IntentMandate {
242            user_cart_confirmation_required: true,
243            natural_language_description: "  ".into(),
244            merchants: None,
245            skus: None,
246            requires_refundability: None,
247            intent_expiry: "2026-12-31T23:59:59Z".into(),
248        };
249        assert!(im.validate().is_err());
250    }
251
252    #[test]
253    fn valid_receipt() {
254        let receipt = PaymentReceipt {
255            payment_mandate_id: "pm_1".into(),
256            timestamp: "2025-09-16T12:00:00Z".into(),
257            payment_id: "pay_1".into(),
258            amount: PaymentCurrencyAmount {
259                currency: "EUR".into(),
260                value: 50.0,
261            },
262            payment_status: PaymentStatus::Success(Success {
263                merchant_confirmation_id: "mc_1".into(),
264                psp_confirmation_id: None,
265                network_confirmation_id: None,
266            }),
267            payment_method_details: None,
268        };
269        assert!(receipt.validate().is_ok());
270    }
271}