use async_trait::async_trait;
use std::marker::PhantomData;
use rust_decimal::Decimal;
use crate::utils::types::IntoDecimal;
use crate::{
client::CryptoBot,
error::{CryptoBotError, CryptoBotResult, ValidationErrorKind},
models::{
APIEndpoint, APIMethod, CryptoCurrencyCode, GetTransfersParams, GetTransfersResponse, Method, Missing, Set,
Transfer, TransferParams,
},
validation::{validate_amount, validate_count, ContextValidate, FieldValidate, ValidationContext},
};
use super::TransferAPI;
use crate::api::ExchangeRateAPI;
pub struct GetTransfersBuilder<'a> {
client: &'a CryptoBot,
params: GetTransfersParams,
}
impl<'a> GetTransfersBuilder<'a> {
pub fn new(client: &'a CryptoBot) -> Self {
Self {
client,
params: GetTransfersParams::default(),
}
}
pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
self.params.asset = Some(asset);
self
}
pub fn transfer_ids(mut self, ids: Vec<u64>) -> Self {
self.params.transfer_ids = Some(ids);
self
}
pub fn spend_id(mut self, spend_id: impl Into<String>) -> Self {
self.params.spend_id = Some(spend_id.into());
self
}
pub fn offset(mut self, offset: u32) -> Self {
self.params.offset = Some(offset);
self
}
pub fn count(mut self, count: u16) -> Self {
self.params.count = Some(count);
self
}
pub async fn execute(self) -> CryptoBotResult<Vec<Transfer>> {
if let Some(count) = self.params.count {
validate_count(count)?;
}
let response: GetTransfersResponse = self
.client
.make_request(
&APIMethod {
endpoint: APIEndpoint::GetTransfers,
method: Method::GET,
},
Some(&self.params),
)
.await?;
Ok(response.items)
}
}
pub struct TransferBuilder<'a, U = Missing, A = Missing, M = Missing, S = Missing> {
client: &'a CryptoBot,
user_id: u64,
asset: CryptoCurrencyCode,
amount: Decimal,
spend_id: String,
comment: Option<String>,
disable_send_notification: Option<bool>,
_state: PhantomData<(U, A, M, S)>,
}
impl<'a> TransferBuilder<'a, Missing, Missing, Missing, Missing> {
pub fn new(client: &'a CryptoBot) -> Self {
Self {
client,
user_id: 0,
asset: CryptoCurrencyCode::Ton,
amount: Decimal::ZERO,
spend_id: String::new(),
comment: None,
disable_send_notification: None,
_state: PhantomData,
}
}
}
impl<'a, A, M, S> TransferBuilder<'a, Missing, A, M, S> {
pub fn user_id(mut self, user_id: u64) -> TransferBuilder<'a, Set, A, M, S> {
self.user_id = user_id;
self.transform()
}
}
impl<'a, U, M, S> TransferBuilder<'a, U, Missing, M, S> {
pub fn asset(mut self, asset: CryptoCurrencyCode) -> TransferBuilder<'a, U, Set, M, S> {
self.asset = asset;
self.transform()
}
}
impl<'a, U, A, S> TransferBuilder<'a, U, A, Missing, S> {
pub fn amount(mut self, amount: impl IntoDecimal) -> TransferBuilder<'a, U, A, Set, S> {
self.amount = amount.into_decimal();
self.transform()
}
}
impl<'a, U, A, M> TransferBuilder<'a, U, A, M, Missing> {
pub fn spend_id(mut self, spend_id: impl Into<String>) -> TransferBuilder<'a, U, A, M, Set> {
self.spend_id = spend_id.into();
self.transform()
}
}
impl<'a, U, A, M, S> TransferBuilder<'a, U, A, M, S> {
pub fn comment(mut self, comment: impl Into<String>) -> Self {
self.comment = Some(comment.into());
self
}
pub fn disable_send_notification(mut self, disable: bool) -> Self {
self.disable_send_notification = Some(disable);
self
}
fn transform<U2, A2, M2, S2>(self) -> TransferBuilder<'a, U2, A2, M2, S2> {
TransferBuilder {
client: self.client,
user_id: self.user_id,
asset: self.asset,
amount: self.amount,
spend_id: self.spend_id,
comment: self.comment,
disable_send_notification: self.disable_send_notification,
_state: PhantomData,
}
}
}
impl<'a> FieldValidate for TransferBuilder<'a, Set, Set, Set, Set> {
fn validate(&self) -> CryptoBotResult<()> {
if self.spend_id.chars().count() > 64 {
return Err(CryptoBotError::ValidationError {
kind: ValidationErrorKind::Range,
message: "Spend ID must be at most 64 symbols".to_string(),
field: Some("spend_id".to_string()),
});
}
if let Some(comment) = &self.comment {
if comment.chars().count() > 1024 {
return Err(CryptoBotError::ValidationError {
kind: ValidationErrorKind::Range,
message: "Comment must be at most 1024 symbols".to_string(),
field: Some("comment".to_string()),
});
}
}
Ok(())
}
}
#[async_trait]
impl<'a> ContextValidate for TransferBuilder<'a, Set, Set, Set, Set> {
async fn validate_with_context(&self, ctx: &ValidationContext) -> CryptoBotResult<()> {
validate_amount(&self.amount, &self.asset, ctx).await
}
}
impl<'a> TransferBuilder<'a, Set, Set, Set, Set> {
pub async fn execute(self) -> CryptoBotResult<Transfer> {
self.validate()?;
let rates = self.client.get_exchange_rates().execute().await?;
let ctx = ValidationContext { exchange_rates: rates };
self.validate_with_context(&ctx).await?;
let params = TransferParams {
user_id: self.user_id,
asset: self.asset,
amount: self.amount,
spend_id: self.spend_id,
comment: self.comment,
disable_send_notification: self.disable_send_notification,
};
self.client
.make_request(
&APIMethod {
endpoint: APIEndpoint::Transfer,
method: Method::POST,
},
Some(¶ms),
)
.await
}
}
#[async_trait]
impl TransferAPI for CryptoBot {
fn transfer(&self) -> TransferBuilder<'_> {
TransferBuilder::new(self)
}
fn get_transfers(&self) -> GetTransfersBuilder<'_> {
GetTransfersBuilder::new(self)
}
}
#[cfg(test)]
mod tests {
use mockito::{Matcher, Mock};
use rust_decimal_macros::dec;
use serde_json::json;
use crate::{
api::TransferAPI,
client::CryptoBot,
models::{CryptoCurrencyCode, TransferStatus},
prelude::{CryptoBotError, ValidationErrorKind},
utils::test_utils::TestContext,
validation::FieldValidate,
};
impl TestContext {
pub fn mock_transfer_response(&mut self) -> Mock {
self.server
.mock("POST", "/transfer")
.with_header("content-type", "application/json")
.with_header("Crypto-Pay-API-Token", "test_token")
.with_body(
json!({
"ok": true,
"result": {
"transfer_id": 1,
"user_id": 123456789,
"asset": "TON",
"amount": "10.5",
"status": "completed",
"completed_at": "2024-03-14T12:00:00Z",
"comment": "test_comment",
"spend_id": "test_spend_id",
"disable_send_notification": false,
}
})
.to_string(),
)
.create()
}
pub fn mock_get_transfers_response_without_params(&mut self) -> Mock {
self.server
.mock("GET", "/getTransfers")
.with_header("content-type", "application/json")
.with_header("Crypto-Pay-API-Token", "test_token")
.with_body(
json!({
"ok": true,
"result": {
"items": [{
"transfer_id": 1,
"user_id": 123456789,
"asset": "TON",
"amount": "10.5",
"status": "completed",
"completed_at": "2024-03-14T12:00:00Z",
"comment": "test_comment",
"spend_id": "test_spend_id",
"disable_send_notification": false,
}]
}
})
.to_string(),
)
.create()
}
pub fn mock_get_transfers_response_with_transfer_ids(&mut self) -> Mock {
self.server
.mock("GET", "/getTransfers")
.match_body(json!({ "transfer_ids": "1" }).to_string().as_str())
.with_header("content-type", "application/json")
.with_header("Crypto-Pay-API-Token", "test_token")
.with_body(
json!({
"ok": true,
"result": {
"items": [
{
"transfer_id": 1,
"user_id": 123456789,
"asset": "TON",
"amount": "10.5",
"status": "completed",
"completed_at": "2024-03-14T12:00:00Z",
"comment": "test_comment",
"spend_id": "test_spend_id",
"disable_send_notification": false,
}
]
}
})
.to_string(),
)
.create()
}
pub fn mock_get_transfers_response_with_all_filters(&mut self) -> Mock {
self.server
.mock("GET", "/getTransfers")
.match_body(Matcher::JsonString(
json!({
"asset": "TON",
"transfer_ids": "1,2",
"spend_id": "filter_spend",
"offset": 2,
"count": 3
})
.to_string(),
))
.with_header("content-type", "application/json")
.with_header("Crypto-Pay-API-Token", "test_token")
.with_body(
json!({
"ok": true,
"result": {
"items": [{
"transfer_id": 2,
"user_id": 123456789,
"asset": "TON",
"amount": "1.5",
"status": "completed",
"completed_at": "2024-03-14T12:00:00Z",
"comment": "filter_comment",
"spend_id": "filter_spend",
"disable_send_notification": true,
}]
}
})
.to_string(),
)
.create()
}
pub fn mock_transfer_with_optional_fields_response(&mut self) -> Mock {
self.server
.mock("POST", "/transfer")
.match_body(Matcher::JsonString(
json!({
"user_id": 999,
"asset": "TON",
"amount": "2",
"spend_id": "long_spend",
"comment": "optional",
"disable_send_notification": true
})
.to_string(),
))
.with_header("content-type", "application/json")
.with_header("Crypto-Pay-API-Token", "test_token")
.with_body(
json!({
"ok": true,
"result": {
"transfer_id": 9,
"user_id": 999,
"asset": "TON",
"amount": "2",
"status": "completed",
"completed_at": "2024-03-14T12:00:00Z",
"comment": "optional",
"spend_id": "long_spend",
"disable_send_notification": true,
}
})
.to_string(),
)
.create()
}
}
#[test]
fn test_transfer() {
let mut ctx = TestContext::new();
let _m = ctx.mock_exchange_rates_response();
let _m = ctx.mock_transfer_response();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async {
client
.transfer()
.user_id(123456789)
.asset(CryptoCurrencyCode::Ton)
.amount(dec!(10.5))
.spend_id("test_spend_id".to_string())
.comment("test_comment".to_string())
.execute()
.await
});
println!("result:{:?}", result);
assert!(result.is_ok());
let transfer = result.unwrap();
assert_eq!(transfer.transfer_id, 1);
assert_eq!(transfer.user_id, 123456789);
assert_eq!(transfer.asset, CryptoCurrencyCode::Ton);
assert_eq!(transfer.amount, dec!(10.5));
assert_eq!(transfer.status, TransferStatus::Completed);
}
#[test]
fn test_get_transfers_without_params() {
let mut ctx = TestContext::new();
let _m = ctx.mock_get_transfers_response_without_params();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_transfers().execute().await });
assert!(result.is_ok());
let transfers = result.unwrap();
assert_eq!(transfers.len(), 1);
let transfer = &transfers[0];
assert_eq!(transfer.transfer_id, 1);
assert_eq!(transfer.asset, CryptoCurrencyCode::Ton);
assert_eq!(transfer.status, TransferStatus::Completed);
}
#[test]
fn test_get_transfers_with_transfer_ids() {
let mut ctx = TestContext::new();
let _m = ctx.mock_get_transfers_response_with_transfer_ids();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_transfers().transfer_ids(vec![1]).execute().await });
assert!(result.is_ok());
let transfers = result.unwrap();
assert_eq!(transfers.len(), 1);
}
#[test]
fn test_get_transfers_with_all_filters() {
let mut ctx = TestContext::new();
let _m = ctx.mock_get_transfers_response_with_all_filters();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async {
client
.get_transfers()
.asset(CryptoCurrencyCode::Ton)
.transfer_ids(vec![1, 2])
.spend_id("filter_spend")
.offset(2)
.count(3)
.execute()
.await
});
assert!(result.is_ok());
let transfers = result.unwrap();
assert_eq!(transfers.len(), 1);
}
#[test]
fn test_get_transfers_invalid_count() {
let ctx = TestContext::new();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_transfers().count(0).execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ValidationError {
kind: ValidationErrorKind::Range,
..
})
));
}
#[test]
fn test_transfer_rejects_long_spend_id() {
let ctx = TestContext::new();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let spend_id = "a".repeat(65);
let builder = client
.transfer()
.user_id(1)
.asset(CryptoCurrencyCode::Ton)
.amount(dec!(1))
.spend_id(spend_id);
let result = builder.validate();
assert!(matches!(
result,
Err(CryptoBotError::ValidationError {
field,
kind: ValidationErrorKind::Range,
..
}) if field == Some("spend_id".to_string())
));
}
#[test]
fn test_transfer_rejects_long_comment() {
let ctx = TestContext::new();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let comment = "a".repeat(1_025);
let builder = client
.transfer()
.user_id(1)
.asset(CryptoCurrencyCode::Ton)
.amount(dec!(1))
.spend_id("spend")
.comment(comment);
let result = builder.validate();
assert!(matches!(
result,
Err(CryptoBotError::ValidationError {
field,
kind: ValidationErrorKind::Range,
..
}) if field == Some("comment".to_string())
));
}
#[test]
fn test_transfer_with_disable_notification_flag() {
let mut ctx = TestContext::new();
let _m = ctx.mock_exchange_rates_response();
let _m = ctx.mock_transfer_with_optional_fields_response();
let client = CryptoBot::builder()
.api_token("test_token")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async {
client
.transfer()
.user_id(999)
.asset(CryptoCurrencyCode::Ton)
.amount(dec!(2))
.spend_id("long_spend")
.comment("optional")
.disable_send_notification(true)
.execute()
.await
});
assert!(result.is_ok());
assert!(result.is_ok());
}
}