crypto_pay_api/api/
invoice.rs

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, CreateInvoiceParams, CryptoCurrencyCode, CurrencyType, DeleteInvoiceParams,
12        FiatCurrencyCode, GetInvoicesParams, GetInvoicesResponse, Invoice, InvoiceStatus, Method, Missing,
13        PayButtonName, Set, SwapToAssets,
14    },
15    validation::{validate_amount, validate_count, ContextValidate, FieldValidate, ValidationContext},
16};
17
18use super::ExchangeRateAPI;
19use super::InvoiceAPI;
20
21pub struct DeleteInvoiceBuilder<'a> {
22    client: &'a CryptoBot,
23    invoice_id: u64,
24}
25
26impl<'a> DeleteInvoiceBuilder<'a> {
27    pub fn new(client: &'a CryptoBot, invoice_id: u64) -> Self {
28        Self { client, invoice_id }
29    }
30
31    /// Executes the request to delete the invoice
32    pub async fn execute(self) -> CryptoBotResult<bool> {
33        let params = DeleteInvoiceParams {
34            invoice_id: self.invoice_id,
35        };
36        self.client
37            .make_request(
38                &APIMethod {
39                    endpoint: APIEndpoint::DeleteInvoice,
40                    method: Method::DELETE,
41                },
42                Some(&params),
43            )
44            .await
45    }
46}
47
48pub struct GetInvoicesBuilder<'a> {
49    client: &'a CryptoBot,
50    params: GetInvoicesParams,
51}
52
53impl<'a> GetInvoicesBuilder<'a> {
54    pub fn new(client: &'a CryptoBot) -> Self {
55        Self {
56            client,
57            params: GetInvoicesParams::default(),
58        }
59    }
60
61    /// Set the asset for the invoices.
62    /// Optional. Defaults to all currencies.
63    pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
64        self.params.asset = Some(asset);
65        self
66    }
67
68    /// Set the fiat for the invoices.
69    /// Optional. Defaults to all currencies.
70    pub fn fiat(mut self, fiat: FiatCurrencyCode) -> Self {
71        self.params.fiat = Some(fiat);
72        self
73    }
74
75    /// Set the invoice IDs for the invoices.
76    pub fn invoice_ids(mut self, invoice_ids: Vec<u64>) -> Self {
77        self.params.invoice_ids = Some(invoice_ids);
78        self
79    }
80
81    /// Set the status for the invoices.
82    /// Optional. Defaults to all statuses.
83    pub fn status(mut self, status: InvoiceStatus) -> Self {
84        self.params.status = Some(status);
85        self
86    }
87
88    /// Set the offset for the invoices.
89    /// Optional. Offset needed to return a specific subset of invoices.
90    /// Defaults to 0.
91    pub fn offset(mut self, offset: u32) -> Self {
92        self.params.offset = Some(offset);
93        self
94    }
95
96    /// Set the count for the invoices.
97    /// Optional. Number of invoices to be returned. Values between 1-1000 are accepted.
98    /// Defaults to 100.
99    pub fn count(mut self, count: u16) -> Self {
100        self.params.count = Some(count);
101        self
102    }
103
104    /// Executes the request to get invoices
105    pub async fn execute(self) -> CryptoBotResult<Vec<Invoice>> {
106        if let Some(count) = self.params.count {
107            validate_count(count)?;
108        }
109
110        let response: GetInvoicesResponse = self
111            .client
112            .make_request(
113                &APIMethod {
114                    endpoint: APIEndpoint::GetInvoices,
115                    method: Method::GET,
116                },
117                Some(&self.params),
118            )
119            .await?;
120
121        Ok(response.items)
122    }
123}
124
125pub struct CreateInvoiceBuilder<'a, A = Missing, C = Missing, P = Missing, U = Missing> {
126    client: &'a CryptoBot,
127    currency_type: Option<CurrencyType>,
128    asset: Option<CryptoCurrencyCode>,
129    fiat: Option<FiatCurrencyCode>,
130    accept_asset: Option<Vec<CryptoCurrencyCode>>,
131    amount: Decimal,
132    description: Option<String>,
133    hidden_message: Option<String>,
134    paid_btn_name: Option<PayButtonName>,
135    paid_btn_url: Option<String>,
136    swap_to: Option<SwapToAssets>,
137    payload: Option<String>,
138    allow_comments: Option<bool>,
139    allow_anonymous: Option<bool>,
140    expires_in: Option<u32>,
141    _state: PhantomData<(A, C, P, U)>,
142}
143
144impl<'a> CreateInvoiceBuilder<'a, Missing, Missing, Missing, Missing> {
145    pub fn new(client: &'a CryptoBot) -> Self {
146        Self {
147            client,
148            currency_type: Some(CurrencyType::Crypto),
149            asset: None,
150            fiat: None,
151            accept_asset: None,
152            amount: Decimal::ZERO,
153            description: None,
154            hidden_message: None,
155            paid_btn_name: None,
156            paid_btn_url: None,
157            swap_to: None,
158            payload: None,
159            allow_comments: None,
160            allow_anonymous: None,
161            expires_in: None,
162            _state: PhantomData,
163        }
164    }
165}
166
167impl<'a, C, P, U> CreateInvoiceBuilder<'a, Missing, C, P, U> {
168    /// Set the amount for the invoice.
169    pub fn amount(mut self, amount: impl IntoDecimal) -> CreateInvoiceBuilder<'a, Set, C, P, U> {
170        self.amount = amount.into_decimal();
171        self.transform()
172    }
173}
174
175impl<'a, A, P, U> CreateInvoiceBuilder<'a, A, Missing, P, U> {
176    /// Set the asset for the invoice, if the currency type is crypto.
177    pub fn asset(mut self, asset: CryptoCurrencyCode) -> CreateInvoiceBuilder<'a, A, Set, P, U> {
178        self.currency_type = Some(CurrencyType::Crypto);
179        self.asset = Some(asset);
180        self.transform()
181    }
182
183    /// Set the fiat for the invoice, if the currency type is fiat.
184    pub fn fiat(mut self, fiat: FiatCurrencyCode) -> CreateInvoiceBuilder<'a, A, Set, P, U> {
185        self.currency_type = Some(CurrencyType::Fiat);
186        self.fiat = Some(fiat);
187        self.transform()
188    }
189}
190
191impl<'a, A, C, U> CreateInvoiceBuilder<'a, A, C, Missing, U> {
192    /// Set the paid button name for the invoice.
193    pub fn paid_btn_name(mut self, paid_btn_name: PayButtonName) -> CreateInvoiceBuilder<'a, A, C, Set, U> {
194        self.paid_btn_name = Some(paid_btn_name);
195        self.transform()
196    }
197}
198
199impl<'a, A, C> CreateInvoiceBuilder<'a, A, C, Set, Missing> {
200    /// Set the paid button URL for the invoice.
201    pub fn paid_btn_url(mut self, paid_btn_url: impl Into<String>) -> CreateInvoiceBuilder<'a, A, C, Set, Set> {
202        self.paid_btn_url = Some(paid_btn_url.into());
203        self.transform()
204    }
205}
206
207impl<'a, A, C, P, U> CreateInvoiceBuilder<'a, A, C, P, U> {
208    /// Set the accepted assets for the invoice.
209    pub fn accept_asset(mut self, accept_asset: Vec<CryptoCurrencyCode>) -> Self {
210        self.accept_asset = Some(accept_asset);
211        self
212    }
213
214    /// Set the description for the invoice.
215    pub fn description(mut self, description: impl Into<String>) -> Self {
216        self.description = Some(description.into());
217        self
218    }
219
220    /// Set the hidden message for the invoice.
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    pub fn payload(mut self, payload: impl Into<String>) -> Self {
228        self.payload = Some(payload.into());
229        self
230    }
231
232    /// Set the allow comments for the invoice.
233    pub fn allow_comments(mut self, allow_comments: bool) -> Self {
234        self.allow_comments = Some(allow_comments);
235        self
236    }
237
238    /// Set the allow anonymous for the invoice.
239    pub fn allow_anonymous(mut self, allow_anonymous: bool) -> Self {
240        self.allow_anonymous = Some(allow_anonymous);
241        self
242    }
243
244    /// Set the expiration time for the invoice.
245    pub fn expires_in(mut self, expires_in: u32) -> Self {
246        self.expires_in = Some(expires_in);
247        self
248    }
249
250    fn transform<A2, C2, P2, U2>(self) -> CreateInvoiceBuilder<'a, A2, C2, P2, U2> {
251        CreateInvoiceBuilder {
252            client: self.client,
253            currency_type: self.currency_type,
254            asset: self.asset,
255            fiat: self.fiat,
256            accept_asset: self.accept_asset,
257            amount: self.amount,
258            description: self.description,
259            hidden_message: self.hidden_message,
260            paid_btn_name: self.paid_btn_name,
261            paid_btn_url: self.paid_btn_url,
262            swap_to: self.swap_to,
263            payload: self.payload,
264            allow_comments: self.allow_comments,
265            allow_anonymous: self.allow_anonymous,
266            expires_in: self.expires_in,
267            _state: PhantomData,
268        }
269    }
270}
271
272impl<'a, A, C, P, U> FieldValidate for CreateInvoiceBuilder<'a, A, C, P, U> {
273    fn validate(&self) -> CryptoBotResult<()> {
274        if self.amount <= Decimal::ZERO {
275            return Err(CryptoBotError::ValidationError {
276                kind: ValidationErrorKind::Range,
277                message: "Amount must be greater than 0".to_string(),
278                field: Some("amount".to_string()),
279            });
280        }
281
282        if let Some(desc) = &self.description {
283            if desc.chars().count() > 1024 {
284                return Err(CryptoBotError::ValidationError {
285                    kind: ValidationErrorKind::Range,
286                    message: "description too long".to_string(),
287                    field: Some("description".to_string()),
288                });
289            }
290        }
291
292        if let Some(msg) = &self.hidden_message {
293            if msg.chars().count() > 2048 {
294                return Err(CryptoBotError::ValidationError {
295                    kind: ValidationErrorKind::Range,
296                    message: "hidden_message_too_long".to_string(),
297                    field: Some("hidden_message".to_string()),
298                });
299            }
300        }
301
302        if let Some(payload) = &self.payload {
303            if payload.chars().count() > 4096 {
304                return Err(CryptoBotError::ValidationError {
305                    kind: ValidationErrorKind::Range,
306                    message: "payload_too_long".to_string(),
307                    field: Some("payload".to_string()),
308                });
309            }
310        }
311
312        if let Some(expires_in) = &self.expires_in {
313            if !(1..=2_678_400u32).contains(expires_in) {
314                return Err(CryptoBotError::ValidationError {
315                    kind: ValidationErrorKind::Range,
316                    message: "expires_in_invalid".to_string(),
317                    field: Some("expires_in".to_string()),
318                });
319            }
320        }
321        Ok(())
322    }
323}
324
325#[async_trait]
326impl<'a, C: Sync, P: Sync, U: Sync> ContextValidate for CreateInvoiceBuilder<'a, Set, C, P, U> {
327    async fn validate_with_context(&self, ctx: &ValidationContext) -> CryptoBotResult<()> {
328        if let Some(asset) = &self.asset {
329            validate_amount(&self.amount, asset, ctx).await?;
330        }
331        Ok(())
332    }
333}
334
335impl<'a> CreateInvoiceBuilder<'a, Set, Set, Missing, Missing> {
336    /// Executes the request to create the invoice
337    pub async fn execute(self) -> CryptoBotResult<Invoice> {
338        self.validate()?;
339
340        let exchange_rates = self.client.get_exchange_rates().execute().await?;
341        let ctx = ValidationContext { exchange_rates };
342        self.validate_with_context(&ctx).await?;
343
344        let params = CreateInvoiceParams {
345            currency_type: self.currency_type,
346            asset: self.asset,
347            fiat: self.fiat,
348            accept_asset: self.accept_asset,
349            amount: self.amount,
350            description: self.description,
351            hidden_message: self.hidden_message,
352            paid_btn_name: self.paid_btn_name,
353            paid_btn_url: self.paid_btn_url,
354            swap_to: self.swap_to,
355            payload: self.payload,
356            allow_comments: self.allow_comments,
357            allow_anonymous: self.allow_anonymous,
358            expires_in: self.expires_in,
359        };
360        self.client
361            .make_request(
362                &APIMethod {
363                    endpoint: APIEndpoint::CreateInvoice,
364                    method: Method::POST,
365                },
366                Some(&params),
367            )
368            .await
369    }
370}
371
372impl<'a> CreateInvoiceBuilder<'a, Set, Set, Set, Set> {
373    /// Executes the request to create the invoice
374    pub async fn execute(self) -> CryptoBotResult<Invoice> {
375        self.validate()?;
376
377        if let Some(url) = &self.paid_btn_url {
378            if !url.starts_with("https://") && !url.starts_with("http://") {
379                return Err(CryptoBotError::ValidationError {
380                    kind: ValidationErrorKind::Format,
381                    message: "paid_btn_url_invalid".to_string(),
382                    field: Some("paid_btn_url".to_string()),
383                });
384            }
385        }
386
387        let exchange_rates = self.client.get_exchange_rates().execute().await?;
388        let ctx = ValidationContext { exchange_rates };
389        self.validate_with_context(&ctx).await?;
390
391        let params = CreateInvoiceParams {
392            currency_type: self.currency_type,
393            asset: self.asset,
394            fiat: self.fiat,
395            accept_asset: self.accept_asset,
396            amount: self.amount,
397            description: self.description,
398            hidden_message: self.hidden_message,
399            paid_btn_name: self.paid_btn_name,
400            paid_btn_url: self.paid_btn_url,
401            swap_to: self.swap_to,
402            payload: self.payload,
403            allow_comments: self.allow_comments,
404            allow_anonymous: self.allow_anonymous,
405            expires_in: self.expires_in,
406        };
407
408        self.client
409            .make_request(
410                &APIMethod {
411                    endpoint: APIEndpoint::CreateInvoice,
412                    method: Method::POST,
413                },
414                Some(&params),
415            )
416            .await
417    }
418}
419
420#[async_trait]
421impl InvoiceAPI for CryptoBot {
422    /// Creates a new cryptocurrency invoice
423    ///
424    /// An invoice is a request for cryptocurrency payment with a specific amount
425    /// and currency. Once created, the invoice can be paid by any user.
426    ///
427    /// # Returns
428    /// * `CreateInvoiceBuilder` - A builder to construct the invoice parameters
429    fn create_invoice(&self) -> CreateInvoiceBuilder<'_> {
430        CreateInvoiceBuilder::new(self)
431    }
432
433    fn delete_invoice(&self, invoice_id: u64) -> DeleteInvoiceBuilder<'_> {
434        DeleteInvoiceBuilder::new(self, invoice_id)
435    }
436
437    /// Gets a list of invoices with optional filtering
438    ///
439    /// Retrieves all invoices matching the specified filter parameters.
440    /// If no parameters are provided, returns all invoices.
441    ///
442    /// # Returns
443    /// * `GetInvoicesBuilder` - A builder to construct the filter parameters
444    fn get_invoices(&self) -> GetInvoicesBuilder<'_> {
445        GetInvoicesBuilder::new(self)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use futures::executor::block_on;
452    use mockito::{Matcher, Mock};
453    use rust_decimal_macros::dec;
454    use serde_json::json;
455
456    use super::*;
457    use crate::models::{CryptoCurrencyCode, PayButtonName, SwapToAssets};
458    use crate::utils::test_utils::TestContext;
459
460    impl TestContext {
461        pub fn mock_create_invoice_response(&mut self) -> Mock {
462            self.server
463                .mock("POST", "/createInvoice")
464                .with_header("content-type", "application/json")
465                .with_header("Crypto-Pay-API-Token", "test_token")
466                .with_body(
467                    json!({
468                        "ok": true,
469                        "result": {
470                            "invoice_id": 528890,
471                            "hash": "IVDoTcNBYEfk",
472                            "currency_type": "crypto",
473                            "asset": "TON",
474                            "amount": "10.5",
475                            "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
476                            "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
477                            "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
478                            "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
479                            "description": "Test invoice",
480                            "status": "active",
481                            "created_at": "2025-02-08T12:11:01.341Z",
482                            "allow_comments": true,
483                            "allow_anonymous": true
484                        }
485                    })
486                    .to_string(),
487                )
488                .create()
489        }
490
491        pub fn mock_get_invoices_response(&mut self) -> Mock {
492            self.server
493                .mock("GET", "/getInvoices")
494                .with_header("content-type", "application/json")
495                .with_header("Crypto-Pay-API-Token", "test_token")
496                .with_body(json!({
497                    "ok": true,
498                    "result": {
499                        "items": [
500                            {
501                                "invoice_id": 528890,
502                                "hash": "IVDoTcNBYEfk",
503                                "currency_type": "crypto",
504                                "asset": "TON",
505                                "amount": "10.5",
506                                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
507                                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
508                                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
509                                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
510                                "description": "Test invoice",
511                                "status": "active",
512                                "created_at": "2025-02-08T12:11:01.341Z",
513                                "allow_comments": true,
514                                "allow_anonymous": true
515                            },
516                        ]
517                    }
518                })
519                .to_string(),
520            )
521            .create()
522        }
523
524        pub fn mock_get_invoices_response_with_invoice_ids(&mut self) -> Mock {
525            self.server
526                .mock("GET", "/getInvoices")
527                .match_body(json!({ "invoice_ids": "530195"}).to_string().as_str())
528                .with_header("content-type", "application/json")
529                .with_header("Crypto-Pay-API-Token", "test_token")
530                .with_body(json!({
531                    "ok": true,
532                    "result": {
533                        "items": [
534                            {
535                                "invoice_id": 530195,
536                                "hash": "IVcKhSGh244v",
537                                "currency_type": "crypto",
538                                "asset": "BTC",
539                                "amount": "0.5",
540                                "pay_url": "https://t.me/CryptoTestnetBot?start=IVcKhSGh244v",
541                                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVcKhSGh244v",
542                                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVcKhSGh244v",
543                                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVcKhSGh244v",
544                                "status": "active",
545                                "created_at": "2025-02-09T03:46:07.811Z",
546                                "allow_comments": true,
547                                "allow_anonymous": true
548                            }
549                        ]
550                    }
551                })
552                .to_string(),
553            )
554            .create()
555        }
556
557        pub fn mock_delete_invoice_response(&mut self) -> Mock {
558            self.server
559                .mock("DELETE", "/deleteInvoice")
560                .match_body(Matcher::JsonString(
561                    json!({
562                        "invoice_id": 528890
563                    })
564                    .to_string(),
565                ))
566                .with_header("content-type", "application/json")
567                .with_header("Crypto-Pay-API-Token", "test_token")
568                .with_body(
569                    json!({
570                        "ok": true,
571                        "result": true
572                    })
573                    .to_string(),
574                )
575                .create()
576        }
577
578        pub fn mock_create_invoice_with_accept_asset_response(&mut self) -> Mock {
579            self.server
580                .mock("POST", "/createInvoice")
581                .match_body(Matcher::JsonString(
582                    json!({
583                        "currency_type": "crypto",
584                        "asset": "TON",
585                        "amount": "2",
586                        "accept_asset": ["TON", "USDT"],
587                        "payload": "payload",
588                        "hidden_message": "Hidden",
589                        "allow_comments": false,
590                        "allow_anonymous": true,
591                        "expires_in": 120
592                    })
593                    .to_string(),
594                ))
595                .with_header("content-type", "application/json")
596                .with_header("Crypto-Pay-API-Token", "test_token")
597                .with_body(
598                    json!({
599                        "ok": true,
600                        "result": {
601                            "invoice_id": 42,
602                            "hash": "hash",
603                            "currency_type": "crypto",
604                            "asset": "TON",
605                            "amount": "2",
606                            "pay_url": "https://t.me/CryptoTestnetBot?start=hash",
607                            "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=hash",
608                            "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-hash",
609                            "web_app_invoice_url": "https://testnet-app.send.tg/invoices/hash",
610                            "status": "active",
611                            "created_at": "2025-02-08T12:11:01.341Z",
612                            "allow_comments": false,
613                            "allow_anonymous": true
614                        }
615                    })
616                    .to_string(),
617                )
618                .create()
619        }
620
621        pub fn mock_get_invoices_response_with_filters(&mut self) -> Mock {
622            self.server
623                .mock("GET", "/getInvoices")
624                .match_body(Matcher::JsonString(
625                    json!({
626                        "asset": "TON",
627                        "fiat": "USD",
628                        "invoice_ids": "1,2",
629                        "status": "paid",
630                        "offset": 3,
631                        "count": 4
632                    })
633                    .to_string(),
634                ))
635                .with_header("content-type", "application/json")
636                .with_header("Crypto-Pay-API-Token", "test_token")
637                .with_body(
638                    json!({
639                        "ok": true,
640                        "result": {
641                            "items": [
642                                {
643                                    "invoice_id": 1,
644                                    "hash": "hash",
645                                    "currency_type": "crypto",
646                                    "asset": "TON",
647                                    "amount": "1",
648                                    "pay_url": "https://t.me/CryptoTestnetBot?start=hash",
649                                    "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=hash",
650                                    "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-hash",
651                                    "web_app_invoice_url": "https://testnet-app.send.tg/invoices/hash",
652                                    "status": "paid",
653                                    "created_at": "2025-02-08T12:11:01.341Z",
654                                    "allow_comments": true,
655                                    "allow_anonymous": true
656                                }
657                            ]
658                        }
659                    })
660                    .to_string(),
661                )
662                .create()
663        }
664    }
665
666    #[test]
667    fn test_create_invoice() {
668        let mut ctx = TestContext::new();
669        let _m = ctx.mock_exchange_rates_response();
670        let _m = ctx.mock_create_invoice_response();
671
672        let client = CryptoBot::builder()
673            .api_token("test_token")
674            .base_url(ctx.server.url())
675            .build()
676            .unwrap();
677
678        let result = ctx.run(async {
679            client
680                .create_invoice()
681                .asset(CryptoCurrencyCode::Ton)
682                .amount(dec!(10.5))
683                .description("Test invoice".to_string())
684                .expires_in(3600)
685                .execute()
686                .await
687        });
688
689        println!("result: {:?}", result);
690        assert!(result.is_ok());
691
692        let invoice = result.unwrap();
693        assert_eq!(invoice.amount, dec!(10.5));
694        assert_eq!(invoice.asset, Some(CryptoCurrencyCode::Ton));
695        assert_eq!(invoice.description, Some("Test invoice".to_string()));
696    }
697
698    #[test]
699    fn test_get_invoices_without_params() {
700        let mut ctx = TestContext::new();
701        let _m = ctx.mock_get_invoices_response();
702        let client = CryptoBot::builder()
703            .api_token("test_token")
704            .base_url(ctx.server.url())
705            .build()
706            .unwrap();
707        let result = ctx.run(async { client.get_invoices().execute().await });
708
709        println!("result:{:?}", result);
710
711        assert!(result.is_ok());
712
713        let invoices = result.unwrap();
714        assert!(!invoices.is_empty());
715        assert_eq!(invoices.len(), 1);
716    }
717
718    #[test]
719    fn test_get_invoices_with_params() {
720        let mut ctx = TestContext::new();
721        let _m = ctx.mock_get_invoices_response_with_invoice_ids();
722        let client = CryptoBot::builder()
723            .api_token("test_token")
724            .base_url(ctx.server.url())
725            .build()
726            .unwrap();
727
728        let result = ctx.run(async { client.get_invoices().invoice_ids(vec![530195]).execute().await });
729
730        println!("result: {:?}", result);
731
732        assert!(result.is_ok());
733
734        let invoices = result.unwrap();
735        assert!(!invoices.is_empty());
736        assert_eq!(invoices.len(), 1);
737        assert_eq!(invoices[0].invoice_id, 530195);
738    }
739
740    #[test]
741    fn test_delete_invoice() {
742        let mut ctx = TestContext::new();
743        let _m = ctx.mock_delete_invoice_response();
744
745        let client = CryptoBot::builder()
746            .api_token("test_token")
747            .base_url(ctx.server.url())
748            .build()
749            .unwrap();
750
751        let result = ctx.run(async { client.delete_invoice(528890).execute().await });
752
753        assert!(result.is_ok());
754        assert!(result.unwrap());
755    }
756
757    #[test]
758    fn test_get_invoices_with_all_params() {
759        let mut ctx = TestContext::new();
760        let _m = ctx.mock_get_invoices_response();
761        let client = CryptoBot::builder()
762            .api_token("test_token")
763            .base_url(ctx.server.url())
764            .build()
765            .unwrap();
766
767        let result = ctx.run(async {
768            client
769                .get_invoices()
770                .asset(CryptoCurrencyCode::Ton)
771                .fiat(FiatCurrencyCode::Usd)
772                .status(InvoiceStatus::Paid)
773                .offset(10)
774                .count(50)
775                .execute()
776                .await
777        });
778
779        assert!(result.is_ok());
780    }
781
782    #[test]
783    fn test_get_invoices_invalid_count() {
784        let ctx = TestContext::new();
785        let client = CryptoBot::builder()
786            .api_token("test_token")
787            .base_url(ctx.server.url())
788            .build()
789            .unwrap();
790
791        let result = ctx.run(async { client.get_invoices().count(0).execute().await });
792
793        assert!(result.is_err());
794        match result {
795            Err(CryptoBotError::ValidationError { kind, .. }) => {
796                assert_eq!(kind, ValidationErrorKind::Range);
797            }
798            _ => panic!("Expected ValidationError"),
799        }
800    }
801
802    #[test]
803    fn test_create_invoice_with_all_optional_params() {
804        let mut ctx = TestContext::new();
805        let _m = ctx.mock_exchange_rates_response();
806        let _m = ctx.mock_create_invoice_response();
807        let client = CryptoBot::builder()
808            .api_token("test_token")
809            .base_url(ctx.server.url())
810            .build()
811            .unwrap();
812
813        let result = ctx.run(async {
814            client
815                .create_invoice()
816                .asset(CryptoCurrencyCode::Ton)
817                .amount(dec!(10.5))
818                .description("Test".to_string())
819                .hidden_message("Hidden".to_string())
820                .paid_btn_name(PayButtonName::ViewItem)
821                .paid_btn_url("https://example.com".to_string())
822                .payload("payload".to_string())
823                .allow_comments(true)
824                .allow_anonymous(false)
825                .expires_in(3600)
826                .execute()
827                .await
828        });
829
830        assert!(result.is_ok());
831    }
832
833    #[test]
834    fn test_swap_to_assets_serialization() {
835        let serialized = serde_json::to_string(&SwapToAssets::Ton).unwrap();
836        assert_eq!(serialized, "\"TON\"");
837
838        let deserialized: SwapToAssets = serde_json::from_str("\"USDT\"").unwrap();
839        assert_eq!(deserialized, SwapToAssets::Usdt);
840    }
841
842    #[test]
843    fn test_invoice_swap_fields_serialization() {
844        let invoice: Invoice = serde_json::from_value(json!({
845            "invoice_id": 123,
846            "hash": "hash-value",
847            "currency_type": "crypto",
848            "asset": "TON",
849            "amount": "10.00",
850            "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=hash-value",
851            "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-hash-value",
852            "web_app_invoice_url": "https://testnet-app.send.tg/invoices/hash-value",
853            "status": "paid",
854            "allow_comments": true,
855            "allow_anonymous": false,
856            "created_at": "2025-02-08T12:11:01.341Z",
857            "swap_to": "USDT",
858            "is_swapped": "true",
859            "swapped_uid": "swap-uid",
860            "swapped_to": "USDT",
861            "swapped_rate": "1.50",
862            "swapped_output": "100.00",
863            "swapped_usd_amount": "1500.00",
864            "swapped_usd_rate": "1.50"
865        }))
866        .unwrap();
867
868        assert_eq!(invoice.swapped_usd_amount, Some(dec!(1500.00))); // 1500.00
869        assert_eq!(invoice.swapped_usd_rate, Some(dec!(1.50))); // 1.50
870        assert_eq!(invoice.swap_to, Some(SwapToAssets::Usdt));
871        assert_eq!(invoice.swapped_to, Some(SwapToAssets::Usdt));
872    }
873
874    #[test]
875    fn test_create_invoice_rejects_negative_amount() {
876        let ctx = TestContext::new();
877        let client = CryptoBot::builder()
878            .api_token("test_token")
879            .base_url(ctx.server.url())
880            .build()
881            .unwrap();
882
883        let builder = client.create_invoice().asset(CryptoCurrencyCode::Ton).amount(dec!(-1));
884
885        let result = builder.validate();
886        assert!(result.is_err());
887        match result {
888            Err(CryptoBotError::ValidationError { field, .. }) => assert_eq!(field, Some("amount".to_string())),
889            _ => panic!("Expected validation error for negative amount"),
890        }
891    }
892
893    #[test]
894    fn test_create_invoice_rejects_description_too_long() {
895        let ctx = TestContext::new();
896        let client = CryptoBot::builder()
897            .api_token("test_token")
898            .base_url(ctx.server.url())
899            .build()
900            .unwrap();
901
902        let long_description = "a".repeat(1_025);
903        let builder = client
904            .create_invoice()
905            .asset(CryptoCurrencyCode::Ton)
906            .amount(dec!(1))
907            .description(long_description);
908
909        let result = builder.validate();
910        assert!(result.is_err());
911        match result {
912            Err(CryptoBotError::ValidationError { field, .. }) => {
913                assert_eq!(field, Some("description".to_string()))
914            }
915            _ => panic!("Expected validation error for long description"),
916        }
917    }
918
919    #[test]
920    fn test_create_invoice_invalid_paid_button_url() {
921        let ctx = TestContext::new();
922        let client = CryptoBot::builder()
923            .api_token("test_token")
924            .base_url(ctx.server.url())
925            .build()
926            .unwrap();
927
928        let result = ctx.run(async {
929            client
930                .create_invoice()
931                .asset(CryptoCurrencyCode::Ton)
932                .amount(dec!(5))
933                .paid_btn_name(PayButtonName::ViewItem)
934                .paid_btn_url("ftp://example.com")
935                .execute()
936                .await
937        });
938
939        assert!(result.is_err());
940        match result {
941            Err(CryptoBotError::ValidationError { field, .. }) => assert_eq!(field, Some("paid_btn_url".to_string())),
942            _ => panic!("Expected validation error for invalid paid_btn_url"),
943        }
944    }
945
946    #[test]
947    fn test_create_invoice_rejects_hidden_message_too_long() {
948        let ctx = TestContext::new();
949        let client = CryptoBot::builder()
950            .api_token("test_token")
951            .base_url(ctx.server.url())
952            .build()
953            .unwrap();
954
955        let message = "a".repeat(2_049);
956        let builder = client
957            .create_invoice()
958            .asset(CryptoCurrencyCode::Ton)
959            .amount(dec!(1))
960            .hidden_message(message);
961
962        let result = builder.validate();
963        assert!(matches!(
964            result,
965            Err(CryptoBotError::ValidationError {
966                field,
967                kind: ValidationErrorKind::Range,
968                ..
969            }) if field == Some("hidden_message".to_string())
970        ));
971    }
972
973    #[test]
974    fn test_create_invoice_rejects_payload_too_long() {
975        let ctx = TestContext::new();
976        let client = CryptoBot::builder()
977            .api_token("test_token")
978            .base_url(ctx.server.url())
979            .build()
980            .unwrap();
981
982        let payload = "a".repeat(4_097);
983        let builder = client
984            .create_invoice()
985            .asset(CryptoCurrencyCode::Ton)
986            .amount(dec!(1))
987            .payload(payload);
988
989        let result = builder.validate();
990        assert!(matches!(
991            result,
992            Err(CryptoBotError::ValidationError {
993                field,
994                kind: ValidationErrorKind::Range,
995                ..
996            }) if field == Some("payload".to_string())
997        ));
998    }
999
1000    #[test]
1001    fn test_create_invoice_rejects_invalid_expires_in() {
1002        let ctx = TestContext::new();
1003        let client = CryptoBot::builder()
1004            .api_token("test_token")
1005            .base_url(ctx.server.url())
1006            .build()
1007            .unwrap();
1008
1009        let builder = client
1010            .create_invoice()
1011            .asset(CryptoCurrencyCode::Ton)
1012            .amount(dec!(1))
1013            .expires_in(0);
1014
1015        let result = builder.validate();
1016        assert!(matches!(
1017            result,
1018            Err(CryptoBotError::ValidationError {
1019                field,
1020                kind: ValidationErrorKind::Range,
1021                ..
1022            }) if field == Some("expires_in".to_string())
1023        ));
1024    }
1025
1026    #[test]
1027    fn test_create_invoice_with_accept_asset_and_flags() {
1028        let mut ctx = TestContext::new();
1029        let _m = ctx.mock_exchange_rates_response();
1030        let _m = ctx.mock_create_invoice_with_accept_asset_response();
1031
1032        let client = CryptoBot::builder()
1033            .api_token("test_token")
1034            .base_url(ctx.server.url())
1035            .build()
1036            .unwrap();
1037
1038        let result = ctx.run(async {
1039            client
1040                .create_invoice()
1041                .asset(CryptoCurrencyCode::Ton)
1042                .amount(dec!(2))
1043                .accept_asset(vec![CryptoCurrencyCode::Ton, CryptoCurrencyCode::Usdt])
1044                .payload("payload")
1045                .hidden_message("Hidden")
1046                .allow_comments(false)
1047                .allow_anonymous(true)
1048                .expires_in(120)
1049                .execute()
1050                .await
1051        });
1052
1053        assert!(result.is_ok());
1054        let invoice = result.unwrap();
1055        assert_eq!(invoice.invoice_id, 42);
1056        assert!(!invoice.allow_comments);
1057    }
1058
1059    #[test]
1060    fn test_get_invoices_serializes_filters() {
1061        let mut ctx = TestContext::new();
1062        let _m = ctx.mock_get_invoices_response_with_filters();
1063
1064        let client = CryptoBot::builder()
1065            .api_token("test_token")
1066            .base_url(ctx.server.url())
1067            .build()
1068            .unwrap();
1069
1070        let result = ctx.run(async {
1071            client
1072                .get_invoices()
1073                .asset(CryptoCurrencyCode::Ton)
1074                .fiat(FiatCurrencyCode::Usd)
1075                .invoice_ids(vec![1, 2])
1076                .status(InvoiceStatus::Paid)
1077                .offset(3)
1078                .count(4)
1079                .execute()
1080                .await
1081        });
1082
1083        assert!(result.is_ok());
1084        let invoices = result.unwrap();
1085        assert_eq!(invoices.len(), 1);
1086        assert_eq!(invoices[0].invoice_id, 1);
1087    }
1088
1089    #[test]
1090    fn test_invoice_validate_with_context_crypto_amount() {
1091        let client = CryptoBot::test_client();
1092        let builder = client.create_invoice().asset(CryptoCurrencyCode::Ton).amount(dec!(5));
1093        let ctx = ValidationContext {
1094            exchange_rates: crate::utils::test_utils::TestContext::mock_exchange_rates(),
1095        };
1096
1097        let result = block_on(async { builder.validate_with_context(&ctx).await });
1098        assert!(result.is_ok());
1099    }
1100
1101    #[test]
1102    fn test_invoice_validate_with_context_fiat_skips_amount_check() {
1103        let client = CryptoBot::test_client();
1104        let builder = client.create_invoice().fiat(FiatCurrencyCode::Usd).amount(dec!(5));
1105        let ctx = ValidationContext {
1106            exchange_rates: crate::utils::test_utils::TestContext::mock_exchange_rates(),
1107        };
1108
1109        let result = block_on(async { builder.validate_with_context(&ctx).await });
1110        assert!(result.is_ok());
1111    }
1112}