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