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