Skip to main content

agentis_pay/mcp/
backend.rs

1use std::{collections::HashMap, str::FromStr};
2
3use anyhow::{Result, anyhow, bail};
4use async_trait::async_trait;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use time::{OffsetDateTime, UtcOffset, format_description};
8
9use agentis_pay_shared::{
10    BipaClient, ClientConfig, CredentialsStore, outflow_poll_id, proto::pb,
11    types::pix_payment_key::PixKeyPayment,
12};
13
14pub type PixHistoryId = String;
15
16#[derive(Debug, Clone)]
17pub enum HistoryQuery {
18    List { limit: u32 },
19    Detail(PixHistoryId),
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct WhoamiRecord {
24    pub has_session: bool,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub agent_name: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub expires_at: Option<i64>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub expires_in_seconds: Option<i64>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub auth_method: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct AccountRecord {
37    pub user_id: String,
38    pub owner_name: String,
39    pub document: String,
40    pub status: String,
41    pub stark_pix_account_branch: String,
42    pub stark_pix_account_number: String,
43    pub bipa_name: String,
44    pub bipa_ispb: String,
45    pub stark_pix_keys: Vec<StarkPixKeyRecord>,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct StarkPixKeyRecord {
50    pub key_type: String,
51    pub key: String,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct BalanceRecord {
56    pub available_cents: i64,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct TransactionSummary {
61    pub transaction_id: String,
62    pub title: String,
63    pub direction: String,
64    pub amount_cents: i64,
65    pub amount_formatted: String,
66    pub status: String,
67    pub timestamp: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub description: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct TransactionSummaryList {
74    pub transactions: Vec<TransactionSummary>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct TransactionDetail {
79    pub transaction_id: String,
80    pub title: String,
81    pub direction: String,
82    pub amount_cents: i64,
83    pub amount_formatted: String,
84    pub status: String,
85    pub note: Option<String>,
86    pub created_at: String,
87    pub updated_at: String,
88    #[serde(skip_serializing_if = "Vec::is_empty")]
89    pub sections: Vec<TransactionSection>,
90}
91
92#[derive(Debug, Clone, Serialize)]
93pub struct TransactionSection {
94    pub name: String,
95    pub fields: Vec<TransactionField>,
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct TransactionField {
100    pub label: String,
101    pub value: String,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub enum HistoryRecord {
106    List(TransactionSummaryList),
107    Detail(TransactionDetail),
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct DepositRecord {
112    pub keys: Vec<StarkPixDepositRecord>,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct StarkPixDepositRecord {
117    pub key: String,
118    pub key_type: String,
119}
120
121#[derive(Debug, Clone, Serialize)]
122pub struct PixLimitRecord {
123    pub daily_limit_cents: i64,
124    pub nightly_limit_cents: i64,
125    pub nightly_limit_available_in_up_to_hours: u32,
126    pub daily_limit_available_in_up_to_hours: u32,
127    pub max_available_in_up_to_hours: u32,
128    pub min_available_in_up_to_hours: u32,
129}
130
131#[derive(Debug, Clone, Serialize)]
132pub struct PayTransferRecord {
133    pub status: String,
134    pub key: String,
135    pub owner_name: String,
136    pub amount_brl: String,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub note: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub message: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
144pub struct PayInput {
145    /// PIX key of the recipient (email, phone, CPF, CNPJ, or EVP).
146    pub key: String,
147    /// Amount in cents (must be > 0).
148    pub amount_cents: i64,
149    /// Optional payment description.
150    pub note: Option<String>,
151    /// Agent explanation shown to the user during payment approval.
152    pub agent_message: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
156pub struct HistoryInput {
157    #[serde(default)]
158    pub id: Option<String>,
159    #[serde(default)]
160    pub limit: Option<u32>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
164pub struct BrcodeDecodeInput {
165    pub code: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
169pub struct NoArgs {}
170
171#[async_trait]
172pub trait PixBackend: Send {
173    fn set_mcp_client_name(&mut self, _name: String) {}
174    fn set_jwt(&mut self, _jwt: String) {}
175    async fn whoami(&mut self) -> Result<WhoamiRecord>;
176    async fn account(&mut self) -> Result<AccountRecord>;
177    async fn balance(&mut self) -> Result<BalanceRecord>;
178    async fn history(&mut self, query: HistoryQuery) -> Result<HistoryRecord>;
179    async fn deposit(&mut self) -> Result<DepositRecord>;
180    async fn pix_list(&mut self) -> Result<DepositRecord> {
181        self.deposit().await
182    }
183    async fn limits(&mut self) -> Result<PixLimitRecord>;
184    async fn pay(
185        &mut self,
186        input: PayInput,
187        idempotency_key: uuid::Uuid,
188    ) -> Result<PayTransferRecord>;
189}
190
191enum BackendAuth {
192    Static,
193    EnvSession(Box<crate::session::EnvSession>),
194    StoredCredentials(Box<CredentialsStore>),
195}
196
197pub struct RealBackend {
198    config: ClientConfig,
199    client: BipaClient,
200    auth: BackendAuth,
201    agent_name: Option<String>,
202}
203
204impl RealBackend {
205    pub fn new(config: ClientConfig, client: BipaClient) -> Self {
206        let agent_name = client.agent_name().map(ToString::to_string);
207        Self {
208            config,
209            client,
210            auth: BackendAuth::Static,
211            agent_name,
212        }
213    }
214
215    pub fn with_env_session(
216        config: ClientConfig,
217        client: BipaClient,
218        session: crate::session::EnvSession,
219    ) -> Self {
220        let agent_name = client.agent_name().map(ToString::to_string);
221        Self {
222            config,
223            client,
224            auth: BackendAuth::EnvSession(Box::new(session)),
225            agent_name,
226        }
227    }
228
229    pub fn with_stored_credentials(
230        config: ClientConfig,
231        client: BipaClient,
232        credentials: CredentialsStore,
233    ) -> Self {
234        let agent_name = credentials
235            .agent_name()
236            .map(ToString::to_string)
237            .or_else(|| client.agent_name().map(ToString::to_string));
238        Self {
239            config,
240            client,
241            auth: BackendAuth::StoredCredentials(Box::new(credentials)),
242            agent_name,
243        }
244    }
245
246    async fn ensure_session(&mut self) -> Result<()> {
247        match &mut self.auth {
248            BackendAuth::Static => {}
249            BackendAuth::EnvSession(session) => {
250                crate::session::ensure_valid_env_session(&self.config, &mut self.client, session)
251                    .await?;
252            }
253            BackendAuth::StoredCredentials(credentials) => {
254                crate::session::ensure_valid_session(&self.config, &mut self.client, credentials)
255                    .await?;
256            }
257        }
258        Ok(())
259    }
260}
261
262#[derive(Debug, Clone)]
263pub struct MockBackend {
264    whoami_result: WhoamiRecord,
265    account_result: AccountRecord,
266    balance_result: BalanceRecord,
267    history_list: Vec<TransactionSummary>,
268    history_details: HashMap<PixHistoryId, TransactionDetail>,
269    deposit_result: DepositRecord,
270    limits_result: PixLimitRecord,
271    pay_result: PayTransferRecord,
272    pay_inputs: std::sync::Arc<std::sync::Mutex<Vec<PayInput>>>,
273    mcp_client_name: std::sync::Arc<std::sync::Mutex<Option<String>>>,
274}
275
276impl Default for MockBackend {
277    fn default() -> Self {
278        Self::fixture()
279    }
280}
281
282impl MockBackend {
283    pub fn fixture() -> Self {
284        Self {
285            whoami_result: WhoamiRecord {
286                has_session: true,
287                agent_name: Some("agentis-test".to_string()),
288                expires_at: Some(agentis_pay_shared::unix_timestamp_seconds() + 3600),
289                expires_in_seconds: Some(3600),
290                auth_method: Some("oauth".to_string()),
291            },
292            account_result: AccountRecord {
293                user_id: "user-123".to_string(),
294                owner_name: "Alice Cooper".to_string(),
295                document: "123.456.789-00".to_string(),
296                status: "active".to_string(),
297                stark_pix_account_branch: "0001".to_string(),
298                stark_pix_account_number: "12345-6".to_string(),
299                bipa_name: "Bipa IP".to_string(),
300                bipa_ispb: "40910463".to_string(),
301                stark_pix_keys: vec![
302                    StarkPixKeyRecord {
303                        key_type: "cpf".to_string(),
304                        key: "12345678900".to_string(),
305                    },
306                    StarkPixKeyRecord {
307                        key_type: "phone".to_string(),
308                        key: "5511999999999".to_string(),
309                    },
310                ],
311            },
312            balance_result: BalanceRecord {
313                available_cents: 9_876_500,
314            },
315            history_list: vec![
316                TransactionSummary {
317                    transaction_id: "tx-001".to_string(),
318                    title: "Alice Cooper".to_string(),
319                    direction: "credit".to_string(),
320                    amount_cents: 1250,
321                    amount_formatted: "+R$ 12,50".to_string(),
322                    status: "confirmed".to_string(),
323                    timestamp: "2026-02-28T00:00:00Z".to_string(),
324                    description: None,
325                },
326                TransactionSummary {
327                    transaction_id: "tx-002".to_string(),
328                    title: "Coffee Shop".to_string(),
329                    direction: "debit".to_string(),
330                    amount_cents: 500,
331                    amount_formatted: "-R$ 5,00".to_string(),
332                    status: "confirmed".to_string(),
333                    timestamp: "2026-02-28T01:00:00Z".to_string(),
334                    description: Some("morning coffee".to_string()),
335                },
336            ],
337            history_details: HashMap::from([
338                (
339                    "tx-001".to_string(),
340                    TransactionDetail {
341                        transaction_id: "tx-001".to_string(),
342                        title: "Alice Cooper".to_string(),
343                        direction: "credit".to_string(),
344                        amount_cents: 1250,
345                        amount_formatted: "+R$ 12,50".to_string(),
346                        status: "confirmed".to_string(),
347                        note: Some("mock transfer".to_string()),
348                        created_at: "2026-02-28T00:00:00Z".to_string(),
349                        updated_at: "2026-02-28T00:00:00Z".to_string(),
350                        sections: vec![],
351                    },
352                ),
353                (
354                    "tx-002".to_string(),
355                    TransactionDetail {
356                        transaction_id: "tx-002".to_string(),
357                        title: "Coffee Shop".to_string(),
358                        direction: "debit".to_string(),
359                        amount_cents: 500,
360                        amount_formatted: "-R$ 5,00".to_string(),
361                        status: "confirmed".to_string(),
362                        note: None,
363                        created_at: "2026-02-28T01:00:00Z".to_string(),
364                        updated_at: "2026-02-28T01:00:00Z".to_string(),
365                        sections: vec![],
366                    },
367                ),
368            ]),
369            deposit_result: DepositRecord {
370                keys: vec![
371                    StarkPixDepositRecord {
372                        key: "alice@example.com".to_string(),
373                        key_type: "email".to_string(),
374                    },
375                    StarkPixDepositRecord {
376                        key: "5511999999999".to_string(),
377                        key_type: "phone".to_string(),
378                    },
379                ],
380            },
381            limits_result: PixLimitRecord {
382                daily_limit_cents: 2_000_000,
383                nightly_limit_cents: 2_000_000,
384                nightly_limit_available_in_up_to_hours: 6,
385                daily_limit_available_in_up_to_hours: 24,
386                max_available_in_up_to_hours: 24,
387                min_available_in_up_to_hours: 0,
388            },
389            pay_result: PayTransferRecord {
390                status: "awaiting_approval".to_string(),
391                key: "alice@example.com".to_string(),
392                owner_name: "Alice Cooper".to_string(),
393                amount_brl: "R$ 10,00".to_string(),
394                note: Some("mock payment".to_string()),
395                message: None,
396            },
397            pay_inputs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
398            mcp_client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
399        }
400    }
401
402    pub fn pay_inputs(&self) -> std::sync::MutexGuard<'_, Vec<PayInput>> {
403        self.pay_inputs
404            .lock()
405            .expect("pay input lock must not be poisoned")
406    }
407
408    pub fn mcp_client_name(&self) -> Option<String> {
409        self.mcp_client_name
410            .lock()
411            .expect("mcp_client_name lock must not be poisoned")
412            .clone()
413    }
414}
415
416#[async_trait]
417impl PixBackend for MockBackend {
418    fn set_mcp_client_name(&mut self, name: String) {
419        *self
420            .mcp_client_name
421            .lock()
422            .expect("mcp_client_name lock must not be poisoned") = Some(name);
423    }
424
425    async fn whoami(&mut self) -> Result<WhoamiRecord> {
426        Ok(self.whoami_result.clone())
427    }
428
429    async fn account(&mut self) -> Result<AccountRecord> {
430        Ok(self.account_result.clone())
431    }
432
433    async fn balance(&mut self) -> Result<BalanceRecord> {
434        Ok(self.balance_result.clone())
435    }
436
437    async fn history(&mut self, query: HistoryQuery) -> Result<HistoryRecord> {
438        match query {
439            HistoryQuery::List { limit } => Ok(HistoryRecord::List(TransactionSummaryList {
440                transactions: self
441                    .history_list
442                    .iter()
443                    .take(limit as usize)
444                    .cloned()
445                    .collect(),
446            })),
447            HistoryQuery::Detail(id) => self
448                .history_details
449                .get(&id)
450                .cloned()
451                .map(HistoryRecord::Detail)
452                .ok_or_else(|| anyhow!("transaction not found")),
453        }
454    }
455
456    async fn deposit(&mut self) -> Result<DepositRecord> {
457        Ok(self.deposit_result.clone())
458    }
459
460    async fn limits(&mut self) -> Result<PixLimitRecord> {
461        Ok(self.limits_result.clone())
462    }
463
464    async fn pay(
465        &mut self,
466        input: PayInput,
467        _idempotency_key: uuid::Uuid,
468    ) -> Result<PayTransferRecord> {
469        if input.amount_cents <= 0 {
470            bail!("amount_cents must be greater than zero");
471        }
472        if input.agent_message.trim().is_empty() {
473            bail!("agent_message is required");
474        }
475
476        let mut calls = self
477            .pay_inputs
478            .lock()
479            .expect("pay input lock must not be poisoned");
480
481        calls.push(input.clone());
482        Ok(PayTransferRecord {
483            status: "awaiting_approval".to_string(),
484            key: input.key,
485            owner_name: self.pay_result.owner_name.clone(),
486            amount_brl: crate::display::cents_to_brl(input.amount_cents),
487            note: input.note,
488            message: Some(
489                "Payment submitted. The user must approve this operation in the Bipa app. \
490                 Use agentispay_history to check the transaction status."
491                    .to_string(),
492            ),
493        })
494    }
495}
496
497#[async_trait]
498impl PixBackend for RealBackend {
499    fn set_mcp_client_name(&mut self, _name: String) {}
500
501    fn set_jwt(&mut self, jwt: String) {
502        self.client.set_jwt(jwt);
503    }
504
505    async fn whoami(&mut self) -> Result<WhoamiRecord> {
506        self.ensure_session().await?;
507        let has_session = self
508            .client
509            .jwt()
510            .is_some_and(|value: &str| !value.trim().is_empty());
511
512        let (expires_at, expires_in_seconds, auth_method) = match &self.auth {
513            BackendAuth::StoredCredentials(creds) => {
514                let exp = creds.jwt_expires_at();
515                let remaining =
516                    exp.map(|e| (e - agentis_pay_shared::unix_timestamp_seconds()).max(0));
517                let method = if has_session {
518                    if creds.oauth_client_id().is_some() {
519                        Some("oauth".to_string())
520                    } else {
521                        Some("pin".to_string())
522                    }
523                } else {
524                    None
525                };
526                (exp, remaining, method)
527            }
528            BackendAuth::EnvSession(session) => {
529                let exp = session.jwt_expires_at();
530                let remaining =
531                    exp.map(|e| (e - agentis_pay_shared::unix_timestamp_seconds()).max(0));
532                (exp, remaining, Some("env".to_string()))
533            }
534            BackendAuth::Static => (None, None, Some("static".to_string())),
535        };
536
537        Ok(WhoamiRecord {
538            has_session,
539            agent_name: self.agent_name.clone(),
540            expires_at,
541            expires_in_seconds,
542            auth_method,
543        })
544    }
545
546    async fn account(&mut self) -> Result<AccountRecord> {
547        self.ensure_session().await?;
548        let user = self.client.load_user_cli().await?;
549        Ok(AccountRecord {
550            user_id: user.user_id.to_string(),
551            owner_name: user.owner_name,
552            document: user.document,
553            status: user.status,
554            stark_pix_account_branch: user.stark_pix_account_branch,
555            stark_pix_account_number: user.stark_pix_account_number,
556            bipa_name: "Bipa IP".to_string(),
557            bipa_ispb: "40910463".to_string(),
558            stark_pix_keys: user
559                .stark_pix_keys
560                .into_iter()
561                .map(|key| StarkPixKeyRecord {
562                    key_type: key.key_type,
563                    key: key.key,
564                })
565                .collect(),
566        })
567    }
568
569    async fn balance(&mut self) -> Result<BalanceRecord> {
570        self.ensure_session().await?;
571        let balances = self.client.balances().await?;
572        let available_cents = if balances.available_cents != 0 {
573            balances.available_cents
574        } else {
575            balances.cents
576        };
577        Ok(BalanceRecord { available_cents })
578    }
579
580    async fn history(&mut self, query: HistoryQuery) -> Result<HistoryRecord> {
581        self.ensure_session().await?;
582        match query {
583            HistoryQuery::List { limit } => {
584                let response = self
585                    .client
586                    .list_pix_transactions(limit as i32, None)
587                    .await?;
588                let transactions = response
589                    .items
590                    .into_iter()
591                    .map(|tx| {
592                        let direction = pix_flow_direction(tx.flow);
593                        let title = pix_title(&tx, direction.as_str());
594                        let amount_cents = tx.amount_cents;
595                        let amount_formatted =
596                            format_amount(amount_with_sign(amount_cents, direction.as_str()));
597                        let description = tx.description.filter(|d| !d.is_empty());
598                        TransactionSummary {
599                            transaction_id: tx.id,
600                            title,
601                            amount_formatted,
602                            direction,
603                            amount_cents,
604                            status: pix_status_label(tx.status),
605                            timestamp: format_timestamp_brt(tx.created_at),
606                            description,
607                        }
608                    })
609                    .collect();
610                Ok(HistoryRecord::List(TransactionSummaryList { transactions }))
611            }
612            HistoryQuery::Detail(id) => {
613                let tx = self.client.get_pix_transaction(&id).await?;
614                let direction = pix_flow_direction(tx.flow);
615                let title = pix_title(&tx, direction.as_str());
616                let description = tx.description.filter(|d| !d.is_empty());
617                let amount_cents = tx.amount_cents;
618
619                let mut sections = Vec::new();
620
621                let mut sender_fields = vec![
622                    TransactionField {
623                        label: "Nome".to_string(),
624                        value: tx.sender_name,
625                    },
626                    TransactionField {
627                        label: "CPF/CNPJ".to_string(),
628                        value: tx.sender_tax_id,
629                    },
630                ];
631                if !tx.sender_bank_code.is_empty() {
632                    sender_fields.push(TransactionField {
633                        label: "Instituição".to_string(),
634                        value: tx.sender_bank_code,
635                    });
636                }
637
638                let mut receiver_fields = vec![
639                    TransactionField {
640                        label: "Nome".to_string(),
641                        value: tx.receiver_name,
642                    },
643                    TransactionField {
644                        label: "CPF/CNPJ".to_string(),
645                        value: tx.receiver_tax_id,
646                    },
647                ];
648                if !tx.receiver_bank_code.is_empty() {
649                    receiver_fields.push(TransactionField {
650                        label: "Instituição".to_string(),
651                        value: tx.receiver_bank_code,
652                    });
653                }
654
655                sections.push(TransactionSection {
656                    name: "Remetente".to_string(),
657                    fields: sender_fields,
658                });
659                sections.push(TransactionSection {
660                    name: "Destinatário".to_string(),
661                    fields: receiver_fields,
662                });
663
664                if !tx.end_to_end_id.is_empty() {
665                    sections.push(TransactionSection {
666                        name: "Detalhes".to_string(),
667                        fields: vec![TransactionField {
668                            label: "End-to-end ID".to_string(),
669                            value: tx.end_to_end_id,
670                        }],
671                    });
672                }
673
674                Ok(HistoryRecord::Detail(TransactionDetail {
675                    transaction_id: tx.id,
676                    title,
677                    amount_formatted: format_amount(amount_with_sign(amount_cents, &direction)),
678                    direction,
679                    amount_cents,
680                    status: pix_status_label(tx.status),
681                    note: description,
682                    created_at: format_timestamp_brt(tx.created_at),
683                    updated_at: String::new(),
684                    sections,
685                }))
686            }
687        }
688    }
689
690    async fn deposit(&mut self) -> Result<DepositRecord> {
691        self.ensure_session().await?;
692        let keys = self.client.list_stark_pix_keys().await?;
693        let mut entries = Vec::new();
694
695        if let Some(evp) = keys.evp {
696            entries.push(StarkPixDepositRecord {
697                key: evp.value,
698                key_type: "evp".to_string(),
699            });
700        }
701
702        if let Some(cpf) = keys.cpf
703            && let Some((value, kind)) = extract_value_and_kind(cpf)
704        {
705            entries.push(StarkPixDepositRecord {
706                key: value,
707                key_type: format!("cpf_{kind}"),
708            });
709        }
710        if let Some(email) = keys.email
711            && let Some((value, kind)) = extract_value_and_kind(email)
712        {
713            entries.push(StarkPixDepositRecord {
714                key: value,
715                key_type: format!("email_{kind}"),
716            });
717        }
718        if let Some(phone) = keys.phone
719            && let Some((value, kind)) = extract_value_and_kind(phone)
720        {
721            entries.push(StarkPixDepositRecord {
722                key: value,
723                key_type: format!("phone_{kind}"),
724            });
725        }
726        if let Some(cnpj) = keys.cnpj
727            && let Some((value, kind)) = extract_value_and_kind(cnpj)
728        {
729            entries.push(StarkPixDepositRecord {
730                key: value,
731                key_type: format!("cnpj_{kind}"),
732            });
733        }
734
735        Ok(DepositRecord { keys: entries })
736    }
737
738    async fn limits(&mut self) -> Result<PixLimitRecord> {
739        self.ensure_session().await?;
740        let limits = self.client.get_pix_limits().await?;
741        Ok(PixLimitRecord {
742            daily_limit_cents: limits.daily_limit_cents,
743            nightly_limit_cents: limits.nightly_limit_cents,
744            nightly_limit_available_in_up_to_hours: limits
745                .nightly_limit_available_in_up_to_hours
746                .unwrap_or_default(),
747            daily_limit_available_in_up_to_hours: limits
748                .daily_limit_available_in_up_to_hours
749                .unwrap_or_default(),
750            max_available_in_up_to_hours: limits.max_available_in_up_to_hours,
751            min_available_in_up_to_hours: limits.min_available_in_up_to_hours,
752        })
753    }
754
755    async fn pay(
756        &mut self,
757        input: PayInput,
758        idempotency_key: uuid::Uuid,
759    ) -> Result<PayTransferRecord> {
760        self.ensure_session().await?;
761        if input.amount_cents <= 0 {
762            bail!("amount_cents must be greater than zero");
763        }
764        if input.agent_message.trim().is_empty() {
765            bail!("agent_message is required");
766        }
767        let pix_key =
768            PixKeyPayment::from_str(&input.key).map_err(|e| anyhow!("invalid pix key: {e}"))?;
769
770        let recipient = consulted_key(&self.client.consult_stark_pix_key(&pix_key).await?)?;
771        let note = input.note.clone();
772        let owner_name = recipient.name.clone().unwrap_or_default();
773
774        let response = self
775            .client
776            .request_stark_pix_outflow_with_request_id(
777                recipient.stark_pix_key_consultation_id,
778                input.amount_cents,
779                input.note,
780                input.agent_message.as_str(),
781                idempotency_key,
782            )
783            .await?;
784
785        // Check for immediate terminal errors (AboveMax, InsufficientFunds, etc.)
786        // but do NOT poll for approval — the user approves in-app.
787        let status = match outflow_poll_id(&response)? {
788            Some(_) => "awaiting_approval",
789            None => "scheduled",
790        };
791
792        Ok(PayTransferRecord {
793            status: status.to_string(),
794            key: input.key,
795            owner_name,
796            amount_brl: crate::display::cents_to_brl(input.amount_cents),
797            note,
798            message: Some(
799                "Payment submitted. The user must approve this operation in the Bipa app. \
800                 Use agentispay_history to check the transaction status."
801                    .to_string(),
802            ),
803        })
804    }
805}
806
807fn pix_flow_direction(flow: i32) -> String {
808    match flow {
809        f if f == pb::PixTransactionFlow::In as i32 => "credit".to_string(),
810        f if f == pb::PixTransactionFlow::Out as i32 => "debit".to_string(),
811        _ => "n/a".to_string(),
812    }
813}
814
815fn pix_status_label(status: i32) -> String {
816    match status {
817        s if s == pb::PixTransactionStatus::Created as i32 => "created".to_string(),
818        s if s == pb::PixTransactionStatus::Pending as i32 => "pending".to_string(),
819        s if s == pb::PixTransactionStatus::Succeeded as i32 => "succeeded".to_string(),
820        s if s == pb::PixTransactionStatus::Failed as i32 => "failed".to_string(),
821        _ => "unspecified".to_string(),
822    }
823}
824
825fn pix_title(tx: &pb::PixTransaction, direction: &str) -> String {
826    let counterparty = match direction {
827        "credit" => &tx.sender_name,
828        "debit" => &tx.receiver_name,
829        _ => &tx.receiver_name,
830    };
831
832    if counterparty.is_empty() {
833        match direction {
834            "credit" => "Pix recebido".to_string(),
835            "debit" => "Pagamento Pix".to_string(),
836            _ => "Pix".to_string(),
837        }
838    } else {
839        counterparty.clone()
840    }
841}
842
843fn amount_with_sign(amount_cents: i64, direction: &str) -> i64 {
844    match direction {
845        "debit" => -(amount_cents.unsigned_abs() as i64),
846        "credit" => amount_cents.unsigned_abs() as i64,
847        _ => amount_cents,
848    }
849}
850
851fn format_amount(value: i64) -> String {
852    let cents: u64 = value.unsigned_abs();
853    let integer = cents / 100;
854    let fractional = cents % 100;
855    let mut groups = Vec::new();
856    let mut remaining = integer;
857
858    loop {
859        groups.push((remaining % 1000).to_string());
860        remaining /= 1000;
861        if remaining == 0 {
862            break;
863        }
864    }
865
866    groups.reverse();
867    let mut formatted = String::new();
868    for (index, group) in groups.iter().enumerate() {
869        if index == 0 {
870            formatted.push_str(group);
871        } else {
872            formatted.push('.');
873            formatted.push_str(&format!("{group:0>3}"));
874        }
875    }
876
877    let amount = format!("R$ {formatted},{fractional:02}");
878    if value < 0 {
879        format!("-{amount}")
880    } else if value > 0 {
881        format!("+{amount}")
882    } else {
883        amount
884    }
885}
886
887fn format_timestamp_brt(unix_seconds: u64) -> String {
888    let brt = UtcOffset::from_hms(-3, 0, 0).expect("valid BRT offset");
889    let Ok(dt) = OffsetDateTime::from_unix_timestamp(unix_seconds as i64) else {
890        return unix_seconds.to_string();
891    };
892    let dt = dt.to_offset(brt);
893    let fmt =
894        format_description::parse("[day]/[month]/[year] [hour]:[minute]").expect("valid format");
895    dt.format(&fmt).unwrap_or_else(|_| unix_seconds.to_string())
896}
897
898fn extract_value_and_kind(key: pb::list_stark_pix_keys_response::Key) -> Option<(String, String)> {
899    match key.kind {
900        Some(pb::list_stark_pix_keys_response::key::Kind::Claimed(claimed)) => {
901            Some((claimed.value, "claimed".to_string()))
902        }
903        Some(pb::list_stark_pix_keys_response::key::Kind::Owned(owned)) => {
904            Some((owned.value, "owned".to_string()))
905        }
906        None => None,
907    }
908}
909
910fn consulted_key(
911    response: &pb::ConsultStarkPixKeyResponse,
912) -> Result<pb::consult_stark_pix_key_response::Found> {
913    match response.outcome.as_ref() {
914        Some(pb::consult_stark_pix_key_response::Outcome::Found(found)) => Ok(found.clone()),
915        Some(pb::consult_stark_pix_key_response::Outcome::NotFound(_)) => {
916            bail!("recipient key was not found")
917        }
918        Some(pb::consult_stark_pix_key_response::Outcome::Failed(_)) => {
919            bail!("recipient key lookup failed")
920        }
921        Some(pb::consult_stark_pix_key_response::Outcome::TooManyRequests(_)) => {
922            bail!("recipient key lookup was rate-limited")
923        }
924        Some(pb::consult_stark_pix_key_response::Outcome::FraudulentPixKey(_)) => {
925            bail!("recipient key is flagged as fraudulent")
926        }
927        None => bail!("recipient key response was empty"),
928    }
929}