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
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}