crypto_pay_api/api/
transfer.rs

1use async_trait::async_trait;
2use std::marker::PhantomData;
3
4use rust_decimal::Decimal;
5
6use crate::utils::types::IntoDecimal;
7use crate::{
8    client::CryptoBot,
9    error::{CryptoBotError, CryptoBotResult, ValidationErrorKind},
10    models::{
11        APIEndpoint, APIMethod, CryptoCurrencyCode, GetTransfersParams, GetTransfersResponse, Method, Missing, Set,
12        Transfer, TransferParams,
13    },
14    validation::{validate_amount, validate_count, ContextValidate, FieldValidate, ValidationContext},
15};
16
17use super::TransferAPI;
18use crate::api::ExchangeRateAPI;
19
20pub struct GetTransfersBuilder<'a> {
21    client: &'a CryptoBot,
22    params: GetTransfersParams,
23}
24
25impl<'a> GetTransfersBuilder<'a> {
26    pub fn new(client: &'a CryptoBot) -> Self {
27        Self {
28            client,
29            params: GetTransfersParams::default(),
30        }
31    }
32
33    /// Set the asset for the transfers.
34    /// Optional. Defaults to all currencies.
35    pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
36        self.params.asset = Some(asset);
37        self
38    }
39
40    /// Set the transfer IDs for the transfers.
41    /// Optional.
42    pub fn transfer_ids(mut self, ids: Vec<u64>) -> Self {
43        self.params.transfer_ids = Some(ids);
44        self
45    }
46
47    /// Set the spend ID for the transfers.
48    /// Optional. Unique UTF-8 transfer string.
49    pub fn spend_id(mut self, spend_id: impl Into<String>) -> Self {
50        self.params.spend_id = Some(spend_id.into());
51        self
52    }
53
54    /// Set the offset for the transfers.
55    /// Optional. Offset needed to return a specific subset of transfers.
56    /// Defaults to 0.
57    pub fn offset(mut self, offset: u32) -> Self {
58        self.params.offset = Some(offset);
59        self
60    }
61
62    /// Set the count for the transfers.
63    /// Optional. Defaults to 100. Values between 1-1000 are accepted.
64    pub fn count(mut self, count: u16) -> Self {
65        self.params.count = Some(count);
66        self
67    }
68
69    /// Executes the request to get transfers
70    pub async fn execute(self) -> CryptoBotResult<Vec<Transfer>> {
71        if let Some(count) = self.params.count {
72            validate_count(count)?;
73        }
74
75        let response: GetTransfersResponse = self
76            .client
77            .make_request(
78                &APIMethod {
79                    endpoint: APIEndpoint::GetTransfers,
80                    method: Method::GET,
81                },
82                Some(&self.params),
83            )
84            .await?;
85
86        Ok(response.items)
87    }
88}
89
90pub struct TransferBuilder<'a, U = Missing, A = Missing, M = Missing, S = Missing> {
91    client: &'a CryptoBot,
92    user_id: u64,
93    asset: CryptoCurrencyCode,
94    amount: Decimal,
95    spend_id: String,
96    comment: Option<String>,
97    disable_send_notification: Option<bool>,
98    _state: PhantomData<(U, A, M, S)>,
99}
100
101impl<'a> TransferBuilder<'a, Missing, Missing, Missing, Missing> {
102    pub fn new(client: &'a CryptoBot) -> Self {
103        Self {
104            client,
105            user_id: 0,
106            asset: CryptoCurrencyCode::Ton,
107            amount: Decimal::ZERO,
108            spend_id: String::new(),
109            comment: None,
110            disable_send_notification: None,
111            _state: PhantomData,
112        }
113    }
114}
115
116impl<'a, A, M, S> TransferBuilder<'a, Missing, A, M, S> {
117    /// Set the Telegram user ID for the transfer.
118    pub fn user_id(mut self, user_id: u64) -> TransferBuilder<'a, Set, A, M, S> {
119        self.user_id = user_id;
120        self.transform()
121    }
122}
123
124impl<'a, U, M, S> TransferBuilder<'a, U, Missing, M, S> {
125    /// Set the asset for the transfer.
126    pub fn asset(mut self, asset: CryptoCurrencyCode) -> TransferBuilder<'a, U, Set, M, S> {
127        self.asset = asset;
128        self.transform()
129    }
130}
131
132impl<'a, U, A, S> TransferBuilder<'a, U, A, Missing, S> {
133    /// Set the amount for the transfer.
134    /// The minimum and maximum amount limits for each of the supported assets roughly correspond to 1-25000 USD.
135    pub fn amount(mut self, amount: impl IntoDecimal) -> TransferBuilder<'a, U, A, Set, S> {
136        self.amount = amount.into_decimal();
137        self.transform()
138    }
139}
140
141impl<'a, U, A, M> TransferBuilder<'a, U, A, M, Missing> {
142    /// Set the spend ID for the transfer.
143    /// Random UTF-8 string unique per transfer for idempotent requests.
144    /// The same spend_id can be accepted only once from your app.
145    /// Up to 64 symbols.
146    pub fn spend_id(mut self, spend_id: impl Into<String>) -> TransferBuilder<'a, U, A, M, Set> {
147        self.spend_id = spend_id.into();
148        self.transform()
149    }
150}
151
152impl<'a, U, A, M, S> TransferBuilder<'a, U, A, M, S> {
153    /// Set the comment for the transfer.
154    /// Optional. Comment for the transfer.
155    /// Users will see this comment in the notification about the transfer.
156    /// Up to 1024 symbols.
157    pub fn comment(mut self, comment: impl Into<String>) -> Self {
158        self.comment = Some(comment.into());
159        self
160    }
161
162    /// Set the disable send notification for the transfer.
163    /// Optional. Pass true to not send to the user the notification about the transfer.
164    /// Defaults to false.
165    pub fn disable_send_notification(mut self, disable: bool) -> Self {
166        self.disable_send_notification = Some(disable);
167        self
168    }
169
170    fn transform<U2, A2, M2, S2>(self) -> TransferBuilder<'a, U2, A2, M2, S2> {
171        TransferBuilder {
172            client: self.client,
173            user_id: self.user_id,
174            asset: self.asset,
175            amount: self.amount,
176            spend_id: self.spend_id,
177            comment: self.comment,
178            disable_send_notification: self.disable_send_notification,
179            _state: PhantomData,
180        }
181    }
182}
183
184impl<'a> FieldValidate for TransferBuilder<'a, Set, Set, Set, Set> {
185    fn validate(&self) -> CryptoBotResult<()> {
186        if self.spend_id.chars().count() > 64 {
187            return Err(CryptoBotError::ValidationError {
188                kind: ValidationErrorKind::Range,
189                message: "Spend ID must be at most 64 symbols".to_string(),
190                field: Some("spend_id".to_string()),
191            });
192        }
193
194        if let Some(comment) = &self.comment {
195            if comment.chars().count() > 1024 {
196                return Err(CryptoBotError::ValidationError {
197                    kind: ValidationErrorKind::Range,
198                    message: "Comment must be at most 1024 symbols".to_string(),
199                    field: Some("comment".to_string()),
200                });
201            }
202        }
203
204        Ok(())
205    }
206}
207
208#[async_trait]
209impl<'a> ContextValidate for TransferBuilder<'a, Set, Set, Set, Set> {
210    async fn validate_with_context(&self, ctx: &ValidationContext) -> CryptoBotResult<()> {
211        validate_amount(&self.amount, &self.asset, ctx).await
212    }
213}
214
215impl<'a> TransferBuilder<'a, Set, Set, Set, Set> {
216    /// Executes the request to transfer cryptocurrency
217    pub async fn execute(self) -> CryptoBotResult<Transfer> {
218        self.validate()?;
219
220        let rates = self.client.get_exchange_rates().execute().await?;
221        let ctx = ValidationContext { exchange_rates: rates };
222        self.validate_with_context(&ctx).await?;
223
224        let params = TransferParams {
225            user_id: self.user_id,
226            asset: self.asset,
227            amount: self.amount,
228            spend_id: self.spend_id,
229            comment: self.comment,
230            disable_send_notification: self.disable_send_notification,
231        };
232
233        self.client
234            .make_request(
235                &APIMethod {
236                    endpoint: APIEndpoint::Transfer,
237                    method: Method::POST,
238                },
239                Some(&params),
240            )
241            .await
242    }
243}
244
245#[async_trait]
246impl TransferAPI for CryptoBot {
247    /// Transfer cryptocurrency to a user
248    ///
249    /// # Returns
250    /// * `TransferBuilder` - A builder to construct the transfer parameters
251    fn transfer(&self) -> TransferBuilder<'_> {
252        TransferBuilder::new(self)
253    }
254
255    /// Get transfers history
256    ///
257    /// # Returns
258    /// * `GetTransfersBuilder` - A builder to construct the filter parameters
259    fn get_transfers(&self) -> GetTransfersBuilder<'_> {
260        GetTransfersBuilder::new(self)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use mockito::{Matcher, Mock};
267    use rust_decimal_macros::dec;
268    use serde_json::json;
269
270    use crate::{
271        api::TransferAPI,
272        client::CryptoBot,
273        models::{CryptoCurrencyCode, TransferStatus},
274        prelude::{CryptoBotError, ValidationErrorKind},
275        utils::test_utils::TestContext,
276        validation::FieldValidate,
277    };
278
279    impl TestContext {
280        pub fn mock_transfer_response(&mut self) -> Mock {
281            self.server
282                .mock("POST", "/transfer")
283                .with_header("content-type", "application/json")
284                .with_header("Crypto-Pay-API-Token", "test_token")
285                .with_body(
286                    json!({
287                        "ok": true,
288                        "result": {
289                            "transfer_id": 1,
290                            "user_id": 123456789,
291                            "asset": "TON",
292                            "amount": "10.5",
293                            "status": "completed",
294                            "completed_at": "2024-03-14T12:00:00Z",
295                            "comment": "test_comment",
296                            "spend_id": "test_spend_id",
297                            "disable_send_notification": false,
298                        }
299                    })
300                    .to_string(),
301                )
302                .create()
303        }
304
305        pub fn mock_get_transfers_response_without_params(&mut self) -> Mock {
306            self.server
307                .mock("GET", "/getTransfers")
308                .with_header("content-type", "application/json")
309                .with_header("Crypto-Pay-API-Token", "test_token")
310                .with_body(
311                    json!({
312                        "ok": true,
313                        "result": {
314                            "items": [{
315                                "transfer_id": 1,
316                                "user_id": 123456789,
317                                "asset": "TON",
318                                "amount": "10.5",
319                                "status": "completed",
320                                "completed_at": "2024-03-14T12:00:00Z",
321                                "comment": "test_comment",
322                                "spend_id": "test_spend_id",
323                                "disable_send_notification": false,
324                            }]
325                        }
326                    })
327                    .to_string(),
328                )
329                .create()
330        }
331
332        pub fn mock_get_transfers_response_with_transfer_ids(&mut self) -> Mock {
333            self.server
334                .mock("GET", "/getTransfers")
335                .match_body(json!({ "transfer_ids": "1" }).to_string().as_str())
336                .with_header("content-type", "application/json")
337                .with_header("Crypto-Pay-API-Token", "test_token")
338                .with_body(
339                    json!({
340                        "ok": true,
341                        "result": {
342                            "items": [
343                                {
344                                    "transfer_id": 1,
345                                    "user_id": 123456789,
346                                    "asset": "TON",
347                                    "amount": "10.5",
348                                    "status": "completed",
349                                    "completed_at": "2024-03-14T12:00:00Z",
350                                    "comment": "test_comment",
351                                    "spend_id": "test_spend_id",
352                                    "disable_send_notification": false,
353                                }
354                            ]
355                        }
356                    })
357                    .to_string(),
358                )
359                .create()
360        }
361
362        pub fn mock_get_transfers_response_with_all_filters(&mut self) -> Mock {
363            self.server
364                .mock("GET", "/getTransfers")
365                .match_body(Matcher::JsonString(
366                    json!({
367                        "asset": "TON",
368                        "transfer_ids": "1,2",
369                        "spend_id": "filter_spend",
370                        "offset": 2,
371                        "count": 3
372                    })
373                    .to_string(),
374                ))
375                .with_header("content-type", "application/json")
376                .with_header("Crypto-Pay-API-Token", "test_token")
377                .with_body(
378                    json!({
379                        "ok": true,
380                        "result": {
381                            "items": [{
382                                "transfer_id": 2,
383                                "user_id": 123456789,
384                                "asset": "TON",
385                                "amount": "1.5",
386                                "status": "completed",
387                                "completed_at": "2024-03-14T12:00:00Z",
388                                "comment": "filter_comment",
389                                "spend_id": "filter_spend",
390                                "disable_send_notification": true,
391                            }]
392                        }
393                    })
394                    .to_string(),
395                )
396                .create()
397        }
398
399        pub fn mock_transfer_with_optional_fields_response(&mut self) -> Mock {
400            self.server
401                .mock("POST", "/transfer")
402                .match_body(Matcher::JsonString(
403                    json!({
404                        "user_id": 999,
405                        "asset": "TON",
406                        "amount": "2",
407                        "spend_id": "long_spend",
408                        "comment": "optional",
409                        "disable_send_notification": true
410                    })
411                    .to_string(),
412                ))
413                .with_header("content-type", "application/json")
414                .with_header("Crypto-Pay-API-Token", "test_token")
415                .with_body(
416                    json!({
417                        "ok": true,
418                        "result": {
419                            "transfer_id": 9,
420                            "user_id": 999,
421                            "asset": "TON",
422                            "amount": "2",
423                            "status": "completed",
424                            "completed_at": "2024-03-14T12:00:00Z",
425                            "comment": "optional",
426                            "spend_id": "long_spend",
427                            "disable_send_notification": true,
428                        }
429                    })
430                    .to_string(),
431                )
432                .create()
433        }
434    }
435
436    #[test]
437    fn test_transfer() {
438        let mut ctx = TestContext::new();
439        let _m = ctx.mock_exchange_rates_response();
440        let _m = ctx.mock_transfer_response();
441
442        let client = CryptoBot::builder()
443            .api_token("test_token")
444            .base_url(ctx.server.url())
445            .build()
446            .unwrap();
447
448        let result = ctx.run(async {
449            client
450                .transfer()
451                .user_id(123456789)
452                .asset(CryptoCurrencyCode::Ton)
453                .amount(dec!(10.5))
454                .spend_id("test_spend_id".to_string())
455                .comment("test_comment".to_string())
456                .execute()
457                .await
458        });
459
460        println!("result:{:?}", result);
461
462        assert!(result.is_ok());
463
464        let transfer = result.unwrap();
465        assert_eq!(transfer.transfer_id, 1);
466        assert_eq!(transfer.user_id, 123456789);
467        assert_eq!(transfer.asset, CryptoCurrencyCode::Ton);
468        assert_eq!(transfer.amount, dec!(10.5));
469        assert_eq!(transfer.status, TransferStatus::Completed);
470    }
471
472    #[test]
473    fn test_get_transfers_without_params() {
474        let mut ctx = TestContext::new();
475        let _m = ctx.mock_get_transfers_response_without_params();
476
477        let client = CryptoBot::builder()
478            .api_token("test_token")
479            .base_url(ctx.server.url())
480            .build()
481            .unwrap();
482
483        let result = ctx.run(async { client.get_transfers().execute().await });
484
485        assert!(result.is_ok());
486        let transfers = result.unwrap();
487        assert_eq!(transfers.len(), 1);
488
489        let transfer = &transfers[0];
490        assert_eq!(transfer.transfer_id, 1);
491        assert_eq!(transfer.asset, CryptoCurrencyCode::Ton);
492        assert_eq!(transfer.status, TransferStatus::Completed);
493    }
494
495    #[test]
496    fn test_get_transfers_with_transfer_ids() {
497        let mut ctx = TestContext::new();
498        let _m = ctx.mock_get_transfers_response_with_transfer_ids();
499
500        let client = CryptoBot::builder()
501            .api_token("test_token")
502            .base_url(ctx.server.url())
503            .build()
504            .unwrap();
505
506        let result = ctx.run(async { client.get_transfers().transfer_ids(vec![1]).execute().await });
507
508        assert!(result.is_ok());
509        let transfers = result.unwrap();
510        assert_eq!(transfers.len(), 1);
511    }
512
513    #[test]
514    fn test_get_transfers_with_all_filters() {
515        let mut ctx = TestContext::new();
516        let _m = ctx.mock_get_transfers_response_with_all_filters();
517
518        let client = CryptoBot::builder()
519            .api_token("test_token")
520            .base_url(ctx.server.url())
521            .build()
522            .unwrap();
523
524        let result = ctx.run(async {
525            client
526                .get_transfers()
527                .asset(CryptoCurrencyCode::Ton)
528                .transfer_ids(vec![1, 2])
529                .spend_id("filter_spend")
530                .offset(2)
531                .count(3)
532                .execute()
533                .await
534        });
535
536        assert!(result.is_ok());
537        let transfers = result.unwrap();
538        assert_eq!(transfers.len(), 1);
539    }
540
541    #[test]
542    fn test_get_transfers_invalid_count() {
543        let ctx = TestContext::new();
544        let client = CryptoBot::builder()
545            .api_token("test_token")
546            .base_url(ctx.server.url())
547            .build()
548            .unwrap();
549
550        let result = ctx.run(async { client.get_transfers().count(0).execute().await });
551
552        assert!(matches!(
553            result,
554            Err(CryptoBotError::ValidationError {
555                kind: ValidationErrorKind::Range,
556                ..
557            })
558        ));
559    }
560
561    #[test]
562    fn test_transfer_rejects_long_spend_id() {
563        let ctx = TestContext::new();
564        let client = CryptoBot::builder()
565            .api_token("test_token")
566            .base_url(ctx.server.url())
567            .build()
568            .unwrap();
569
570        let spend_id = "a".repeat(65);
571        let builder = client
572            .transfer()
573            .user_id(1)
574            .asset(CryptoCurrencyCode::Ton)
575            .amount(dec!(1))
576            .spend_id(spend_id);
577
578        let result = builder.validate();
579        assert!(matches!(
580            result,
581            Err(CryptoBotError::ValidationError {
582                field,
583                kind: ValidationErrorKind::Range,
584                ..
585            }) if field == Some("spend_id".to_string())
586        ));
587    }
588
589    #[test]
590    fn test_transfer_rejects_long_comment() {
591        let ctx = TestContext::new();
592        let client = CryptoBot::builder()
593            .api_token("test_token")
594            .base_url(ctx.server.url())
595            .build()
596            .unwrap();
597
598        let comment = "a".repeat(1_025);
599        let builder = client
600            .transfer()
601            .user_id(1)
602            .asset(CryptoCurrencyCode::Ton)
603            .amount(dec!(1))
604            .spend_id("spend")
605            .comment(comment);
606
607        let result = builder.validate();
608        assert!(matches!(
609            result,
610            Err(CryptoBotError::ValidationError {
611                field,
612                kind: ValidationErrorKind::Range,
613                ..
614            }) if field == Some("comment".to_string())
615        ));
616    }
617
618    #[test]
619    fn test_transfer_with_disable_notification_flag() {
620        let mut ctx = TestContext::new();
621        let _m = ctx.mock_exchange_rates_response();
622        let _m = ctx.mock_transfer_with_optional_fields_response();
623
624        let client = CryptoBot::builder()
625            .api_token("test_token")
626            .base_url(ctx.server.url())
627            .build()
628            .unwrap();
629
630        let result = ctx.run(async {
631            client
632                .transfer()
633                .user_id(999)
634                .asset(CryptoCurrencyCode::Ton)
635                .amount(dec!(2))
636                .spend_id("long_spend")
637                .comment("optional")
638                .disable_send_notification(true)
639                .execute()
640                .await
641        });
642
643        assert!(result.is_ok());
644        assert!(result.is_ok());
645    }
646}