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 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}