Skip to main content

agentis_pay/mcp/
backend.rs

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