use serde::{Deserialize, Serialize};
use super::types::{MethodName, ReceiptStatus};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Receipt {
pub status: ReceiptStatus,
pub method: MethodName,
pub timestamp: String,
pub reference: String,
#[serde(rename = "externalId", skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
#[serde(rename = "subscriptionId", skip_serializing_if = "Option::is_none")]
pub subscription_id: Option<String>,
#[serde(flatten)]
pub extensions: serde_json::Map<String, serde_json::Value>,
}
impl Receipt {
#[must_use]
pub fn success(method: impl Into<MethodName>, reference: impl Into<String>) -> Self {
Self {
status: ReceiptStatus::Success,
method: method.into(),
timestamp: now_iso8601(),
reference: reference.into(),
external_id: None,
subscription_id: None,
extensions: serde_json::Map::new(),
}
}
#[must_use]
pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
self.external_id = Some(external_id.into());
self
}
#[must_use]
pub fn with_subscription_id(mut self, subscription_id: impl Into<String>) -> Self {
self.subscription_id = Some(subscription_id.into());
self
}
pub fn is_success(&self) -> bool {
self.status == ReceiptStatus::Success
}
pub fn to_header(&self) -> crate::error::Result<String> {
super::format_receipt(self)
}
pub fn from_header(header: &str) -> crate::error::Result<Self> {
super::parse_receipt(header)
}
pub fn from_response(receipt_header: &str) -> crate::error::Result<Self> {
Self::from_header(receipt_header)
}
}
fn now_iso8601() -> String {
use time::format_description::well_known::Iso8601;
use time::OffsetDateTime;
OffsetDateTime::now_utc()
.format(&Iso8601::DEFAULT)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
pub fn extract_tx_hash(receipt_b64: &str) -> Option<String> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let decoded = URL_SAFE_NO_PAD.decode(receipt_b64.trim()).ok()?;
let json: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
json.get("txHash")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_payment_receipt_status() {
let success = Receipt {
status: ReceiptStatus::Success,
method: "tempo".into(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
reference: "0xabc".to_string(),
external_id: None,
subscription_id: None,
extensions: serde_json::Map::new(),
};
assert!(success.is_success());
assert!(success.external_id.is_none());
}
#[test]
fn test_receipt_with_external_id() {
let receipt = Receipt::success("tempo", "0xabc").with_external_id("order-123");
assert_eq!(receipt.external_id.as_deref(), Some("order-123"));
let json = serde_json::to_string(&receipt).unwrap();
assert!(json.contains("\"externalId\":\"order-123\""));
let parsed: Receipt = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.external_id.as_deref(), Some("order-123"));
let bare = Receipt::success("tempo", "0xabc");
let bare_json = serde_json::to_string(&bare).unwrap();
assert!(!bare_json.contains("externalId"));
}
#[test]
fn test_receipt_with_subscription_id() {
let receipt = Receipt::success("tempo", "0xabc").with_subscription_id("sub_123");
assert_eq!(receipt.subscription_id.as_deref(), Some("sub_123"));
let json = serde_json::to_string(&receipt).unwrap();
assert!(json.contains("\"subscriptionId\":\"sub_123\""));
let parsed: Receipt = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.subscription_id.as_deref(), Some("sub_123"));
let bare = Receipt::success("tempo", "0xabc");
let bare_json = serde_json::to_string(&bare).unwrap();
assert!(!bare_json.contains("subscriptionId"));
}
#[test]
fn test_receipt_from_header() {
let receipt = Receipt::success("tempo", "0xabc123");
let header = receipt.to_header().unwrap();
let parsed = Receipt::from_header(&header).unwrap();
assert!(parsed.is_success());
assert_eq!(parsed.reference, "0xabc123");
}
#[test]
fn test_extract_tx_hash_valid() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let json = serde_json::json!({"txHash": "0xabc123", "status": "success"});
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap());
assert_eq!(extract_tx_hash(&encoded), Some("0xabc123".to_string()));
}
#[test]
fn test_extract_tx_hash_missing() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let json = serde_json::json!({"status": "success"});
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap());
assert_eq!(extract_tx_hash(&encoded), None);
}
#[test]
fn test_extract_tx_hash_empty() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let json = serde_json::json!({"txHash": ""});
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap());
assert_eq!(extract_tx_hash(&encoded), None);
}
#[test]
fn test_extract_tx_hash_invalid_base64() {
assert_eq!(extract_tx_hash("not-valid-base64!!!"), None);
}
#[test]
fn test_extract_tx_hash_invalid_json() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let encoded = URL_SAFE_NO_PAD.encode(b"not json");
assert_eq!(extract_tx_hash(&encoded), None);
}
}