1use std::marker::PhantomData;
2
3use rust_decimal::Decimal;
4
5use crate::{
6 api::ExchangeRateAPI,
7 client::CryptoBot,
8 error::{CryptoBotError, CryptoBotResult, ValidationErrorKind},
9 models::{CryptoCurrencyCode, CurrencyType, FiatCurrencyCode, Missing, PayButtonName, Set, SwapToAssets},
10 validation::{validate_amount, validate_count, ContextValidate, FieldValidate, ValidationContext},
11};
12
13use super::{CreateInvoiceParams, GetInvoicesParams, InvoiceStatus};
14
15#[derive(Debug, Default)]
18pub struct GetInvoicesParamsBuilder {
19 pub asset: Option<CryptoCurrencyCode>,
20 pub fiat: Option<FiatCurrencyCode>,
21 pub invoice_ids: Option<Vec<u64>>,
22 pub status: Option<InvoiceStatus>,
23 pub offset: Option<u32>,
24 pub count: Option<u16>,
25}
26
27impl GetInvoicesParamsBuilder {
28 pub fn new() -> Self {
30 Self::default()
31 }
32
33 pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
36 self.asset = Some(asset);
37 self
38 }
39
40 pub fn fiat(mut self, fiat: FiatCurrencyCode) -> Self {
43 self.fiat = Some(fiat);
44 self
45 }
46
47 pub fn invoice_ids(mut self, invoice_ids: Vec<u64>) -> Self {
49 self.invoice_ids = Some(invoice_ids);
50 self
51 }
52
53 pub fn status(mut self, status: InvoiceStatus) -> Self {
56 self.status = Some(status);
57 self
58 }
59
60 pub fn offset(mut self, offset: u32) -> Self {
64 self.offset = Some(offset);
65 self
66 }
67
68 pub fn count(mut self, count: u16) -> Self {
72 self.count = Some(count);
73 self
74 }
75}
76
77impl FieldValidate for GetInvoicesParamsBuilder {
78 fn validate(&self) -> CryptoBotResult<()> {
79 if let Some(count) = &self.count {
80 validate_count(*count)?;
81 }
82 Ok(())
83 }
84}
85
86impl GetInvoicesParamsBuilder {
87 pub fn build(self) -> CryptoBotResult<GetInvoicesParams> {
88 self.validate()?;
89
90 Ok(GetInvoicesParams {
91 asset: self.asset,
92 fiat: self.fiat,
93 invoice_ids: self.invoice_ids,
94 status: self.status,
95 offset: self.offset,
96 count: self.count,
97 })
98 }
99}
100#[derive(Debug)]
106pub struct CreateInvoiceParamsBuilder<A = Missing, C = Missing, P = Missing, U = Missing> {
107 pub currency_type: Option<CurrencyType>,
108 pub asset: Option<CryptoCurrencyCode>,
109 pub fiat: Option<FiatCurrencyCode>,
110 pub accept_asset: Option<Vec<CryptoCurrencyCode>>,
111 pub amount: Decimal,
112 pub description: Option<String>,
113 pub hidden_message: Option<String>,
114 pub paid_btn_name: Option<PayButtonName>,
115 pub paid_btn_url: Option<String>,
116 pub swap_to: Option<SwapToAssets>,
117 pub payload: Option<String>,
118 pub allow_comments: Option<bool>,
119 pub allow_anonymous: Option<bool>,
120 pub expires_in: Option<u32>,
121 _state: PhantomData<(A, C, P, U)>,
122}
123
124impl CreateInvoiceParamsBuilder<Missing, Missing, Missing, Missing> {
125 pub fn new() -> Self {
127 Self {
128 currency_type: Some(CurrencyType::Crypto),
129 asset: None,
130 fiat: None,
131 accept_asset: None,
132 amount: Decimal::ZERO,
133 description: None,
134 hidden_message: None,
135 paid_btn_name: None,
136 paid_btn_url: None,
137 swap_to: None,
138 payload: None,
139 allow_comments: None,
140 allow_anonymous: None,
141 expires_in: None,
142 _state: PhantomData,
143 }
144 }
145}
146
147impl Default for CreateInvoiceParamsBuilder<Missing, Missing, Missing, Missing> {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl<C, P, U> CreateInvoiceParamsBuilder<Missing, C, P, U> {
154 pub fn amount(mut self, amount: Decimal) -> CreateInvoiceParamsBuilder<Set, C, P, U> {
156 self.amount = amount;
157 self.transform()
158 }
159}
160
161impl<A, P, U> CreateInvoiceParamsBuilder<A, Missing, P, U> {
162 pub fn asset(mut self, asset: CryptoCurrencyCode) -> CreateInvoiceParamsBuilder<A, Set, P, U> {
164 self.currency_type = Some(CurrencyType::Crypto);
165 self.asset = Some(asset);
166 self.transform()
167 }
168
169 pub fn fiat(mut self, fiat: FiatCurrencyCode) -> CreateInvoiceParamsBuilder<A, Set, P, U> {
171 self.currency_type = Some(CurrencyType::Fiat);
172 self.fiat = Some(fiat);
173 self.transform()
174 }
175}
176
177impl<A, C, U> CreateInvoiceParamsBuilder<A, C, Missing, U> {
178 pub fn paid_btn_name(mut self, paid_btn_name: PayButtonName) -> CreateInvoiceParamsBuilder<A, C, Set, U> {
186 self.paid_btn_name = Some(paid_btn_name);
187 self.transform()
188 }
189}
190
191impl<A, C> CreateInvoiceParamsBuilder<A, C, Set, Missing> {
192 pub fn paid_btn_url(mut self, paid_btn_url: impl Into<String>) -> CreateInvoiceParamsBuilder<A, C, Set, Set> {
197 self.paid_btn_url = Some(paid_btn_url.into());
198 self.transform()
199 }
200}
201
202impl<A, C, P, U> CreateInvoiceParamsBuilder<A, C, P, U> {
203 pub fn accept_asset(mut self, accept_asset: Vec<CryptoCurrencyCode>) -> Self {
206 self.accept_asset = Some(accept_asset);
207 self
208 }
209
210 pub fn description(mut self, description: impl Into<String>) -> Self {
214 self.description = Some(description.into());
215 self
216 }
217
218 pub fn hidden_message(mut self, hidden_message: impl Into<String>) -> Self {
222 self.hidden_message = Some(hidden_message.into());
223 self
224 }
225
226 pub fn payload(mut self, payload: impl Into<String>) -> Self {
230 self.payload = Some(payload.into());
231 self
232 }
233
234 pub fn allow_comments(mut self, allow_comments: bool) -> Self {
238 self.allow_comments = Some(allow_comments);
239 self
240 }
241
242 pub fn allow_anonymous(mut self, allow_anonymous: bool) -> Self {
246 self.allow_anonymous = Some(allow_anonymous);
247 self
248 }
249
250 pub fn expires_in(mut self, expires_in: u32) -> Self {
254 self.expires_in = Some(expires_in);
255 self
256 }
257
258 fn transform<A2, C2, P2, U2>(self) -> CreateInvoiceParamsBuilder<A2, C2, P2, U2> {
259 CreateInvoiceParamsBuilder {
260 currency_type: self.currency_type,
261 asset: self.asset,
262 fiat: self.fiat,
263 accept_asset: self.accept_asset,
264 amount: self.amount,
265 description: self.description,
266 hidden_message: self.hidden_message,
267 paid_btn_name: self.paid_btn_name,
268 paid_btn_url: self.paid_btn_url,
269 swap_to: self.swap_to,
270 payload: self.payload,
271 allow_comments: self.allow_comments,
272 allow_anonymous: self.allow_anonymous,
273 expires_in: self.expires_in,
274 _state: PhantomData,
275 }
276 }
277}
278
279impl<A, C, P, U> FieldValidate for CreateInvoiceParamsBuilder<A, C, P, U> {
280 fn validate(&self) -> CryptoBotResult<()> {
281 if self.amount < Decimal::ZERO {
283 return Err(CryptoBotError::ValidationError {
284 kind: ValidationErrorKind::Range,
285 message: "Amount must be greater than 0".to_string(),
286 field: Some("amount".to_string()),
287 });
288 }
289
290 if let Some(desc) = &self.description {
292 if desc.chars().count() > 1024 {
293 return Err(CryptoBotError::ValidationError {
294 kind: ValidationErrorKind::Range,
295 message: "description too long".to_string(),
296 field: Some("description".to_string()),
297 });
298 }
299 }
300
301 if let Some(msg) = &self.hidden_message {
303 if msg.chars().count() > 2048 {
304 return Err(CryptoBotError::ValidationError {
305 kind: ValidationErrorKind::Range,
306 message: "hidden_message_too_long".to_string(),
307 field: Some("hidden_message".to_string()),
308 });
309 }
310 }
311
312 if let Some(payload) = &self.payload {
314 if payload.chars().count() > 4096 {
315 return Err(CryptoBotError::ValidationError {
316 kind: ValidationErrorKind::Range,
317 message: "payload_too_long".to_string(),
318 field: Some("payload".to_string()),
319 });
320 }
321 }
322
323 if let Some(expires_in) = &self.expires_in {
325 if !(&1..=&2678400).contains(&expires_in) {
326 return Err(CryptoBotError::ValidationError {
327 kind: ValidationErrorKind::Range,
328 message: "expires_in_invalid".to_string(),
329 field: Some("expires_in".to_string()),
330 });
331 }
332 }
333 Ok(())
334 }
335}
336
337impl CreateInvoiceParamsBuilder<Set, Set, Missing, Missing> {
338 pub async fn build(self, client: &CryptoBot) -> CryptoBotResult<CreateInvoiceParams> {
339 self.validate()?;
340
341 let exchange_rates = client.get_exchange_rates().await?;
342 let ctx = ValidationContext { exchange_rates };
343 self.validate_with_context(&ctx).await?;
344
345 Ok(CreateInvoiceParams {
346 currency_type: self.currency_type,
347 asset: self.asset,
348 fiat: self.fiat,
349 accept_asset: self.accept_asset,
350 amount: self.amount,
351 description: self.description,
352 hidden_message: self.hidden_message,
353 paid_btn_name: self.paid_btn_name,
354 paid_btn_url: self.paid_btn_url,
355 swap_to: self.swap_to,
356 payload: self.payload,
357 allow_comments: self.allow_comments,
358 allow_anonymous: self.allow_anonymous,
359 expires_in: self.expires_in,
360 })
361 }
362}
363
364impl CreateInvoiceParamsBuilder<Set, Set, Set, Set> {
365 pub async fn build(self, client: &CryptoBot) -> CryptoBotResult<CreateInvoiceParams> {
366 self.validate()?;
367
368 if let Some(url) = &self.paid_btn_url {
369 if !url.starts_with("https://") && !url.starts_with("http://") {
370 return Err(CryptoBotError::ValidationError {
371 kind: ValidationErrorKind::Format,
372 message: "paid_btn_url_invalid".to_string(),
373 field: Some("paid_btn_url".to_string()),
374 });
375 }
376 }
377
378 let rates = client.get_exchange_rates().await?;
379 let ctx = ValidationContext { exchange_rates: rates };
380 self.validate_with_context(&ctx).await?;
381
382 Ok(CreateInvoiceParams {
383 currency_type: self.currency_type,
384 asset: self.asset,
385 fiat: self.fiat,
386 accept_asset: self.accept_asset,
387 amount: self.amount,
388 description: self.description,
389 hidden_message: self.hidden_message,
390 paid_btn_name: self.paid_btn_name,
391 paid_btn_url: self.paid_btn_url,
392 swap_to: self.swap_to,
393 payload: self.payload,
394 allow_comments: self.allow_comments,
395 allow_anonymous: self.allow_anonymous,
396 expires_in: self.expires_in,
397 })
398 }
399}
400
401#[async_trait::async_trait]
402impl<C: Sync, P: Sync, U: Sync> ContextValidate for CreateInvoiceParamsBuilder<Set, C, P, U> {
403 async fn validate_with_context(&self, ctx: &ValidationContext) -> CryptoBotResult<()> {
404 if let Some(asset) = &self.asset {
405 println!("Validating amount");
406 validate_amount(&self.amount, asset, ctx).await?;
407 }
408 Ok(())
409 }
410}
411
412#[cfg(test)]
415mod tests {
416 use rust_decimal_macros::dec;
417
418 use super::*;
419 use crate::models::ExchangeRate;
420
421 fn ton_usd_context() -> ValidationContext {
422 ValidationContext {
423 exchange_rates: vec![ExchangeRate {
424 is_valid: true,
425 is_crypto: true,
426 is_fiat: false,
427 source: CryptoCurrencyCode::Ton,
428 target: FiatCurrencyCode::Usd,
429 rate: dec!(2),
430 }],
431 }
432 }
433
434 #[test]
435 fn test_get_invoices_params_builder() {
436 let params = GetInvoicesParamsBuilder::new().count(100).build().unwrap();
437 assert_eq!(params.count, Some(100));
438 assert_eq!(params.offset, None);
439 assert_eq!(params.invoice_ids, None);
440 assert_eq!(params.status, None);
441 assert_eq!(params.asset, None);
442 assert_eq!(params.fiat, None);
443 }
444
445 #[test]
446 fn test_get_invoices_params_builder_custom_config() {
447 let params = GetInvoicesParamsBuilder::new()
448 .asset(CryptoCurrencyCode::Ton)
449 .fiat(FiatCurrencyCode::Usd)
450 .status(InvoiceStatus::Paid)
451 .offset(2)
452 .build()
453 .unwrap();
454
455 assert_eq!(params.asset, Some(CryptoCurrencyCode::Ton));
456 assert_eq!(params.fiat, Some(FiatCurrencyCode::Usd));
457 assert_eq!(params.status, Some(InvoiceStatus::Paid));
458 assert_eq!(params.offset, Some(2));
459 }
460
461 #[test]
462 fn test_get_invoices_params_builder_invalid_count() {
463 let result = GetInvoicesParamsBuilder::new().count(1001).build();
464
465 assert!(matches!(
466 result,
467 Err(CryptoBotError::ValidationError {
468 kind: ValidationErrorKind::Range,
469 field: Some(field),
470 ..
471 }) if field == "count"
472 ));
473 }
474
475 #[tokio::test]
476 async fn test_create_invoice_params_builder() {
477 let client = CryptoBot::test_client();
478 let params = CreateInvoiceParamsBuilder::new()
479 .amount(Decimal::from(100))
480 .asset(CryptoCurrencyCode::Ton)
481 .build(&client)
482 .await
483 .unwrap();
484
485 assert_eq!(params.amount, Decimal::from(100));
486 assert_eq!(params.currency_type, Some(CurrencyType::Crypto));
487 assert_eq!(params.asset, Some(CryptoCurrencyCode::Ton));
488 assert_eq!(params.fiat, None);
489 assert_eq!(params.accept_asset, None);
490 assert_eq!(params.description, None);
491 assert_eq!(params.hidden_message, None);
492 }
493
494 #[tokio::test]
495 async fn test_create_invoice_params_builder_custom_config() {
496 let client = CryptoBot::test_client();
497 let params = CreateInvoiceParamsBuilder::default()
498 .amount(Decimal::from(100))
499 .asset(CryptoCurrencyCode::Ton)
500 .accept_asset(vec![CryptoCurrencyCode::Ton, CryptoCurrencyCode::Usdt])
501 .allow_comments(false)
502 .allow_anonymous(false)
503 .paid_btn_name(PayButtonName::ViewItem)
504 .paid_btn_url("https://example.com")
505 .description("test")
506 .hidden_message("test")
507 .payload("test")
508 .build(&client)
509 .await
510 .unwrap();
511
512 assert_eq!(
513 params.accept_asset,
514 Some(vec![CryptoCurrencyCode::Ton, CryptoCurrencyCode::Usdt])
515 );
516 assert_eq!(params.allow_comments, Some(false));
517 assert_eq!(params.allow_anonymous, Some(false));
518 assert_eq!(params.paid_btn_name, Some(PayButtonName::ViewItem));
519 assert_eq!(params.paid_btn_url, Some("https://example.com".to_string()));
520 assert_eq!(params.description, Some("test".to_string()));
521 assert_eq!(params.hidden_message, Some("test".to_string()));
522 assert_eq!(params.payload, Some("test".to_string()));
523 }
524
525 #[tokio::test]
526 async fn test_create_invoice_params_builder_invalid_amount() {
527 let client = CryptoBot::test_client();
528 let result = CreateInvoiceParamsBuilder::new()
529 .amount(Decimal::from(-100))
530 .asset(CryptoCurrencyCode::Ton)
531 .build(&client)
532 .await;
533
534 assert!(matches!(
535 result,
536 Err(CryptoBotError::ValidationError {
537 kind: ValidationErrorKind::Range,
538 field: Some(field),
539 ..
540 }) if field == "amount"
541 ));
542
543 let result = CreateInvoiceParamsBuilder::new()
544 .amount(dec!(0))
545 .asset(CryptoCurrencyCode::Ton)
546 .build(&client)
547 .await;
548
549 assert!(matches!(
550 result,
551 Err(CryptoBotError::ValidationError {
552 kind: ValidationErrorKind::Range,
553 field: Some(field),
554 ..
555 }) if field == "amount"
556 ));
557
558 let result = CreateInvoiceParamsBuilder::new()
559 .amount(dec!(10000))
560 .asset(CryptoCurrencyCode::Ton)
561 .build(&client)
562 .await;
563
564 println!("{:?}", result);
565
566 assert!(matches!(
567 result,
568 Err(CryptoBotError::ValidationError {
569 kind: ValidationErrorKind::Range,
570 field: Some(field),
571 ..
572 }) if field == "amount"
573 ));
574 }
575
576 #[test]
577 fn test_description_chars_count() {
578 let builder = CreateInvoiceParamsBuilder::new()
579 .amount(dec!(100))
580 .fiat(FiatCurrencyCode::Usd);
581
582 let desc = "a".repeat(1024);
583 assert_eq!(desc.chars().count(), 1024);
584 let valid_builder = builder.description(desc);
585 assert!(valid_builder.validate().is_ok());
586
587 let builder = CreateInvoiceParamsBuilder::new()
588 .amount(dec!(100))
589 .fiat(FiatCurrencyCode::Usd);
590 let multibyte_desc = "🦀".repeat(1024);
591 assert_eq!(multibyte_desc.chars().count(), 1024);
592 let valid_multibyte = builder.description(multibyte_desc);
593 assert!(valid_multibyte.validate().is_ok());
594
595 let builder = CreateInvoiceParamsBuilder::new()
596 .amount(dec!(100))
597 .fiat(FiatCurrencyCode::Usd);
598 let long_desc = "a".repeat(1025);
599 assert_eq!(long_desc.chars().count(), 1025);
600 let invalid_builder = builder.description(long_desc);
601
602 match invalid_builder.validate() {
603 Err(CryptoBotError::ValidationError { kind, message, field }) => {
604 assert_eq!(kind, ValidationErrorKind::Range);
605 assert_eq!(message, "description too long");
606 assert_eq!(field, Some("description".to_string()));
607 }
608 _ => panic!("Expected ValidationError"),
609 }
610 }
611
612 #[test]
613 fn test_hidden_message_chars_count_validation() {
614 let builder = CreateInvoiceParamsBuilder::new()
615 .amount(dec!(100))
616 .fiat(FiatCurrencyCode::Usd)
617 .hidden_message("a".repeat(2049));
618
619 let result = builder.validate();
620 assert!(matches!(
621 result,
622 Err(CryptoBotError::ValidationError {
623 kind: ValidationErrorKind::Range,
624 field: Some(field),
625 ..
626 }) if field == "hidden_message"
627 ));
628 }
629
630 #[test]
631 fn test_payload_chars_count_validation() {
632 let builder = CreateInvoiceParamsBuilder::new()
633 .amount(dec!(100))
634 .fiat(FiatCurrencyCode::Usd)
635 .payload("a".repeat(4097));
636
637 let result = builder.validate();
638 assert!(matches!(
639 result,
640 Err(CryptoBotError::ValidationError {
641 kind: ValidationErrorKind::Range,
642 field: Some(field),
643 ..
644 }) if field == "payload"
645 ));
646 }
647
648 #[test]
649 fn test_expires_in_range_validation() {
650 let builder = CreateInvoiceParamsBuilder::new()
651 .amount(dec!(100))
652 .fiat(FiatCurrencyCode::Usd)
653 .expires_in(2_678_401);
654
655 let result = builder.validate();
656 assert!(matches!(
657 result,
658 Err(CryptoBotError::ValidationError {
659 kind: ValidationErrorKind::Range,
660 field: Some(field),
661 ..
662 }) if field == "expires_in"
663 ));
664 }
665
666 #[tokio::test]
667 async fn test_create_invoice_params_builder_invalid_hidden_message() {
668 let client = CryptoBot::test_client();
669 let result = CreateInvoiceParamsBuilder::new()
670 .amount(dec!(10.0))
671 .asset(CryptoCurrencyCode::Ton)
672 .hidden_message("a".repeat(2049))
673 .build(&client)
674 .await;
675
676 assert!(matches!(
677 result,
678 Err(CryptoBotError::ValidationError {
679 kind: ValidationErrorKind::Range,
680 field: Some(field),
681 ..
682 }) if field == "hidden_message"
683 ));
684 }
685
686 #[tokio::test]
687 async fn test_create_invoice_params_builder_invalid_payload() {
688 let client = CryptoBot::test_client();
689 let result = CreateInvoiceParamsBuilder::new()
690 .amount(dec!(10.0))
691 .fiat(FiatCurrencyCode::Usd)
692 .payload("a".repeat(4097))
693 .build(&client)
694 .await;
695
696 assert!(matches!(
697 result,
698 Err(CryptoBotError::ValidationError {
699 kind: ValidationErrorKind::Range,
700 field: Some(field),
701 ..
702 }) if field == "payload"
703 ));
704 }
705
706 #[tokio::test]
707 async fn test_create_invoice_params_builder_invalid_expires_in() {
708 let client = CryptoBot::test_client();
709 let result = CreateInvoiceParamsBuilder::new()
710 .amount(dec!(10.0))
711 .fiat(FiatCurrencyCode::Usd)
712 .expires_in(0)
713 .build(&client)
714 .await;
715
716 assert!(matches!(
717 result,
718 Err(CryptoBotError::ValidationError {
719 kind: ValidationErrorKind::Range,
720 field: Some(field),
721 ..
722 }) if field == "expires_in"
723 ));
724 }
725
726 #[tokio::test]
727 async fn test_create_invoice_params_builder_invalid_paid_btn_url() {
728 let client = CryptoBot::test_client();
729 let result = CreateInvoiceParamsBuilder::new()
730 .amount(dec!(10.0))
731 .fiat(FiatCurrencyCode::Usd)
732 .paid_btn_name(PayButtonName::OpenBot)
733 .paid_btn_url("invalid_url")
734 .build(&client)
735 .await;
736
737 assert!(matches!(
738 result,
739 Err(CryptoBotError::ValidationError {
740 kind: ValidationErrorKind::Format,
741 field: Some(field),
742 ..
743 }) if field == "paid_btn_url"
744 ));
745 }
746
747 #[tokio::test]
748 async fn test_validate_with_context_success() {
749 let ctx = ton_usd_context();
750 let builder = CreateInvoiceParamsBuilder::new()
751 .amount(dec!(10))
752 .asset(CryptoCurrencyCode::Ton);
753
754 assert!(builder.validate_with_context(&ctx).await.is_ok());
755 }
756
757 #[tokio::test]
758 async fn test_validate_with_context_missing_exchange_rate() {
759 let ctx = ValidationContext { exchange_rates: vec![] };
760 let builder = CreateInvoiceParamsBuilder::new()
761 .amount(dec!(10))
762 .asset(CryptoCurrencyCode::Ton);
763
764 let result = builder.validate_with_context(&ctx).await;
765
766 assert!(matches!(
767 result,
768 Err(CryptoBotError::ValidationError {
769 kind: ValidationErrorKind::Missing,
770 field: Some(field),
771 ..
772 }) if field == "exchange_rate"
773 ));
774 }
775}