Skip to main content

tap_agent/
payment_link.rs

1//! Payment link functionality for TAP agents
2//!
3//! This module provides utilities for creating payment links using Out-of-Band messages
4//! containing signed Payment messages according to TAIP-14 and TAIP-2.
5
6use crate::error::{Error, Result};
7use crate::oob::OutOfBandInvitation;
8use serde_json::Value;
9use std::collections::HashMap;
10use tap_msg::message::{Payment, TapMessageBody};
11
12/// Default service URL for payment links
13pub const DEFAULT_PAYMENT_SERVICE_URL: &str = "https://flow-connect.notabene.dev/payin";
14
15/// Configuration for creating payment links
16#[derive(Debug, Clone)]
17pub struct PaymentLinkConfig {
18    /// Base URL for the payment service
19    pub service_url: String,
20    /// Additional metadata to include in the OOB invitation
21    pub metadata: HashMap<String, Value>,
22    /// Custom goal description (defaults to "Process payment request")
23    pub goal: Option<String>,
24}
25
26impl Default for PaymentLinkConfig {
27    fn default() -> Self {
28        Self {
29            service_url: DEFAULT_PAYMENT_SERVICE_URL.to_string(),
30            metadata: HashMap::new(),
31            goal: None,
32        }
33    }
34}
35
36impl PaymentLinkConfig {
37    /// Create a new configuration with the default service URL
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Set a custom service URL
43    pub fn with_service_url(mut self, url: &str) -> Self {
44        self.service_url = url.to_string();
45        self
46    }
47
48    /// Add metadata to the OOB invitation
49    pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
50        self.metadata.insert(key.to_string(), value);
51        self
52    }
53
54    /// Set a custom goal description
55    pub fn with_goal(mut self, goal: &str) -> Self {
56        self.goal = Some(goal.to_string());
57        self
58    }
59}
60
61/// Builder for creating payment links
62pub struct PaymentLinkBuilder {
63    agent_did: String,
64    payment: Payment,
65    config: PaymentLinkConfig,
66}
67
68impl PaymentLinkBuilder {
69    /// Create a new payment link builder
70    pub fn new(agent_did: &str, payment: Payment) -> Self {
71        Self {
72            agent_did: agent_did.to_string(),
73            payment,
74            config: PaymentLinkConfig::default(),
75        }
76    }
77
78    /// Set the configuration
79    pub fn with_config(mut self, config: PaymentLinkConfig) -> Self {
80        self.config = config;
81        self
82    }
83
84    /// Set the service URL
85    pub fn with_service_url(mut self, url: &str) -> Self {
86        self.config.service_url = url.to_string();
87        self
88    }
89
90    /// Add metadata
91    pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
92        self.config.metadata.insert(key.to_string(), value);
93        self
94    }
95
96    /// Build the payment link (requires signing the payment message)
97    pub async fn build_with_signer<F, Fut>(self, sign_fn: F) -> Result<PaymentLink>
98    where
99        F: FnOnce(String) -> Fut,
100        Fut: std::future::Future<Output = Result<String>>,
101    {
102        // Create the DIDComm PlainMessage for the payment
103        let plain_message = self.payment.to_didcomm(&self.agent_did)?;
104
105        // Serialize the plain message for signing
106        let message_json = serde_json::to_string(&plain_message)
107            .map_err(|e| Error::Serialization(format!("Failed to serialize payment: {}", e)))?;
108
109        // Sign the message using the provided signing function
110        let signed_message = sign_fn(message_json).await?;
111
112        // Create the OOB invitation
113        let goal = self
114            .config
115            .goal
116            .unwrap_or_else(|| "Process payment request".to_string());
117
118        let mut oob_builder = OutOfBandInvitation::builder(&self.agent_did, "tap.payment", &goal)
119            .add_signed_attachment(
120                "payment-request",
121                &signed_message,
122                Some("Signed payment request message"),
123            );
124
125        // Add any additional metadata
126        for (key, value) in &self.config.metadata {
127            oob_builder = oob_builder.add_metadata(key, value.clone());
128        }
129
130        let oob_invitation = oob_builder.build();
131
132        // Generate the URL
133        let url = oob_invitation.to_url(&self.config.service_url)?;
134
135        Ok(PaymentLink {
136            url,
137            oob_invitation,
138            payment: self.payment,
139            signed_message,
140        })
141    }
142}
143
144/// A payment link containing the URL and associated data
145#[derive(Debug, Clone)]
146pub struct PaymentLink {
147    /// The payment link URL
148    pub url: String,
149    /// The Out-of-Band invitation
150    pub oob_invitation: OutOfBandInvitation,
151    /// The original payment message
152    pub payment: Payment,
153    /// The signed message string
154    pub signed_message: String,
155}
156
157impl PaymentLink {
158    /// Create a new payment link builder
159    pub fn builder(agent_did: &str, payment: Payment) -> PaymentLinkBuilder {
160        PaymentLinkBuilder::new(agent_did, payment)
161    }
162
163    /// Get the payment amount as a string
164    pub fn amount(&self) -> &str {
165        &self.payment.amount
166    }
167
168    /// Get the payment currency (if specified)
169    pub fn currency(&self) -> Option<&str> {
170        self.payment.currency_code.as_deref()
171    }
172
173    /// Get the payment asset (if specified)  
174    pub fn asset(&self) -> Option<String> {
175        self.payment.asset.as_ref().map(|a| a.to_string())
176    }
177
178    /// Get the merchant information
179    pub fn merchant(&self) -> &tap_msg::message::Party {
180        &self.payment.merchant
181    }
182
183    /// Check if the payment link has expired
184    pub fn is_expired(&self) -> bool {
185        if let Some(expiry) = &self.payment.expiry {
186            // Parse ISO 8601 timestamp and compare with current time
187            if let Ok(expiry_time) = chrono::DateTime::parse_from_rfc3339(expiry) {
188                return chrono::Utc::now() > expiry_time.with_timezone(&chrono::Utc);
189            }
190        }
191        false
192    }
193
194    /// Get the payment link as a QR code-friendly format
195    pub fn to_qr_data(&self) -> &str {
196        &self.url
197    }
198
199    /// Parse a payment link from a URL
200    pub fn from_url(url: &str) -> Result<PaymentLinkInfo> {
201        let oob_invitation = OutOfBandInvitation::from_url(url)?;
202
203        // Validate it's a payment invitation
204        if !oob_invitation.is_payment_invitation() {
205            return Err(Error::Validation(
206                "OOB invitation is not a payment request".to_string(),
207            ));
208        }
209
210        // Extract the payment attachment
211        let attachment = oob_invitation
212            .get_signed_attachment()
213            .ok_or_else(|| Error::Validation("No signed payment attachment found".to_string()))?;
214
215        Ok(PaymentLinkInfo {
216            oob_invitation: oob_invitation.clone(),
217            attachment_id: attachment.id.clone().unwrap_or_default(),
218        })
219    }
220
221    /// Create a short link URL using just the invitation ID
222    pub fn to_short_url(&self, base_url: &str) -> Result<String> {
223        self.oob_invitation.to_id_url(base_url)
224    }
225}
226
227/// Information extracted from a payment link URL
228#[derive(Debug, Clone)]
229pub struct PaymentLinkInfo {
230    /// The Out-of-Band invitation
231    pub oob_invitation: OutOfBandInvitation,
232    /// ID of the payment attachment
233    pub attachment_id: String,
234}
235
236impl PaymentLinkInfo {
237    /// Get the signed payment message JSON
238    pub fn get_signed_payment(&self) -> Option<&Value> {
239        self.oob_invitation
240            .extract_attachment_json(&self.attachment_id)
241    }
242
243    /// Get the merchant DID from the invitation
244    pub fn merchant_did(&self) -> &str {
245        &self.oob_invitation.from
246    }
247
248    /// Get the goal description
249    pub fn goal(&self) -> &str {
250        &self.oob_invitation.body.goal
251    }
252
253    /// Validate the payment link structure
254    pub fn validate(&self) -> Result<()> {
255        self.oob_invitation.validate()?;
256
257        // Check that the signed payment attachment exists
258        if self.get_signed_payment().is_none() {
259            return Err(Error::Validation(
260                "Signed payment attachment not found".to_string(),
261            ));
262        }
263
264        Ok(())
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use serde_json::json;
272
273    #[test]
274    fn test_payment_link_config_defaults() {
275        let config = PaymentLinkConfig::default();
276        assert_eq!(config.service_url, DEFAULT_PAYMENT_SERVICE_URL);
277        assert!(config.metadata.is_empty());
278        assert!(config.goal.is_none());
279    }
280
281    #[test]
282    fn test_payment_link_config_builder() {
283        let config = PaymentLinkConfig::new()
284            .with_service_url("https://custom.com/pay")
285            .with_metadata("order_id", json!("12345"))
286            .with_goal("Complete your purchase");
287
288        assert_eq!(config.service_url, "https://custom.com/pay");
289        assert_eq!(config.metadata.get("order_id"), Some(&json!("12345")));
290        assert_eq!(config.goal, Some("Complete your purchase".to_string()));
291    }
292
293    #[test]
294    fn test_payment_link_parsing_error() {
295        // Test error handling for invalid URLs
296        let result = PaymentLink::from_url("https://example.com/invalid");
297        assert!(result.is_err());
298    }
299}