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 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(¶ms),
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 pub fn asset(mut self, asset: CryptoCurrencyCode) -> Self {
64 self.params.asset = Some(asset);
65 self
66 }
67
68 pub fn fiat(mut self, fiat: FiatCurrencyCode) -> Self {
71 self.params.fiat = Some(fiat);
72 self
73 }
74
75 pub fn invoice_ids(mut self, invoice_ids: Vec<u64>) -> Self {
77 self.params.invoice_ids = Some(invoice_ids);
78 self
79 }
80
81 pub fn status(mut self, status: InvoiceStatus) -> Self {
84 self.params.status = Some(status);
85 self
86 }
87
88 pub fn offset(mut self, offset: u32) -> Self {
92 self.params.offset = Some(offset);
93 self
94 }
95
96 pub fn count(mut self, count: u16) -> Self {
100 self.params.count = Some(count);
101 self
102 }
103
104 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 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 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 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 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 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 pub fn accept_asset(mut self, accept_asset: Vec<CryptoCurrencyCode>) -> Self {
210 self.accept_asset = Some(accept_asset);
211 self
212 }
213
214 pub fn description(mut self, description: impl Into<String>) -> Self {
216 self.description = Some(description.into());
217 self
218 }
219
220 pub fn hidden_message(mut self, hidden_message: impl Into<String>) -> Self {
222 self.hidden_message = Some(hidden_message.into());
223 self
224 }
225
226 pub fn payload(mut self, payload: impl Into<String>) -> Self {
228 self.payload = Some(payload.into());
229 self
230 }
231
232 pub fn allow_comments(mut self, allow_comments: bool) -> Self {
234 self.allow_comments = Some(allow_comments);
235 self
236 }
237
238 pub fn allow_anonymous(mut self, allow_anonymous: bool) -> Self {
240 self.allow_anonymous = Some(allow_anonymous);
241 self
242 }
243
244 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 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(¶ms),
367 )
368 .await
369 }
370}
371
372impl<'a> CreateInvoiceBuilder<'a, Set, Set, Set, Set> {
373 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(¶ms),
415 )
416 .await
417 }
418}
419
420#[async_trait]
421impl InvoiceAPI for CryptoBot {
422 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 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))); assert_eq!(invoice.swapped_usd_rate, Some(dec!(1.50))); 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}