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