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