use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::instrument;
use crate::{
Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse,
};
const PROVIDER: &str = "idpay";
const API: &str = "https://api.idpay.ir";
pub struct IDPay {
api_key: String,
sandbox: bool,
api_base: String,
client: reqwest::Client,
}
impl IDPay {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
sandbox: false,
api_base: API.into(),
client: reqwest::Client::new(),
}
}
#[must_use]
pub fn sandbox(mut self) -> Self {
self.sandbox = true;
self
}
#[must_use]
pub fn with_api_base(mut self, url: impl Into<String>) -> Self {
self.api_base = url.into();
self
}
#[must_use]
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
fn request<U: AsRef<str>>(&self, path: U) -> reqwest::RequestBuilder {
let mut rb = self
.client
.post(format!("{}{}", self.api_base, path.as_ref()))
.header("X-API-KEY", &self.api_key)
.header("Content-Type", "application/json");
if self.sandbox {
rb = rb.header("X-SANDBOX", "1");
}
rb
}
}
#[async_trait]
impl Gateway for IDPay {
fn name(&self) -> &'static str {
PROVIDER
}
#[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))]
async fn start_payment(&self, req: &StartRequest) -> Result<StartResponse> {
if req.amount.as_rials() < 1_000 {
return Err(Error::Config(format!(
"idpay: amount must be at least 1000 Rials (got {} Rials)",
req.amount.as_rials()
)));
}
let body = json!({
"order_id": req.order_id.clone().unwrap_or_default(),
"amount": req.amount.as_rials(),
"name": req.extras.get("name").cloned().unwrap_or_default(),
"phone": req.mobile.clone().unwrap_or_default(),
"mail": req.email.clone().unwrap_or_default(),
"desc": req.description,
"callback": req.callback_url,
});
let resp = self
.request("/v1.1/payment")
.json(&body)
.send()
.await
.map_err(|e| Error::http(PROVIDER, e))?;
let raw: serde_json::Value = resp.json().await.map_err(|e| Error::http(PROVIDER, e))?;
if let Some(code) = raw.get("error_code").and_then(|v| v.as_i64()) {
return Err(Error::Gateway {
provider: PROVIDER,
code,
message: raw
.get("error_message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
});
}
let parsed: IdpStart = serde_json::from_value(raw.clone())
.map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?;
Ok(StartResponse {
authority: parsed.id,
payment_url: parsed.link,
provider: PROVIDER,
raw,
})
}
#[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))]
async fn verify_payment(&self, req: &VerifyRequest) -> Result<VerifyResponse> {
let body = json!({
"id": req.authority,
"order_id": req.authority,
});
let resp = self
.request("/v1.1/payment/verify")
.json(&body)
.send()
.await
.map_err(|e| Error::http(PROVIDER, e))?;
let raw: serde_json::Value = resp.json().await.map_err(|e| Error::http(PROVIDER, e))?;
if let Some(code) = raw.get("error_code").and_then(|v| v.as_i64()) {
return Err(Error::Gateway {
provider: PROVIDER,
code,
message: raw
.get("error_message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
});
}
let parsed: IdpVerify = serde_json::from_value(raw.clone())
.map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?;
if parsed.status != 100 && parsed.status != 101 {
return Err(Error::Gateway {
provider: PROVIDER,
code: parsed.status,
message: format!("idpay status {}", parsed.status),
});
}
let actual = Amount::rial(parsed.amount);
if actual != req.amount {
return Err(Error::AmountMismatch {
expected: req.amount,
actual,
});
}
Ok(VerifyResponse {
transaction_id: parsed.track_id.to_string(),
authority: req.authority.clone(),
amount: actual,
card_pan: parsed.payment.as_ref().and_then(|p| p.card_no.clone()),
card_hash: parsed
.payment
.as_ref()
.and_then(|p| p.hashed_card_no.clone()),
fee: None,
provider: PROVIDER,
raw,
})
}
}
#[derive(Debug, Deserialize, Serialize)]
struct IdpStart {
id: String,
link: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct IdpVerify {
status: i64,
track_id: i64,
amount: i64,
#[serde(default)]
payment: Option<IdpPayment>,
}
#[derive(Debug, Deserialize, Serialize)]
struct IdpPayment {
#[serde(default)]
card_no: Option<String>,
#[serde(default)]
hashed_card_no: Option<String>,
}