crypto_pay_api/models/invoice/
builder.rs

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/* #region GetInvoicesParamsBuilder */
16
17#[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    /// Create a new `GetInvoicesParamsBuilder` with default values.
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Set the asset for the invoices.
34    /// Optional. Defaults to all currencies.
35    pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
36        self.asset = Some(asset);
37        self
38    }
39
40    /// Set the fiat for the invoices.
41    /// Optional. Defaults to all currencies.
42    pub fn fiat(mut self, fiat: FiatCurrencyCode) -> Self {
43        self.fiat = Some(fiat);
44        self
45    }
46
47    /// Set the invoice IDs for the invoices.
48    pub fn invoice_ids(mut self, invoice_ids: Vec<u64>) -> Self {
49        self.invoice_ids = Some(invoice_ids);
50        self
51    }
52
53    /// Set the status for the invoices.
54    /// Optional. Defaults to all statuses.
55    pub fn status(mut self, status: InvoiceStatus) -> Self {
56        self.status = Some(status);
57        self
58    }
59
60    /// Set the offset for the invoices.
61    /// Optional. Offset needed to return a specific subset of invoices.
62    /// Defaults to 0.
63    pub fn offset(mut self, offset: u32) -> Self {
64        self.offset = Some(offset);
65        self
66    }
67
68    /// Set the count for the invoices.
69    /// Optional. Number of invoices to be returned. Values between 1-1000 are accepted.
70    /// Defaults to 100.
71    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/* #endregion */
101
102/* #region CreateInvoiceParamsBuilder */
103
104// A - Asset, C - CurrencyType (Crypto or Fiat), P - PayButtonName, U - PayButtonUrl
105#[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    /// Create a new `CreateInvoiceParamsBuilder` with default values.
126    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    /// Set the amount for the invoice.
155    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    /// Set the asset for the invoice, if the currency type is crypto.
163    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    /// Set the fiat for the invoice, if the currency type is fiat.
170    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    /// Set the paid button name for the invoice.
179    /// Optional. Label of the button which will be presented to a user after the invoice is paid.
180    /// Supported names:
181    /// viewItem – "View Item",
182    /// openChannel – "View Channel",
183    /// openBot – "Open Bot",
184    /// callback – "Return to the bot"  
185    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    /// Set the paid button URL for the invoice.
193    /// Optional. Required if paid_btn_name is specified. URL opened using the button which will be presented to a user after the invoice is paid.
194    /// You can set any callback link (for example, a success link or link to homepage).
195    /// Starts with https or http.
196    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    /// Set the accepted assets for the invoice.
204    /// Optional. Defaults to all currencies.
205    pub fn accept_asset(mut self, accept_asset: Vec<CryptoCurrencyCode>) -> Self {
206        self.accept_asset = Some(accept_asset);
207        self
208    }
209
210    /// Set the description for the invoice.
211    /// Optional. Description for the invoice. User will see this description when they pay the invoice.
212    /// Up to 1024 characters.
213    pub fn description(mut self, description: impl Into<String>) -> Self {
214        self.description = Some(description.into());
215        self
216    }
217
218    /// Set the hidden message for the invoice.
219    /// Optional. Text of the message which will be presented to a user after the invoice is paid.
220    /// Up to 2048 characters.
221    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    /// Set the payload for the invoice.
227    /// Optional. Any data you want to attach to the invoice (for example, user ID, payment ID, ect).
228    /// Up to 4kb.
229    pub fn payload(mut self, payload: impl Into<String>) -> Self {
230        self.payload = Some(payload.into());
231        self
232    }
233
234    /// Set the allow comments for the invoice.
235    /// Optional. Allow a user to add a comment to the payment.
236    /// Defaults to true.
237    pub fn allow_comments(mut self, allow_comments: bool) -> Self {
238        self.allow_comments = Some(allow_comments);
239        self
240    }
241
242    /// Set the allow anonymous for the invoice.
243    /// Optional. Allow a user to pay the invoice anonymously.
244    /// Defaults to true.
245    pub fn allow_anonymous(mut self, allow_anonymous: bool) -> Self {
246        self.allow_anonymous = Some(allow_anonymous);
247        self
248    }
249
250    /// Set the expiration time for the invoice.
251    /// Optional. You can set a payment time limit for the invoice in seconds.
252    /// Values between 1-2678400 are accepted.
253    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        // Amount > 0
282        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        // Description <= 1024 chars
291        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        // Hidden message <= 2048 chars
302        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        // Payload up to 4kb
313        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        // ExpiresIn between 1 and 2678400 seconds
324        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/* #endregion */
413
414#[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}