use std::fmt;
use std::marker::PhantomData;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::types::Hold;
#[derive(Debug, Clone)]
pub struct HoldWorkflowConfig {
pub email: HoldEmailConfig,
pub notification: HoldNotificationConfig,
pub timeouts: HoldTimeoutConfig,
}
impl Default for HoldWorkflowConfig {
fn default() -> Self {
Self {
email: HoldEmailConfig::default(),
notification: HoldNotificationConfig::default(),
timeouts: HoldTimeoutConfig::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct HoldEmailConfig {
pub domain: String,
pub from_address: String,
pub reply_to_prefix: String,
pub poll_interval: Duration,
pub imap: ImapConfig,
pub smtp: SmtpConfig,
}
impl Default for HoldEmailConfig {
fn default() -> Self {
Self {
domain: "holds.example.com".to_string(),
from_address: "noreply@example.com".to_string(),
reply_to_prefix: "hold+".to_string(),
poll_interval: Duration::from_secs(30),
imap: ImapConfig::default(),
smtp: SmtpConfig::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct ImapConfig {
pub host: String,
pub port: u16,
pub use_tls: bool,
pub username: String,
pub password: String,
pub mailbox: String,
}
impl Default for ImapConfig {
fn default() -> Self {
Self {
host: "imap.example.com".to_string(),
port: 993,
use_tls: true,
username: String::new(),
password: String::new(),
mailbox: "INBOX".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub use_tls: bool,
pub username: String,
pub password: String,
}
impl Default for SmtpConfig {
fn default() -> Self {
Self {
host: "smtp.example.com".to_string(),
port: 587,
use_tls: true,
username: String::new(),
password: String::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct HoldNotificationConfig {
pub subject_template: String,
pub body_template: String,
pub response_instructions: String,
}
impl Default for HoldNotificationConfig {
fn default() -> Self {
Self {
subject_template: "Action Required: Transaction Hold #{hold_id}".to_string(),
body_template: r#"Dear {merchant_name},
A transaction has been placed on hold and requires your attention.
Hold ID: {hold_id}
Transaction ID: {transaction_id}
Amount: {amount}
{response_instructions}
Please respond to this email with your decision.
Best regards,
Risk Management Team"#
.to_string(),
response_instructions: r#"Please reply to this email with one of the following:
- "VALID" if this transaction is legitimate
- "FRAUDULENT" if this transaction is unauthorized
You may attach any supporting documentation to your reply."#
.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct HoldTimeoutConfig {
pub reminder_after: Duration,
pub escalation_after: Duration,
pub max_response_time: Duration,
}
impl Default for HoldTimeoutConfig {
fn default() -> Self {
Self {
reminder_after: Duration::from_secs(24 * 60 * 60), escalation_after: Duration::from_secs(72 * 60 * 60), max_response_time: Duration::from_secs(168 * 60 * 60), }
}
}
#[derive(Debug, Default)]
pub struct HoldWorkflowConfigBuilder {
config: HoldWorkflowConfig,
}
impl HoldWorkflowConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn domain(mut self, domain: impl Into<String>) -> Self {
self.config.email.domain = domain.into();
self
}
pub fn from_address(mut self, address: impl Into<String>) -> Self {
self.config.email.from_address = address.into();
self
}
pub fn imap(mut self, config: ImapConfig) -> Self {
self.config.email.imap = config;
self
}
pub fn smtp(mut self, config: SmtpConfig) -> Self {
self.config.email.smtp = config;
self
}
pub fn reminder_after(mut self, duration: Duration) -> Self {
self.config.timeouts.reminder_after = duration;
self
}
pub fn escalation_after(mut self, duration: Duration) -> Self {
self.config.timeouts.escalation_after = duration;
self
}
pub fn build(self) -> HoldWorkflowConfig {
self.config
}
}
#[derive(Debug)]
pub enum HoldError {
NotFound(String),
InvalidState {
hold_id: String,
current_state: String,
expected_state: String,
},
AlreadyReleased(String),
InvalidDecision(String),
DocumentUpload(String),
EmailNotification(String),
EmailParsing(String),
Configuration(String),
Api(crate::error::Error),
Workflow(String),
}
impl fmt::Display for HoldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound(id) => write!(f, "Hold not found: {}", id),
Self::InvalidState {
hold_id,
current_state,
expected_state,
} => write!(
f,
"Hold {} is in state '{}', expected '{}'",
hold_id, current_state, expected_state
),
Self::AlreadyReleased(id) => write!(f, "Hold {} has already been released", id),
Self::InvalidDecision(msg) => write!(f, "Invalid decision: {}", msg),
Self::DocumentUpload(msg) => write!(f, "Document upload failed: {}", msg),
Self::EmailNotification(msg) => write!(f, "Email notification failed: {}", msg),
Self::EmailParsing(msg) => write!(f, "Email parsing failed: {}", msg),
Self::Configuration(msg) => write!(f, "Configuration error: {}", msg),
Self::Api(e) => write!(f, "Payrix API error: {}", e),
Self::Workflow(msg) => write!(f, "Workflow error: {}", msg),
}
}
}
impl std::error::Error for HoldError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Api(e) => Some(e),
_ => None,
}
}
}
impl From<crate::error::Error> for HoldError {
fn from(err: crate::error::Error) -> Self {
Self::Api(err)
}
}
impl From<HoldError> for crate::error::Error {
fn from(err: HoldError) -> Self {
match err {
HoldError::Api(e) => e,
other => crate::error::Error::Workflow(other.to_string()),
}
}
}
pub type HoldResult<T> = Result<T, HoldError>;
mod private {
pub trait Sealed {}
}
pub trait HoldState: private::Sealed {
fn state_name() -> &'static str;
}
#[derive(Debug, Clone, Default)]
pub struct OnHold;
impl private::Sealed for OnHold {}
impl HoldState for OnHold {
fn state_name() -> &'static str {
"OnHold"
}
}
#[derive(Debug, Clone, Default)]
pub struct AwaitingResponse;
impl private::Sealed for AwaitingResponse {}
impl HoldState for AwaitingResponse {
fn state_name() -> &'static str {
"AwaitingResponse"
}
}
#[derive(Debug, Clone, Default)]
pub struct DecisionReceived;
impl private::Sealed for DecisionReceived {}
impl HoldState for DecisionReceived {
fn state_name() -> &'static str {
"DecisionReceived"
}
}
#[derive(Debug, Clone, Default)]
pub struct Submitted;
impl private::Sealed for Submitted {}
impl HoldState for Submitted {
fn state_name() -> &'static str {
"Submitted"
}
}
#[derive(Debug, Clone, Default)]
pub struct Resolved;
impl private::Sealed for Resolved {}
impl HoldState for Resolved {
fn state_name() -> &'static str {
"Resolved"
}
}
#[derive(Debug, Clone)]
pub struct TypedHold<S: HoldState> {
inner: Hold,
workflow_id: String,
state_entered_at: DateTime<Utc>,
pub notified_at: Option<DateTime<Utc>>,
pub decision: Option<MerchantDecision>,
pub merchant_note: Option<String>,
pub documents: Vec<HoldDocument>,
_state: PhantomData<S>,
}
impl<S: HoldState> TypedHold<S> {
pub fn id(&self) -> &crate::types::PayrixId {
&self.inner.id
}
pub fn workflow_id(&self) -> &str {
&self.workflow_id
}
pub fn transaction_id(&self) -> Option<&crate::types::PayrixId> {
self.inner.txn.as_ref()
}
pub fn entity_id(&self) -> Option<&crate::types::PayrixId> {
self.inner.entity.as_ref()
}
pub fn state_name(&self) -> &'static str {
S::state_name()
}
pub fn state_entered_at(&self) -> DateTime<Utc> {
self.state_entered_at
}
pub fn inner(&self) -> &Hold {
&self.inner
}
pub fn is_released(&self) -> bool {
self.inner.released.is_some()
}
pub(crate) fn transition<T: HoldState + Default>(self) -> TypedHold<T> {
TypedHold {
inner: self.inner,
workflow_id: self.workflow_id,
state_entered_at: Utc::now(),
notified_at: self.notified_at,
decision: self.decision,
merchant_note: self.merchant_note,
documents: self.documents,
_state: PhantomData,
}
}
}
impl TypedHold<OnHold> {
pub fn new(hold: Hold) -> Self {
Self {
inner: hold,
workflow_id: generate_workflow_id(),
state_entered_at: Utc::now(),
notified_at: None,
decision: None,
merchant_note: None,
documents: Vec::new(),
_state: PhantomData,
}
}
pub fn with_workflow_id(hold: Hold, workflow_id: String) -> Self {
Self {
inner: hold,
workflow_id,
state_entered_at: Utc::now(),
notified_at: None,
decision: None,
merchant_note: None,
documents: Vec::new(),
_state: PhantomData,
}
}
}
impl TypedHold<AwaitingResponse> {
pub fn is_expired(&self, config: &HoldWorkflowConfig) -> bool {
if let Some(notified) = self.notified_at {
let elapsed = Utc::now().signed_duration_since(notified);
elapsed > chrono::Duration::from_std(config.timeouts.max_response_time).unwrap()
} else {
false
}
}
pub fn should_send_reminder(&self, config: &HoldWorkflowConfig) -> bool {
if let Some(notified) = self.notified_at {
let elapsed = Utc::now().signed_duration_since(notified);
elapsed > chrono::Duration::from_std(config.timeouts.reminder_after).unwrap()
} else {
false
}
}
}
#[derive(Debug, Clone)]
pub struct HoldDocument {
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MerchantDecision {
Valid,
Fraudulent,
}
impl fmt::Display for MerchantDecision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Valid => write!(f, "Valid"),
Self::Fraudulent => write!(f, "Fraudulent"),
}
}
}
fn generate_workflow_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
format!("wf_{}", timestamp)
}
#[derive(Debug, Clone)]
pub enum HoldWorkflow {
OnHold(TypedHold<OnHold>),
AwaitingResponse(TypedHold<AwaitingResponse>),
DecisionReceived(TypedHold<DecisionReceived>),
Submitted(TypedHold<Submitted>),
Resolved(TypedHold<Resolved>),
}
impl HoldWorkflow {
pub fn new(hold: Hold) -> Self {
Self::OnHold(TypedHold::new(hold))
}
pub fn id(&self) -> &crate::types::PayrixId {
match self {
Self::OnHold(h) => h.id(),
Self::AwaitingResponse(h) => h.id(),
Self::DecisionReceived(h) => h.id(),
Self::Submitted(h) => h.id(),
Self::Resolved(h) => h.id(),
}
}
pub fn workflow_id(&self) -> &str {
match self {
Self::OnHold(h) => h.workflow_id(),
Self::AwaitingResponse(h) => h.workflow_id(),
Self::DecisionReceived(h) => h.workflow_id(),
Self::Submitted(h) => h.workflow_id(),
Self::Resolved(h) => h.workflow_id(),
}
}
pub fn state_name(&self) -> &'static str {
match self {
Self::OnHold(_) => "OnHold",
Self::AwaitingResponse(_) => "AwaitingResponse",
Self::DecisionReceived(_) => "DecisionReceived",
Self::Submitted(_) => "Submitted",
Self::Resolved(_) => "Resolved",
}
}
pub fn is_resolved(&self) -> bool {
matches!(self, Self::Resolved(_))
}
}
#[derive(Debug, Clone, Default)]
pub struct HoldPollingConfig {
pub entity_id: Option<String>,
pub page_size: Option<u32>,
}
pub async fn detect_unreleased_holds(
client: &crate::PayrixClient,
config: &HoldPollingConfig,
) -> crate::Result<Vec<Hold>> {
use crate::entity::EntityType;
let mut query = "released[equals]=&inactive[equals]=0".to_string();
if let Some(entity_id) = &config.entity_id {
query.push_str(&format!("&entity[equals]={}", entity_id));
}
if let Some(page_size) = config.page_size {
query.push_str(&format!("&page[limit]={}", page_size));
}
let holds: Vec<Hold> = client.search(EntityType::Holds, &query).await?;
tracing::info!(
count = holds.len(),
"Detected unreleased holds on startup"
);
Ok(holds)
}
pub async fn detect_holds_for_transaction(
client: &crate::PayrixClient,
transaction_id: &str,
) -> crate::Result<Vec<Hold>> {
use crate::entity::EntityType;
let query = format!(
"txn[equals]={}&released[equals]=&inactive[equals]=0",
transaction_id
);
let holds: Vec<Hold> = client.search(EntityType::Holds, &query).await?;
Ok(holds)
}
pub async fn transaction_has_hold(
client: &crate::PayrixClient,
transaction_id: &str,
) -> crate::Result<bool> {
let holds = detect_holds_for_transaction(client, transaction_id).await?;
Ok(!holds.is_empty())
}
pub async fn get_hold(client: &crate::PayrixClient, hold_id: &str) -> crate::Result<Hold> {
use crate::entity::EntityType;
client
.get_one::<Hold>(EntityType::Holds, hold_id)
.await?
.ok_or_else(|| crate::error::Error::NotFound(format!("Hold not found: {}", hold_id)))
}
pub fn load_workflow(hold: Hold) -> HoldWorkflow {
if hold.released.is_some() {
let typed = TypedHold::<OnHold>::new(hold);
let typed = typed.transition::<AwaitingResponse>();
let typed = typed.transition::<DecisionReceived>();
let typed = typed.transition::<Submitted>();
let typed = typed.transition::<Resolved>();
return HoldWorkflow::Resolved(typed);
}
HoldWorkflow::new(hold)
}
pub fn webhook_event_has_hold(txn: &crate::types::Transaction) -> bool {
if let Some(reserved) = txn.reserved {
return reserved > 0;
}
false
}
pub fn extract_hold_from_webhook(txn: &crate::types::Transaction) -> Option<String> {
if webhook_event_has_hold(txn) {
Some(txn.id.to_string())
} else {
None
}
}
pub async fn poll_for_holds_at_startup(
client: &crate::PayrixClient,
config: &HoldPollingConfig,
) -> crate::Result<Vec<HoldWorkflow>> {
let holds = detect_unreleased_holds(client, config).await?;
let workflows: Vec<HoldWorkflow> = holds.into_iter().map(load_workflow).collect();
Ok(workflows)
}
#[derive(Debug, Clone)]
pub struct NotificationContext {
pub hold_id: String,
pub workflow_id: String,
pub transaction_id: Option<String>,
pub amount: Option<i64>,
pub merchant_name: String,
pub entity_id: Option<String>,
pub hold_reason: Option<String>,
}
impl NotificationContext {
pub fn formatted_amount(&self) -> String {
match self.amount {
Some(cents) => format!("${:.2}", cents as f64 / 100.0),
None => "Unknown".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct NotificationEmail {
pub to: String,
pub reply_to: String,
pub subject: String,
pub body: String,
}
pub struct NotificationBuilder<'a> {
config: &'a HoldWorkflowConfig,
context: NotificationContext,
recipient: Option<String>,
}
impl<'a> NotificationBuilder<'a> {
pub fn new(config: &'a HoldWorkflowConfig, context: NotificationContext) -> Self {
Self {
config,
context,
recipient: None,
}
}
pub fn to(mut self, email: impl Into<String>) -> Self {
self.recipient = Some(email.into());
self
}
pub fn build(self) -> HoldResult<NotificationEmail> {
let recipient = self.recipient.ok_or_else(|| {
HoldError::Configuration("Recipient email address not set".to_string())
})?;
let reply_to = format!(
"{}{}@{}",
self.config.email.reply_to_prefix,
self.context.workflow_id,
self.config.email.domain
);
let subject = self
.config
.notification
.subject_template
.replace("{hold_id}", &self.context.hold_id)
.replace("{workflow_id}", &self.context.workflow_id);
let body = self
.config
.notification
.body_template
.replace("{hold_id}", &self.context.hold_id)
.replace("{workflow_id}", &self.context.workflow_id)
.replace(
"{transaction_id}",
self.context.transaction_id.as_deref().unwrap_or("N/A"),
)
.replace("{amount}", &self.context.formatted_amount())
.replace("{merchant_name}", &self.context.merchant_name)
.replace(
"{response_instructions}",
&self.config.notification.response_instructions,
);
Ok(NotificationEmail {
to: recipient,
reply_to,
subject,
body,
})
}
}
#[allow(unused_variables)]
pub async fn send_notification(
_config: &HoldWorkflowConfig,
email: &NotificationEmail,
) -> HoldResult<()> {
tracing::info!(
to = %email.to,
subject = %email.subject,
reply_to = %email.reply_to,
"Would send hold notification email"
);
Ok(())
}
pub async fn notify_merchant(
mut hold: TypedHold<OnHold>,
config: &HoldWorkflowConfig,
merchant_email: &str,
merchant_name: &str,
) -> HoldResult<TypedHold<AwaitingResponse>> {
let context = NotificationContext {
hold_id: hold.id().to_string(),
workflow_id: hold.workflow_id().to_string(),
transaction_id: hold.transaction_id().map(|id| id.to_string()),
amount: None, merchant_name: merchant_name.to_string(),
entity_id: hold.entity_id().map(|id| id.to_string()),
hold_reason: hold.inner().hold_source_details.clone(),
};
let email = NotificationBuilder::new(config, context)
.to(merchant_email)
.build()?;
send_notification(config, &email).await?;
hold.notified_at = Some(chrono::Utc::now());
Ok(hold.transition())
}
#[derive(Debug, Clone)]
pub struct ParsedAttachment {
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct ParsedEmailReply {
pub workflow_id: String,
pub decision: MerchantDecision,
pub note: Option<String>,
pub attachments: Vec<ParsedAttachment>,
}
pub fn classify_decision(body: &str) -> HoldResult<MerchantDecision> {
let body_lower = body.to_lowercase();
let valid_keywords = [
"valid",
"legitimate",
"not fraud",
"release",
"approve",
"genuine",
"confirmed",
"is authorized",
"was authorized",
];
let fraud_keywords = [
"fraud",
"fraudulent",
"unauthorized",
"stolen",
"suspicious",
"deny",
"reject",
"fake",
"scam",
];
let fraud_score: usize = fraud_keywords
.iter()
.filter(|kw| body_lower.contains(*kw))
.count();
let valid_score: usize = valid_keywords
.iter()
.filter(|kw| body_lower.contains(*kw))
.count();
if valid_score > fraud_score {
Ok(MerchantDecision::Valid)
} else if fraud_score > valid_score {
Ok(MerchantDecision::Fraudulent)
} else if valid_score > 0 {
Ok(MerchantDecision::Valid)
} else {
Err(HoldError::EmailParsing(
"Could not determine decision from email body".to_string(),
))
}
}
pub fn extract_workflow_id(address: &str, prefix: &str) -> HoldResult<String> {
let address = address.trim_matches(|c| c == '<' || c == '>');
let local_part = address
.split('@')
.next()
.ok_or_else(|| HoldError::EmailParsing("Invalid email address format".to_string()))?;
if !local_part.starts_with(prefix) {
return Err(HoldError::EmailParsing(format!(
"Email address doesn't start with expected prefix '{}'",
prefix
)));
}
let workflow_id = &local_part[prefix.len()..];
if workflow_id.is_empty() {
return Err(HoldError::EmailParsing(
"Workflow ID is empty".to_string(),
));
}
Ok(workflow_id.to_string())
}
pub fn parse_email_reply(
to_address: &str,
body: &str,
attachments: Vec<ParsedAttachment>,
prefix: &str,
) -> HoldResult<ParsedEmailReply> {
let workflow_id = extract_workflow_id(to_address, prefix)?;
let decision = classify_decision(body)?;
let note = extract_merchant_note(body);
Ok(ParsedEmailReply {
workflow_id,
decision,
note,
attachments,
})
}
fn extract_merchant_note(body: &str) -> Option<String> {
let lines: Vec<&str> = body.lines().collect();
let meaningful_lines: Vec<&str> = lines
.into_iter()
.filter(|line| {
let trimmed = line.trim();
!trimmed.starts_with('>')
&& !trimmed.starts_with("On ")
&& !trimmed.starts_with("From:")
&& !trimmed.starts_with("Sent:")
&& !trimmed.starts_with("To:")
&& !trimmed.starts_with("Subject:")
&& !trimmed.starts_with("--")
&& !trimmed.starts_with("___")
&& !trimmed.is_empty()
})
.collect();
if meaningful_lines.is_empty() {
None
} else {
Some(meaningful_lines.join("\n").trim().to_string())
}
}
pub fn receive_decision(
mut hold: TypedHold<AwaitingResponse>,
decision: MerchantDecision,
note: String,
documents: Vec<HoldDocument>,
) -> HoldResult<TypedHold<DecisionReceived>> {
hold.decision = Some(decision);
hold.merchant_note = Some(note);
hold.documents = documents;
Ok(hold.transition())
}
#[derive(Debug)]
pub struct UploadResult {
pub note_id: String,
pub document_ids: Vec<String>,
}
#[derive(Debug)]
pub struct CreateNoteParams {
pub hold_id: String,
pub note_type: String,
pub text: String,
}
#[derive(Debug)]
pub struct UploadDocumentParams {
pub note_id: String,
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
#[allow(unused_variables)]
pub async fn create_hold_note(
client: &crate::PayrixClient,
params: &CreateNoteParams,
) -> HoldResult<String> {
tracing::info!(
hold_id = %params.hold_id,
note_type = %params.note_type,
"Would create hold note"
);
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
Ok(format!("t1_nte_{:023x}", ts))
}
#[allow(unused_variables)]
pub async fn upload_document(
client: &crate::PayrixClient,
params: &UploadDocumentParams,
) -> HoldResult<String> {
tracing::info!(
note_id = %params.note_id,
filename = %params.filename,
content_type = %params.content_type,
size = params.data.len(),
"Would upload document"
);
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
Ok(format!("t1_ntd_{:023x}", ts))
}
pub fn infer_file_type(content_type: &str) -> &'static str {
match content_type.to_lowercase().as_str() {
"application/pdf" => "pdf",
"image/jpeg" | "image/jpg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"text/plain" => "txt",
"application/msword" => "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
"application/vnd.ms-excel" => "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
_ => "bin",
}
}
pub fn infer_file_type_from_filename(filename: &str) -> &'static str {
let ext = filename
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"pdf" => "pdf",
"jpg" | "jpeg" => "jpg",
"png" => "png",
"gif" => "gif",
"txt" => "txt",
"doc" => "doc",
"docx" => "docx",
"xls" => "xls",
"xlsx" => "xlsx",
_ => "bin",
}
}
pub async fn upload_decision_documents(
client: &crate::PayrixClient,
hold_id: &str,
decision: MerchantDecision,
note_text: &str,
documents: &[HoldDocument],
) -> HoldResult<UploadResult> {
let note_type = match decision {
MerchantDecision::Valid => "riskApproved",
MerchantDecision::Fraudulent => "riskDenied",
};
let note_id = create_hold_note(
client,
&CreateNoteParams {
hold_id: hold_id.to_string(),
note_type: note_type.to_string(),
text: note_text.to_string(),
},
)
.await?;
let mut document_ids = Vec::new();
for doc in documents {
let doc_id = upload_document(
client,
&UploadDocumentParams {
note_id: note_id.clone(),
filename: doc.filename.clone(),
content_type: doc.content_type.clone(),
data: doc.data.clone(),
},
)
.await?;
document_ids.push(doc_id);
}
Ok(UploadResult {
note_id,
document_ids,
})
}
#[derive(Debug)]
pub struct DecisionResult {
pub accepted: bool,
pub hold_id: String,
pub document_ids: Vec<String>,
}
#[allow(unused_variables)]
pub async fn submit_decision(
hold: TypedHold<DecisionReceived>,
client: &crate::PayrixClient,
config: &HoldWorkflowConfig,
) -> HoldResult<TypedHold<Submitted>> {
let decision = hold
.decision
.ok_or_else(|| HoldError::InvalidDecision("No decision set".to_string()))?;
let note = hold
.merchant_note
.clone()
.unwrap_or_else(|| format!("Merchant decision: {}", decision));
let _upload_result = upload_decision_documents(
client,
&hold.id().to_string(),
decision,
¬e,
&hold.documents,
)
.await?;
match decision {
MerchantDecision::Valid => {
submit_valid_decision(client, &hold.id().to_string()).await?;
}
MerchantDecision::Fraudulent => {
submit_fraudulent_decision(client, &hold.id().to_string()).await?;
}
}
Ok(hold.transition())
}
#[allow(unused_variables)]
async fn submit_valid_decision(
client: &crate::PayrixClient,
hold_id: &str,
) -> HoldResult<()> {
tracing::info!(
hold_id = %hold_id,
action = "release",
"Would submit valid decision - release hold"
);
Ok(())
}
#[allow(unused_variables)]
async fn submit_fraudulent_decision(
client: &crate::PayrixClient,
hold_id: &str,
) -> HoldResult<()> {
tracing::info!(
hold_id = %hold_id,
action = "acknowledge_fraud",
"Would submit fraudulent decision - acknowledge fraud"
);
Ok(())
}
#[allow(unused_variables)]
pub async fn check_resolution(
hold: TypedHold<Submitted>,
client: &crate::PayrixClient,
) -> HoldResult<ResolutionStatus> {
let current_hold = get_hold(client, &hold.id().to_string()).await?;
if current_hold.released.is_some() {
Ok(ResolutionStatus::Resolved(hold.transition()))
} else {
Ok(ResolutionStatus::Pending(hold))
}
}
#[derive(Debug)]
pub enum ResolutionStatus {
Pending(TypedHold<Submitted>),
Resolved(TypedHold<Resolved>),
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_hold() -> Hold {
Hold {
id: "t1_hld_12345678901234567890123".parse().unwrap(),
created: None,
modified: None,
creator: None,
modifier: None,
login: None,
entity: Some("t1_ent_12345678901234567890123".parse().unwrap()),
txn: Some("t1_txn_12345678901234567890123".parse().unwrap()),
terminal_txn: None,
account: None,
verification: None,
verification_ref: None,
decision_action: None,
action: None,
hold_source: None,
hold_source_id: None,
hold_source_details: Some("Risk review required".to_string()),
division: None,
released: None,
reviewed: None,
inactive: false,
frozen: false,
release_action: None,
delayed_funding_start_date: None,
delayed_funding_end_date: None,
analyst: None,
claimed: None,
#[cfg(not(feature = "sqlx"))]
reserve: None,
}
}
#[test]
fn test_default_config() {
let config = HoldWorkflowConfig::default();
assert_eq!(config.email.domain, "holds.example.com");
assert_eq!(config.email.poll_interval, Duration::from_secs(30));
assert_eq!(config.timeouts.reminder_after, Duration::from_secs(24 * 60 * 60));
}
#[test]
fn test_config_builder() {
let config = HoldWorkflowConfigBuilder::new()
.domain("mycompany.com")
.from_address("noreply@mycompany.com")
.reminder_after(Duration::from_secs(12 * 60 * 60))
.build();
assert_eq!(config.email.domain, "mycompany.com");
assert_eq!(config.email.from_address, "noreply@mycompany.com");
assert_eq!(config.timeouts.reminder_after, Duration::from_secs(12 * 60 * 60));
}
#[test]
fn test_typed_hold_creation() {
let hold = make_test_hold();
let typed = TypedHold::<OnHold>::new(hold);
assert_eq!(typed.state_name(), "OnHold");
assert!(typed.workflow_id().starts_with("wf_"));
assert!(!typed.is_released());
}
#[test]
fn test_typed_hold_with_workflow_id() {
let hold = make_test_hold();
let typed = TypedHold::<OnHold>::with_workflow_id(hold, "custom_wf_123".to_string());
assert_eq!(typed.workflow_id(), "custom_wf_123");
}
#[test]
fn test_hold_workflow_enum() {
let hold = make_test_hold();
let workflow = HoldWorkflow::new(hold);
assert_eq!(workflow.state_name(), "OnHold");
assert!(!workflow.is_resolved());
}
#[test]
fn test_merchant_decision_display() {
assert_eq!(format!("{}", MerchantDecision::Valid), "Valid");
assert_eq!(format!("{}", MerchantDecision::Fraudulent), "Fraudulent");
}
#[test]
fn test_notification_context_formatted_amount() {
let context = NotificationContext {
hold_id: "123".to_string(),
workflow_id: "abc".to_string(),
transaction_id: None,
amount: Some(10050),
merchant_name: "Test Merchant".to_string(),
entity_id: None,
hold_reason: None,
};
assert_eq!(context.formatted_amount(), "$100.50");
}
#[test]
fn test_notification_context_no_amount() {
let context = NotificationContext {
hold_id: "123".to_string(),
workflow_id: "abc".to_string(),
transaction_id: None,
amount: None,
merchant_name: "Test Merchant".to_string(),
entity_id: None,
hold_reason: None,
};
assert_eq!(context.formatted_amount(), "Unknown");
}
#[test]
fn test_notification_builder() {
let config = HoldWorkflowConfig::default();
let context = NotificationContext {
hold_id: "t1_hld_123".to_string(),
workflow_id: "wf_abc".to_string(),
transaction_id: Some("t1_txn_456".to_string()),
amount: Some(5000),
merchant_name: "Acme Corp".to_string(),
entity_id: None,
hold_reason: None,
};
let email = NotificationBuilder::new(&config, context)
.to("merchant@example.com")
.build()
.unwrap();
assert_eq!(email.to, "merchant@example.com");
assert!(email.reply_to.contains("wf_abc"));
assert!(email.subject.contains("t1_hld_123"));
assert!(email.body.contains("Acme Corp"));
assert!(email.body.contains("$50.00"));
}
#[test]
fn test_notification_builder_missing_recipient() {
let config = HoldWorkflowConfig::default();
let context = NotificationContext {
hold_id: "123".to_string(),
workflow_id: "abc".to_string(),
transaction_id: None,
amount: None,
merchant_name: "Test".to_string(),
entity_id: None,
hold_reason: None,
};
let result = NotificationBuilder::new(&config, context).build();
assert!(result.is_err());
}
#[test]
fn test_classify_decision_valid() {
assert_eq!(
classify_decision("This is a valid transaction").unwrap(),
MerchantDecision::Valid
);
assert_eq!(
classify_decision("Please release this legitimate payment").unwrap(),
MerchantDecision::Valid
);
assert_eq!(
classify_decision("I confirm this is not fraud").unwrap(),
MerchantDecision::Valid
);
}
#[test]
fn test_classify_decision_fraudulent() {
assert_eq!(
classify_decision("This is fraud").unwrap(),
MerchantDecision::Fraudulent
);
assert_eq!(
classify_decision("This transaction is unauthorized").unwrap(),
MerchantDecision::Fraudulent
);
assert_eq!(
classify_decision("My card was stolen").unwrap(),
MerchantDecision::Fraudulent
);
}
#[test]
fn test_classify_decision_unclear() {
let result = classify_decision("Hello, I received your email");
assert!(result.is_err());
}
#[test]
fn test_extract_workflow_id() {
let id = extract_workflow_id("hold+wf_123456@holds.example.com", "hold+").unwrap();
assert_eq!(id, "wf_123456");
let id = extract_workflow_id("<hold+wf_789@holds.example.com>", "hold+").unwrap();
assert_eq!(id, "wf_789");
}
#[test]
fn test_extract_workflow_id_wrong_prefix() {
let result = extract_workflow_id("support+123@example.com", "hold+");
assert!(result.is_err());
}
#[test]
fn test_parse_email_reply() {
let reply = parse_email_reply(
"hold+wf_123@holds.example.com",
"This transaction is valid. Please release.",
vec![],
"hold+",
)
.unwrap();
assert_eq!(reply.workflow_id, "wf_123");
assert_eq!(reply.decision, MerchantDecision::Valid);
assert!(reply.note.is_some());
}
#[test]
fn test_infer_file_type() {
assert_eq!(infer_file_type("application/pdf"), "pdf");
assert_eq!(infer_file_type("image/jpeg"), "jpg");
assert_eq!(infer_file_type("image/png"), "png");
assert_eq!(infer_file_type("text/plain"), "txt");
assert_eq!(infer_file_type("application/octet-stream"), "bin");
}
#[test]
fn test_infer_file_type_from_filename() {
assert_eq!(infer_file_type_from_filename("document.pdf"), "pdf");
assert_eq!(infer_file_type_from_filename("photo.jpg"), "jpg");
assert_eq!(infer_file_type_from_filename("image.PNG"), "png");
assert_eq!(infer_file_type_from_filename("unknown"), "bin");
}
#[test]
fn test_hold_error_display() {
let err = HoldError::NotFound("123".to_string());
assert_eq!(format!("{}", err), "Hold not found: 123");
let err = HoldError::InvalidState {
hold_id: "456".to_string(),
current_state: "OnHold".to_string(),
expected_state: "AwaitingResponse".to_string(),
};
assert!(format!("{}", err).contains("456"));
assert!(format!("{}", err).contains("OnHold"));
}
#[test]
fn test_load_workflow_fresh() {
let hold = make_test_hold();
let workflow = load_workflow(hold);
assert_eq!(workflow.state_name(), "OnHold");
}
#[test]
fn test_load_workflow_released() {
let mut hold = make_test_hold();
hold.released = Some("2024-01-01 12:00:00.0000".to_string());
let workflow = load_workflow(hold);
assert_eq!(workflow.state_name(), "Resolved");
}
}