crypto_pay_api/api/
check.rs

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