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
420 #[test]
421 fn test_get_invoices_params_builder() {
422 let params = GetInvoicesParamsBuilder::new().count(100).build().unwrap();
423 assert_eq!(params.count, Some(100));
424 assert_eq!(params.offset, None);
425 assert_eq!(params.invoice_ids, None);
426 assert_eq!(params.status, None);
427 assert_eq!(params.asset, None);
428 assert_eq!(params.fiat, None);
429 }
430
431 #[test]
432 fn test_get_invoices_params_builder_custom_config() {
433 let params = GetInvoicesParamsBuilder::new()
434 .asset(CryptoCurrencyCode::Ton)
435 .fiat(FiatCurrencyCode::Usd)
436 .status(InvoiceStatus::Paid)
437 .offset(2)
438 .build()
439 .unwrap();
440
441 assert_eq!(params.asset, Some(CryptoCurrencyCode::Ton));
442 assert_eq!(params.fiat, Some(FiatCurrencyCode::Usd));
443 assert_eq!(params.status, Some(InvoiceStatus::Paid));
444 assert_eq!(params.offset, Some(2));
445 }
446
447 #[test]
448 fn test_get_invoices_params_builder_invalid_count() {
449 let result = GetInvoicesParamsBuilder::new().count(1001).build();
450
451 assert!(matches!(
452 result,
453 Err(CryptoBotError::ValidationError {
454 kind: ValidationErrorKind::Range,
455 field: Some(field),
456 ..
457 }) if field == "count"
458 ));
459 }
460
461 #[tokio::test]
462 async fn test_create_invoice_params_builder() {
463 let client = CryptoBot::test_client();
464 let params = CreateInvoiceParamsBuilder::new()
465 .amount(Decimal::from(100))
466 .asset(CryptoCurrencyCode::Ton)
467 .build(&client)
468 .await
469 .unwrap();
470
471 assert_eq!(params.amount, Decimal::from(100));
472 assert_eq!(params.currency_type, Some(CurrencyType::Crypto));
473 assert_eq!(params.asset, Some(CryptoCurrencyCode::Ton));
474 assert_eq!(params.fiat, None);
475 assert_eq!(params.accept_asset, None);
476 assert_eq!(params.description, None);
477 assert_eq!(params.hidden_message, None);
478 }
479
480 #[tokio::test]
481 async fn test_create_invoice_params_builder_custom_config() {
482 let client = CryptoBot::test_client();
483 let params = CreateInvoiceParamsBuilder::default()
484 .amount(Decimal::from(100))
485 .asset(CryptoCurrencyCode::Ton)
486 .accept_asset(vec![CryptoCurrencyCode::Ton, CryptoCurrencyCode::Usdt])
487 .allow_comments(false)
488 .allow_anonymous(false)
489 .paid_btn_name(PayButtonName::ViewItem)
490 .paid_btn_url("https://example.com")
491 .description("test")
492 .hidden_message("test")
493 .payload("test")
494 .build(&client)
495 .await
496 .unwrap();
497
498 assert_eq!(
499 params.accept_asset,
500 Some(vec![CryptoCurrencyCode::Ton, CryptoCurrencyCode::Usdt])
501 );
502 assert_eq!(params.allow_comments, Some(false));
503 assert_eq!(params.allow_anonymous, Some(false));
504 assert_eq!(params.paid_btn_name, Some(PayButtonName::ViewItem));
505 assert_eq!(params.paid_btn_url, Some("https://example.com".to_string()));
506 assert_eq!(params.description, Some("test".to_string()));
507 assert_eq!(params.hidden_message, Some("test".to_string()));
508 assert_eq!(params.payload, Some("test".to_string()));
509 }
510
511 #[tokio::test]
512 async fn test_create_invoice_params_builder_invalid_amount() {
513 let client = CryptoBot::test_client();
514 let result = CreateInvoiceParamsBuilder::new()
515 .amount(Decimal::from(-100))
516 .asset(CryptoCurrencyCode::Ton)
517 .build(&client)
518 .await;
519
520 assert!(matches!(
521 result,
522 Err(CryptoBotError::ValidationError {
523 kind: ValidationErrorKind::Range,
524 field: Some(field),
525 ..
526 }) if field == "amount"
527 ));
528
529 let result = CreateInvoiceParamsBuilder::new()
530 .amount(dec!(0))
531 .asset(CryptoCurrencyCode::Ton)
532 .build(&client)
533 .await;
534
535 assert!(matches!(
536 result,
537 Err(CryptoBotError::ValidationError {
538 kind: ValidationErrorKind::Range,
539 field: Some(field),
540 ..
541 }) if field == "amount"
542 ));
543
544 let result = CreateInvoiceParamsBuilder::new()
545 .amount(dec!(10000))
546 .asset(CryptoCurrencyCode::Ton)
547 .build(&client)
548 .await;
549
550 println!("{:?}", result);
551
552 assert!(matches!(
553 result,
554 Err(CryptoBotError::ValidationError {
555 kind: ValidationErrorKind::Range,
556 field: Some(field),
557 ..
558 }) if field == "amount"
559 ));
560 }
561
562 #[test]
563 fn test_description_chars_count() {
564 let builder = CreateInvoiceParamsBuilder::new()
565 .amount(dec!(100))
566 .fiat(FiatCurrencyCode::Usd);
567
568 let desc = "a".repeat(1024);
569 assert_eq!(desc.chars().count(), 1024);
570 let valid_builder = builder.description(desc);
571 assert!(valid_builder.validate().is_ok());
572
573 let builder = CreateInvoiceParamsBuilder::new()
574 .amount(dec!(100))
575 .fiat(FiatCurrencyCode::Usd);
576 let multibyte_desc = "🦀".repeat(1024);
577 assert_eq!(multibyte_desc.chars().count(), 1024);
578 let valid_multibyte = builder.description(multibyte_desc);
579 assert!(valid_multibyte.validate().is_ok());
580
581 let builder = CreateInvoiceParamsBuilder::new()
582 .amount(dec!(100))
583 .fiat(FiatCurrencyCode::Usd);
584 let long_desc = "a".repeat(1025);
585 assert_eq!(long_desc.chars().count(), 1025);
586 let invalid_builder = builder.description(long_desc);
587
588 match invalid_builder.validate() {
589 Err(CryptoBotError::ValidationError { kind, message, field }) => {
590 assert_eq!(kind, ValidationErrorKind::Range);
591 assert_eq!(message, "description too long");
592 assert_eq!(field, Some("description".to_string()));
593 }
594 _ => panic!("Expected ValidationError"),
595 }
596 }
597
598 #[tokio::test]
599 async fn test_create_invoice_params_builder_invalid_hidden_message() {
600 let client = CryptoBot::test_client();
601 let result = CreateInvoiceParamsBuilder::new()
602 .amount(dec!(10.0))
603 .asset(CryptoCurrencyCode::Ton)
604 .hidden_message("a".repeat(2049))
605 .build(&client)
606 .await;
607
608 assert!(matches!(
609 result,
610 Err(CryptoBotError::ValidationError {
611 kind: ValidationErrorKind::Range,
612 field: Some(field),
613 ..
614 }) if field == "hidden_message"
615 ));
616 }
617
618 #[tokio::test]
619 async fn test_create_invoice_params_builder_invalid_payload() {
620 let client = CryptoBot::test_client();
621 let result = CreateInvoiceParamsBuilder::new()
622 .amount(dec!(10.0))
623 .fiat(FiatCurrencyCode::Usd)
624 .payload("a".repeat(4097))
625 .build(&client)
626 .await;
627
628 assert!(matches!(
629 result,
630 Err(CryptoBotError::ValidationError {
631 kind: ValidationErrorKind::Range,
632 field: Some(field),
633 ..
634 }) if field == "payload"
635 ));
636 }
637
638 #[tokio::test]
639 async fn test_create_invoice_params_builder_invalid_expires_in() {
640 let client = CryptoBot::test_client();
641 let result = CreateInvoiceParamsBuilder::new()
642 .amount(dec!(10.0))
643 .fiat(FiatCurrencyCode::Usd)
644 .expires_in(0)
645 .build(&client)
646 .await;
647
648 assert!(matches!(
649 result,
650 Err(CryptoBotError::ValidationError {
651 kind: ValidationErrorKind::Range,
652 field: Some(field),
653 ..
654 }) if field == "expires_in"
655 ));
656 }
657
658 #[tokio::test]
659 async fn test_create_invoice_params_builder_invalid_paid_btn_url() {
660 let client = CryptoBot::test_client();
661 let result = CreateInvoiceParamsBuilder::new()
662 .amount(dec!(10.0))
663 .fiat(FiatCurrencyCode::Usd)
664 .paid_btn_name(PayButtonName::OpenBot)
665 .paid_btn_url("invalid_url")
666 .build(&client)
667 .await;
668
669 assert!(matches!(
670 result,
671 Err(CryptoBotError::ValidationError {
672 kind: ValidationErrorKind::Format,
673 field: Some(field),
674 ..
675 }) if field == "paid_btn_url"
676 ));
677 }
678}