tap_agent/
payment_link.rs1use crate::error::{Error, Result};
7use crate::oob::OutOfBandInvitation;
8use serde_json::Value;
9use std::collections::HashMap;
10use tap_msg::message::{Payment, TapMessageBody};
11
12pub const DEFAULT_PAYMENT_SERVICE_URL: &str = "https://flow-connect.notabene.dev/payin";
14
15#[derive(Debug, Clone)]
17pub struct PaymentLinkConfig {
18 pub service_url: String,
20 pub metadata: HashMap<String, Value>,
22 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 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn with_service_url(mut self, url: &str) -> Self {
44 self.service_url = url.to_string();
45 self
46 }
47
48 pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
50 self.metadata.insert(key.to_string(), value);
51 self
52 }
53
54 pub fn with_goal(mut self, goal: &str) -> Self {
56 self.goal = Some(goal.to_string());
57 self
58 }
59}
60
61pub struct PaymentLinkBuilder {
63 agent_did: String,
64 payment: Payment,
65 config: PaymentLinkConfig,
66}
67
68impl PaymentLinkBuilder {
69 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 pub fn with_config(mut self, config: PaymentLinkConfig) -> Self {
80 self.config = config;
81 self
82 }
83
84 pub fn with_service_url(mut self, url: &str) -> Self {
86 self.config.service_url = url.to_string();
87 self
88 }
89
90 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 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 let plain_message = self.payment.to_didcomm(&self.agent_did)?;
104
105 let message_json = serde_json::to_string(&plain_message)
107 .map_err(|e| Error::Serialization(format!("Failed to serialize payment: {}", e)))?;
108
109 let signed_message = sign_fn(message_json).await?;
111
112 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 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 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#[derive(Debug, Clone)]
146pub struct PaymentLink {
147 pub url: String,
149 pub oob_invitation: OutOfBandInvitation,
151 pub payment: Payment,
153 pub signed_message: String,
155}
156
157impl PaymentLink {
158 pub fn builder(agent_did: &str, payment: Payment) -> PaymentLinkBuilder {
160 PaymentLinkBuilder::new(agent_did, payment)
161 }
162
163 pub fn amount(&self) -> &str {
165 &self.payment.amount
166 }
167
168 pub fn currency(&self) -> Option<&str> {
170 self.payment.currency_code.as_deref()
171 }
172
173 pub fn asset(&self) -> Option<String> {
175 self.payment.asset.as_ref().map(|a| a.to_string())
176 }
177
178 pub fn merchant(&self) -> &tap_msg::message::Party {
180 &self.payment.merchant
181 }
182
183 pub fn is_expired(&self) -> bool {
185 if let Some(expiry) = &self.payment.expiry {
186 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 pub fn to_qr_data(&self) -> &str {
196 &self.url
197 }
198
199 pub fn from_url(url: &str) -> Result<PaymentLinkInfo> {
201 let oob_invitation = OutOfBandInvitation::from_url(url)?;
202
203 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 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 pub fn to_short_url(&self, base_url: &str) -> Result<String> {
223 self.oob_invitation.to_id_url(base_url)
224 }
225}
226
227#[derive(Debug, Clone)]
229pub struct PaymentLinkInfo {
230 pub oob_invitation: OutOfBandInvitation,
232 pub attachment_id: String,
234}
235
236impl PaymentLinkInfo {
237 pub fn get_signed_payment(&self) -> Option<&Value> {
239 self.oob_invitation
240 .extract_attachment_json(&self.attachment_id)
241 }
242
243 pub fn merchant_did(&self) -> &str {
245 &self.oob_invitation.from
246 }
247
248 pub fn goal(&self) -> &str {
250 &self.oob_invitation.body.goal
251 }
252
253 pub fn validate(&self) -> Result<()> {
255 self.oob_invitation.validate()?;
256
257 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 let result = PaymentLink::from_url("https://example.com/invalid");
297 assert!(result.is_err());
298 }
299}