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