use crate::error::{Error, Result};
use crate::oob::OutOfBandInvitation;
use serde_json::Value;
use std::collections::HashMap;
use tap_msg::message::{Payment, TapMessageBody};
pub const DEFAULT_PAYMENT_SERVICE_URL: &str = "https://flow-connect.notabene.dev/payin";
#[derive(Debug, Clone)]
pub struct PaymentLinkConfig {
pub service_url: String,
pub metadata: HashMap<String, Value>,
pub goal: Option<String>,
}
impl Default for PaymentLinkConfig {
fn default() -> Self {
Self {
service_url: DEFAULT_PAYMENT_SERVICE_URL.to_string(),
metadata: HashMap::new(),
goal: None,
}
}
}
impl PaymentLinkConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_service_url(mut self, url: &str) -> Self {
self.service_url = url.to_string();
self
}
pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
self.metadata.insert(key.to_string(), value);
self
}
pub fn with_goal(mut self, goal: &str) -> Self {
self.goal = Some(goal.to_string());
self
}
}
pub struct PaymentLinkBuilder {
agent_did: String,
payment: Payment,
config: PaymentLinkConfig,
}
impl PaymentLinkBuilder {
pub fn new(agent_did: &str, payment: Payment) -> Self {
Self {
agent_did: agent_did.to_string(),
payment,
config: PaymentLinkConfig::default(),
}
}
pub fn with_config(mut self, config: PaymentLinkConfig) -> Self {
self.config = config;
self
}
pub fn with_service_url(mut self, url: &str) -> Self {
self.config.service_url = url.to_string();
self
}
pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
self.config.metadata.insert(key.to_string(), value);
self
}
pub async fn build_with_signer<F, Fut>(self, sign_fn: F) -> Result<PaymentLink>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
let plain_message = self.payment.to_didcomm(&self.agent_did)?;
let message_json = serde_json::to_string(&plain_message)
.map_err(|e| Error::Serialization(format!("Failed to serialize payment: {}", e)))?;
let signed_message = sign_fn(message_json).await?;
let goal = self
.config
.goal
.unwrap_or_else(|| "Process payment request".to_string());
let mut oob_builder = OutOfBandInvitation::builder(&self.agent_did, "tap.payment", &goal)
.add_signed_attachment(
"payment-request",
&signed_message,
Some("Signed payment request message"),
);
for (key, value) in &self.config.metadata {
oob_builder = oob_builder.add_metadata(key, value.clone());
}
let oob_invitation = oob_builder.build();
let url = oob_invitation.to_url(&self.config.service_url)?;
Ok(PaymentLink {
url,
oob_invitation,
payment: self.payment,
signed_message,
})
}
}
#[derive(Debug, Clone)]
pub struct PaymentLink {
pub url: String,
pub oob_invitation: OutOfBandInvitation,
pub payment: Payment,
pub signed_message: String,
}
impl PaymentLink {
pub fn builder(agent_did: &str, payment: Payment) -> PaymentLinkBuilder {
PaymentLinkBuilder::new(agent_did, payment)
}
pub fn amount(&self) -> &str {
&self.payment.amount
}
pub fn currency(&self) -> Option<&str> {
self.payment.currency_code.as_deref()
}
pub fn asset(&self) -> Option<String> {
self.payment.asset.as_ref().map(|a| a.to_string())
}
pub fn merchant(&self) -> &tap_msg::message::Party {
&self.payment.merchant
}
pub fn is_expired(&self) -> bool {
if let Some(expiry) = &self.payment.expiry {
if let Ok(expiry_time) = chrono::DateTime::parse_from_rfc3339(expiry) {
return chrono::Utc::now() > expiry_time.with_timezone(&chrono::Utc);
}
}
false
}
pub fn to_qr_data(&self) -> &str {
&self.url
}
pub fn from_url(url: &str) -> Result<PaymentLinkInfo> {
let oob_invitation = OutOfBandInvitation::from_url(url)?;
if !oob_invitation.is_payment_invitation() {
return Err(Error::Validation(
"OOB invitation is not a payment request".to_string(),
));
}
let attachment = oob_invitation
.get_signed_attachment()
.ok_or_else(|| Error::Validation("No signed payment attachment found".to_string()))?;
Ok(PaymentLinkInfo {
oob_invitation: oob_invitation.clone(),
attachment_id: attachment.id.clone().unwrap_or_default(),
})
}
pub fn to_short_url(&self, base_url: &str) -> Result<String> {
self.oob_invitation.to_id_url(base_url)
}
}
#[derive(Debug, Clone)]
pub struct PaymentLinkInfo {
pub oob_invitation: OutOfBandInvitation,
pub attachment_id: String,
}
impl PaymentLinkInfo {
pub fn get_signed_payment(&self) -> Option<&Value> {
self.oob_invitation
.extract_attachment_json(&self.attachment_id)
}
pub fn merchant_did(&self) -> &str {
&self.oob_invitation.from
}
pub fn goal(&self) -> &str {
&self.oob_invitation.body.goal
}
pub fn validate(&self) -> Result<()> {
self.oob_invitation.validate()?;
if self.get_signed_payment().is_none() {
return Err(Error::Validation(
"Signed payment attachment not found".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_payment_link_config_defaults() {
let config = PaymentLinkConfig::default();
assert_eq!(config.service_url, DEFAULT_PAYMENT_SERVICE_URL);
assert!(config.metadata.is_empty());
assert!(config.goal.is_none());
}
#[test]
fn test_payment_link_config_builder() {
let config = PaymentLinkConfig::new()
.with_service_url("https://custom.com/pay")
.with_metadata("order_id", json!("12345"))
.with_goal("Complete your purchase");
assert_eq!(config.service_url, "https://custom.com/pay");
assert_eq!(config.metadata.get("order_id"), Some(&json!("12345")));
assert_eq!(config.goal, Some("Complete your purchase".to_string()));
}
#[test]
fn test_payment_link_parsing_error() {
let result = PaymentLink::from_url("https://example.com/invalid");
assert!(result.is_err());
}
}