Skip to main content

a2a_ap2/types/
mandate.rs

1//! AP2 mandate types: IntentMandate, CartMandate, and PaymentMandate.
2
3use serde::{Deserialize, Serialize};
4
5use super::payment_request::{PaymentItem, PaymentRequest, PaymentResponse};
6
7/// A user's purchase intent with constraints and requirements.
8///
9/// Created by a shopping agent based on user input and sent to a merchant
10/// agent via an A2A `Message` with data key
11/// [`INTENT_MANDATE_DATA_KEY`](super::roles::INTENT_MANDATE_DATA_KEY).
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct IntentMandate {
14    /// If `false`, the agent can make purchases without further user approval
15    /// once all purchase conditions are satisfied. Must be `true` if the
16    /// mandate is not signed by the user.
17    #[serde(default = "default_true")]
18    pub user_cart_confirmation_required: bool,
19
20    /// Natural-language description of the user's intent, confirmed by the
21    /// user. Required.
22    pub natural_language_description: String,
23
24    /// Merchants allowed to fulfill the intent. `None` means any merchant.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub merchants: Option<Vec<String>>,
27
28    /// Specific product SKUs. `None` means any SKU.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub skus: Option<Vec<String>>,
31
32    /// If `true`, purchased items must be refundable.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub requires_refundability: Option<bool>,
35
36    /// When this intent expires, in ISO 8601 format.
37    pub intent_expiry: String,
38}
39
40fn default_true() -> bool {
41    true
42}
43
44/// The detailed contents of a cart, signed by the merchant.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct CartContents {
47    /// Unique identifier for this cart.
48    pub id: String,
49
50    /// Whether the merchant requires user confirmation before purchase.
51    pub user_cart_confirmation_required: bool,
52
53    /// W3C `PaymentRequest` containing items, prices, and accepted methods.
54    pub payment_request: PaymentRequest,
55
56    /// When this cart expires, in ISO 8601 format.
57    pub cart_expiry: String,
58
59    /// Name of the merchant.
60    pub merchant_name: String,
61}
62
63/// A cart whose contents have been digitally signed by the merchant.
64///
65/// Returned by a merchant agent as an A2A `Artifact` with data key
66/// [`CART_MANDATE_DATA_KEY`](super::roles::CART_MANDATE_DATA_KEY).
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct CartMandate {
69    /// Cart details and payment information.
70    pub contents: CartContents,
71
72    /// Base64url-encoded JWT digitally signing the cart contents with the
73    /// merchant's private key.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub merchant_authorization: Option<String>,
76}
77
78/// Core payment authorization data inside a [`PaymentMandate`].
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct PaymentMandateContents {
81    /// Unique identifier for this payment mandate.
82    pub payment_mandate_id: String,
83
84    /// Reference to the payment request identifier.
85    pub payment_details_id: String,
86
87    /// Total amount being authorized.
88    pub payment_details_total: PaymentItem,
89
90    /// The user's chosen payment method and details.
91    pub payment_response: PaymentResponse,
92
93    /// Identifier of the merchant agent.
94    pub merchant_agent: String,
95
96    /// Creation timestamp in ISO 8601 format.
97    pub timestamp: String,
98}
99
100/// User's instructions and authorization for payment.
101///
102/// Sent via an A2A `Message` with data key
103/// [`PAYMENT_MANDATE_DATA_KEY`](super::roles::PAYMENT_MANDATE_DATA_KEY).
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct PaymentMandate {
106    /// Core payment authorization data.
107    pub payment_mandate_contents: PaymentMandateContents,
108
109    /// Base64url-encoded verifiable credential presentation binding the user
110    /// to the cart and payment mandate hashes.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub user_authorization: Option<String>,
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::types::payment_request::{
119        PaymentCurrencyAmount, PaymentDetailsInit, PaymentMethodData,
120    };
121
122    fn sample_payment_request() -> PaymentRequest {
123        PaymentRequest {
124            method_data: vec![PaymentMethodData {
125                supported_methods: "CARD".into(),
126                data: None,
127            }],
128            details: PaymentDetailsInit {
129                id: "order_123".into(),
130                display_items: vec![PaymentItem {
131                    label: "Shoes".into(),
132                    amount: PaymentCurrencyAmount {
133                        currency: "USD".into(),
134                        value: 120.0,
135                    },
136                    pending: None,
137                    refund_period: 30,
138                }],
139                shipping_options: None,
140                modifiers: None,
141                total: PaymentItem {
142                    label: "Total".into(),
143                    amount: PaymentCurrencyAmount {
144                        currency: "USD".into(),
145                        value: 120.0,
146                    },
147                    pending: None,
148                    refund_period: 30,
149                },
150            },
151            options: None,
152            shipping_address: None,
153        }
154    }
155
156    #[test]
157    fn intent_mandate_default_confirmation() {
158        let json = r#"{
159            "natural_language_description": "Buy red shoes",
160            "intent_expiry": "2026-12-31T23:59:59Z"
161        }"#;
162        let im: IntentMandate = serde_json::from_str(json).unwrap();
163        assert!(im.user_cart_confirmation_required);
164    }
165
166    #[test]
167    fn intent_mandate_roundtrip() {
168        let im = IntentMandate {
169            user_cart_confirmation_required: false,
170            natural_language_description: "Cool red shoes".into(),
171            merchants: Some(vec!["nike".into()]),
172            skus: None,
173            requires_refundability: Some(true),
174            intent_expiry: "2026-09-16T15:00:00Z".into(),
175        };
176        let json = serde_json::to_string(&im).unwrap();
177        let back: IntentMandate = serde_json::from_str(&json).unwrap();
178        assert_eq!(im, back);
179    }
180
181    #[test]
182    fn cart_mandate_roundtrip() {
183        let cm = CartMandate {
184            contents: CartContents {
185                id: "cart_123".into(),
186                user_cart_confirmation_required: false,
187                payment_request: sample_payment_request(),
188                cart_expiry: "2026-12-31T23:59:59Z".into(),
189                merchant_name: "Cool Shoe Store".into(),
190            },
191            merchant_authorization: Some("eyJhbGci...".into()),
192        };
193        let json = serde_json::to_string(&cm).unwrap();
194        let back: CartMandate = serde_json::from_str(&json).unwrap();
195        assert_eq!(cm, back);
196    }
197
198    #[test]
199    fn payment_mandate_roundtrip() {
200        let pm = PaymentMandate {
201            payment_mandate_contents: PaymentMandateContents {
202                payment_mandate_id: "pm_123".into(),
203                payment_details_id: "order_123".into(),
204                payment_details_total: PaymentItem {
205                    label: "Total".into(),
206                    amount: PaymentCurrencyAmount {
207                        currency: "USD".into(),
208                        value: 120.0,
209                    },
210                    pending: None,
211                    refund_period: 30,
212                },
213                payment_response: crate::types::payment_request::PaymentResponse {
214                    request_id: "order_123".into(),
215                    method_name: "CARD".into(),
216                    details: None,
217                    shipping_address: None,
218                    shipping_option: None,
219                    payer_name: None,
220                    payer_email: None,
221                    payer_phone: None,
222                },
223                merchant_agent: "MerchantAgent".into(),
224                timestamp: "2025-08-26T19:36:36Z".into(),
225            },
226            user_authorization: Some("eyJhbGci...".into()),
227        };
228        let json = serde_json::to_string(&pm).unwrap();
229        let back: PaymentMandate = serde_json::from_str(&json).unwrap();
230        assert_eq!(pm, back);
231    }
232
233    #[test]
234    fn compat_with_python_sdk_intent_message() {
235        // JSON matching the official A2A extension doc example
236        let json = r#"{
237            "user_cart_confirmation_required": false,
238            "natural_language_description": "I'd like some cool red shoes in my size",
239            "merchants": null,
240            "skus": null,
241            "required_refundability": true,
242            "intent_expiry": "2025-09-16T15:00:00Z"
243        }"#;
244        // Note: the Python SDK uses `requires_refundability` but the A2A
245        // extension doc example uses `required_refundability`. We accept
246        // our canonical field name `requires_refundability`; the extra field
247        // is silently ignored by serde.
248        let _im: IntentMandate = serde_json::from_str(json).unwrap();
249    }
250
251    #[test]
252    fn compat_with_python_sdk_cart_artifact() {
253        let json = r#"{
254            "contents": {
255                "id": "cart_shoes_123",
256                "user_cart_confirmation_required": false,
257                "user_signature_required": false,
258                "payment_request": {
259                    "method_data": [{"supported_methods": "CARD", "data": {"payment_processor_url": "http://example.com/pay"}}],
260                    "details": {
261                        "id": "order_shoes_123",
262                        "display_items": [{"label": "Cool Shoes Max", "amount": {"currency": "USD", "value": 120.0}}],
263                        "total": {"label": "Total", "amount": {"currency": "USD", "value": 120.0}}
264                    },
265                    "options": {
266                        "request_payer_name": false,
267                        "request_payer_email": false,
268                        "request_payer_phone": false,
269                        "request_shipping": true
270                    }
271                },
272                "cart_expiry": "2026-12-31T23:59:59Z",
273                "merchant_name": "Cool Shoe Store"
274            },
275            "merchant_authorization": "sig_merchant_shoes_abc1"
276        }"#;
277        let cm: CartMandate = serde_json::from_str(json).unwrap();
278        assert_eq!(cm.contents.id, "cart_shoes_123");
279    }
280}