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