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