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 pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
36 self.params.asset = Some(asset);
37 self
38 }
39
40 pub fn transfer_ids(mut self, ids: Vec<u64>) -> Self {
43 self.params.transfer_ids = Some(ids);
44 self
45 }
46
47 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 pub fn offset(mut self, offset: u32) -> Self {
58 self.params.offset = Some(offset);
59 self
60 }
61
62 pub fn count(mut self, count: u16) -> Self {
65 self.params.count = Some(count);
66 self
67 }
68
69 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 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 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 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 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 pub fn comment(mut self, comment: impl Into<String>) -> Self {
158 self.comment = Some(comment.into());
159 self
160 }
161
162 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 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(¶ms),
240 )
241 .await
242 }
243}
244
245#[async_trait]
246impl TransferAPI for CryptoBot {
247 fn transfer(&self) -> TransferBuilder<'_> {
252 TransferBuilder::new(self)
253 }
254
255 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}