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