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 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(¶ms),
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 pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
65 self.params.asset = Some(asset);
66 self
67 }
68
69 pub fn check_ids(mut self, check_ids: Vec<u64>) -> Self {
71 self.params.check_ids = Some(check_ids);
72 self
73 }
74
75 pub fn status(mut self, status: CheckStatus) -> Self {
79 self.params.status = Some(status);
80 self
81 }
82
83 pub fn offset(mut self, offset: u32) -> Self {
87 self.params.offset = Some(offset);
88 self
89 }
90
91 pub fn count(mut self, count: u16) -> Self {
95 self.params.count = Some(count);
96 self
97 }
98
99 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 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 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 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 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 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(¶ms),
230 )
231 .await
232 }
233}
234
235#[async_trait]
236impl CheckAPI for CryptoBot {
237 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 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}