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