1use anyhow::{Context, Result, anyhow, bail};
2use std::env;
3use tonic::{Request, metadata::MetadataValue, transport::Channel, transport::Endpoint};
4use uuid::Uuid;
5
6use crate::config::ClientConfig;
7use crate::proto::pb;
8use crate::types::pix_payment_key::PixKeyPayment;
9
10const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
11const POLL_MAX_ATTEMPTS: u32 = 90;
12
13pub struct AttemptAuthResult {
14 pub auth_token: String,
15 pub ttl_seconds: i64,
16}
17
18pub struct ValidatedAuth {
19 pub jwt: String,
20 pub refresh_token: Option<String>,
21 pub ttl_seconds: i64,
22}
23
24pub struct BipaClient {
25 endpoint: String,
26 device_id: Option<String>,
27 agent_name: Option<String>,
28 client_mode: String,
29 token: Option<String>,
30 inner: pb::mobile_client::MobileClient<Channel>,
31}
32
33impl BipaClient {
34 pub async fn connect(config: &ClientConfig) -> Result<Self> {
35 let endpoint_url = config.endpoint().to_string();
36 let mut endpoint = Endpoint::from_shared(endpoint_url.clone())
37 .context("invalid endpoint url")?
38 .connect_timeout(std::time::Duration::from_secs(10));
39
40 if config.is_tls() {
41 let tls = crate::tls::client_tls_config(&Self::domain_from_url(&endpoint_url))?;
42 endpoint = endpoint
43 .tls_config(tls)
44 .context("failed to configure TLS")?;
45 }
46
47 let channel = endpoint
48 .connect()
49 .await
50 .with_context(|| format!("transport error while connecting to {endpoint_url}"))?;
51 Ok(Self {
52 endpoint: endpoint_url,
53 device_id: None,
54 agent_name: None,
55 client_mode: "cli".to_string(),
56 token: None,
57 inner: pb::mobile_client::MobileClient::new(channel),
58 })
59 }
60
61 pub fn from_channel(endpoint: String, channel: Channel) -> Self {
62 Self {
63 endpoint,
64 device_id: None,
65 agent_name: None,
66 client_mode: "cli".to_string(),
67 token: None,
68 inner: pb::mobile_client::MobileClient::new(channel),
69 }
70 }
71
72 pub fn lazy_channel(config: &ClientConfig) -> Result<Channel> {
73 let endpoint_url = config.endpoint().to_string();
74 let mut endpoint = Endpoint::from_shared(endpoint_url.clone())
75 .context("invalid endpoint url")?
76 .connect_timeout(std::time::Duration::from_secs(10));
77
78 if config.is_tls() {
79 let tls = crate::tls::client_tls_config(&Self::domain_from_url(&endpoint_url))?;
80 endpoint = endpoint
81 .tls_config(tls)
82 .context("failed to configure TLS")?;
83 }
84
85 Ok(endpoint.connect_lazy())
86 }
87
88 fn domain_from_url(url: &str) -> String {
89 url.trim_start_matches("https://")
90 .trim_start_matches("http://")
91 .split(':')
92 .next()
93 .unwrap_or(url)
94 .split('/')
95 .next()
96 .unwrap_or(url)
97 .to_string()
98 }
99
100 pub fn endpoint(&self) -> &str {
101 &self.endpoint
102 }
103
104 pub fn set_jwt(&mut self, jwt: impl Into<String>) {
105 let token = jwt.into();
106 self.token = Some(token);
107 }
108
109 pub fn set_device_id(&mut self, device_id: impl Into<String>) {
110 let value = device_id.into();
111 if value.trim().is_empty() {
112 self.device_id = None;
113 } else {
114 self.device_id = Some(value);
115 }
116 }
117
118 pub fn set_client_mode(&mut self, mode: impl Into<String>) {
119 self.client_mode = mode.into();
120 }
121
122 pub fn set_agent_name(&mut self, name: impl Into<String>) {
123 let value = name.into();
124 if value.trim().is_empty() {
125 self.agent_name = None;
126 } else {
127 self.agent_name = Some(normalize_agent_name(&value));
128 }
129 }
130
131 pub fn jwt(&self) -> Option<&str> {
132 self.token.as_deref()
133 }
134
135 pub fn agent_name(&self) -> Option<&str> {
136 self.agent_name.as_deref()
137 }
138
139 fn authorize<T>(&self, request: Request<T>) -> Request<T> {
140 self.authorize_with_request_id(request, "auth-user", request_idempotency_key().as_str())
141 }
142
143 fn authorize_with_request_id<T>(
144 &self,
145 mut request: Request<T>,
146 header: &'static str,
147 request_id: &str,
148 ) -> Request<T> {
149 self.authorize_with_header(&mut request, header);
150 self.add_common_headers(&mut request);
151 if let Ok(value) = MetadataValue::try_from(request_id) {
152 request.metadata_mut().insert("x-client-request-id", value);
153 }
154 request
155 }
156
157 fn add_common_headers<T>(&self, request: &mut Request<T>) {
158 let cli_version =
159 std::env::var("AGENTIS_PAY_CLI_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
160 let client_mode = &self.client_mode;
161
162 let user_agent = if let Some(name) = &self.agent_name {
163 format!("AgentisPay/{client_mode}/{cli_version}/{name}")
164 } else {
165 format!("AgentisPay/{client_mode}/{cli_version}")
166 };
167 if let Ok(value) = MetadataValue::try_from(user_agent.as_str()) {
168 request.metadata_mut().insert("user-agent", value);
169 }
170 if let Ok(value) = MetadataValue::try_from("cli") {
171 request.metadata_mut().insert("x-mobile-platform", value);
172 }
173 let mobile_version = format!("{cli_version}/1");
174 if let Ok(value) = MetadataValue::try_from(mobile_version.as_str()) {
175 request.metadata_mut().insert("x-mobile-version", value);
176 }
177 if let Ok(value) = MetadataValue::try_from(client_mode.as_str()) {
178 request.metadata_mut().insert("x-client-mode", value);
179 }
180 if let Some(name) = &self.agent_name
181 && let Ok(value) = MetadataValue::try_from(name.as_str())
182 {
183 request.metadata_mut().insert("x-agent-name", value);
184 }
185 }
186
187 fn authorize_with_header<T>(&self, request: &mut Request<T>, header: &'static str) {
188 if let Some(token) = self
189 .token
190 .as_deref()
191 .filter(|token| !token.trim().is_empty())
192 && let Ok(value) = MetadataValue::try_from(token)
193 {
194 request.metadata_mut().insert(header, value);
195 }
196 }
197
198 pub async fn attempt_auth_email(&mut self, email: &str) -> Result<AttemptAuthResult> {
199 self.attempt_auth(pb::attempt_auth_request::Key::Email(email.to_string()))
200 .await
201 }
202
203 pub async fn attempt_auth_phone(&mut self, phone: &str) -> Result<AttemptAuthResult> {
204 self.attempt_auth(pb::attempt_auth_request::Key::Phone(phone.to_string()))
205 .await
206 }
207
208 async fn attempt_auth(
209 &mut self,
210 key: pb::attempt_auth_request::Key,
211 ) -> Result<AttemptAuthResult> {
212 let request = Request::new(pb::AttemptAuthRequest {
213 key: Some(key),
214 device: Some(pb::attempt_auth_request::Device {
215 manufacturer: Self::attempt_device_manufacturer(),
216 model: Self::attempt_device_model(),
217 }),
218 coordinates: None,
219 device_id: self.device_id(),
220 });
221 let request = self.authorize(request);
222 let response = self.inner.attempt_auth(request).await?.into_inner();
223
224 match response.outcome {
225 Some(pb::attempt_auth_response::Outcome::Success(success)) => Ok(AttemptAuthResult {
226 auth_token: success.auth_token,
227 ttl_seconds: success.seconds_to_allow_resend,
228 }),
229 Some(pb::attempt_auth_response::Outcome::Backoff(_)) => {
230 bail!("attempt auth returned a backoff response")
231 }
232 None => bail!("attempt auth returned an empty response"),
233 }
234 }
235
236 fn attempt_device_manufacturer() -> String {
237 "agentis-pay-cli".to_string()
238 }
239
240 fn attempt_device_model() -> String {
241 format!("{} {}", env::consts::OS, env::consts::ARCH)
242 }
243
244 fn device_id(&self) -> Option<String> {
245 self.device_id.clone()
246 }
247
248 pub async fn validate_auth(&mut self, auth_token: &str, pin: &str) -> Result<ValidatedAuth> {
249 let request = Request::new(pb::ValidateAuthRequest {
250 pin: pin.to_string(),
251 auth_token: auth_token.to_string(),
252 });
253 let request = self.authorize(request);
254 let response = self.inner.validate_auth(request).await?.into_inner();
255 match response.outcome {
256 Some(pb::validate_auth_response::Outcome::Valid(valid)) => Ok(ValidatedAuth {
257 jwt: valid.token,
258 refresh_token: None,
259 ttl_seconds: i64::from(valid.refresh_in),
260 }),
261 Some(pb::validate_auth_response::Outcome::InvalidPin(_)) => {
262 bail!("invalid pin")
263 }
264 Some(pb::validate_auth_response::Outcome::DeactivatedCommercialDisagreement(_)) => {
265 bail!("account deactivated by commercial disagreement")
266 }
267 Some(pb::validate_auth_response::Outcome::Deactivated(_)) => {
268 bail!("account deactivated")
269 }
270 Some(pb::validate_auth_response::Outcome::FaceChallenge(_)) => {
271 bail!("face challenge required")
272 }
273 Some(pb::validate_auth_response::Outcome::Exhausted(_)) => {
274 bail!("authentication attempts exhausted")
275 }
276 None => bail!("validate auth returned an empty response"),
277 }
278 }
279
280 pub async fn refresh_auth(&mut self) -> Result<pb::RefreshAuthResponse> {
281 let request = Request::new(pb::Empty {});
282 let request = self.authorize(request);
283 let response = self.inner.refresh_auth(request).await?;
284 Ok(response.into_inner())
285 }
286
287 pub async fn logout(&mut self) -> Result<()> {
288 let request = Request::new(pb::Empty {});
289 let request = self.authorize(request);
290 self.inner.logout(request).await?;
291 Ok(())
292 }
293
294 pub async fn list_stark_pix_keys(&mut self) -> Result<pb::ListStarkPixKeysResponse> {
295 let request = Request::new(pb::Empty {});
296 let request = self.authorize(request);
297 let response = self.inner.list_stark_pix_keys(request).await?;
298 Ok(response.into_inner())
299 }
300
301 pub async fn consult_stark_pix_key(
302 &mut self,
303 key: &PixKeyPayment,
304 ) -> Result<pb::ConsultStarkPixKeyResponse> {
305 let request = Request::new(pb::ConsultStarkPixKeyRequest {
306 key: key.to_string(),
307 idempotency_key: request_idempotency_key(),
308 });
309 let request = self.authorize(request);
310 let response = self.inner.consult_stark_pix_key(request).await?;
311 Ok(response.into_inner())
312 }
313
314 pub async fn request_stark_pix_outflow(
315 &mut self,
316 pix_key_payment_id: i32,
317 amount_cents: i64,
318 note: Option<String>,
319 agent_message: &str,
320 idempotency_key: uuid::Uuid,
321 ) -> Result<pb::RequestStarkPixOutflowResponse> {
322 self.request_stark_pix_outflow_with_request_id(
323 pix_key_payment_id,
324 amount_cents,
325 note,
326 agent_message,
327 idempotency_key,
328 )
329 .await
330 }
331
332 pub async fn request_stark_pix_outflow_with_request_id(
333 &mut self,
334 pix_key_payment_id: i32,
335 amount_cents: i64,
336 note: Option<String>,
337 agent_message: &str,
338 idempotency_key: uuid::Uuid,
339 ) -> Result<pb::RequestStarkPixOutflowResponse> {
340 let request = Request::new(pb::RequestStarkPixOutflowRequest {
341 target: Some(
342 pb::request_stark_pix_outflow_request::Target::PixKeyPaymentId(pix_key_payment_id),
343 ),
344 funding: Some(
345 pb::request_stark_pix_outflow_request::Funding::CheckingCents(
346 u64::try_from(amount_cents)
347 .map_err(|_| anyhow!("amount_cents must be non-negative"))?,
348 ),
349 ),
350 uuid: idempotency_key.to_string(),
351 message: note,
352 optin_cardv2: true,
353 schedule: None,
354 agent_message: agent_message.to_string(),
355 });
356 let request = self.authorize(request);
357 let response = self
358 .inner
359 .request_stark_pix_outflow(request)
360 .await?
361 .into_inner();
362
363 Ok(response)
364 }
365
366 #[allow(deprecated)]
367 pub async fn preview_stark_pix_brcode(
368 &mut self,
369 brcode: &str,
370 idempotency_key: &str,
371 ) -> Result<pb::PreviewStarkPixBrcodeResponse> {
372 let request = Request::new(pb::PreviewStarkPixBrcodeRequest {
373 brcode: brcode.to_string(),
374 idempotency_key: idempotency_key.to_string(),
375 });
376 let request = self.authorize(request);
377 let response = self
378 .inner
379 .preview_stark_pix_brcode(request)
380 .await?
381 .into_inner();
382 Ok(response)
383 }
384
385 pub async fn request_stark_pix_outflow_brcode(
386 &mut self,
387 pix_qrcode_payment_id: i32,
388 amount_cents: i64,
389 note: Option<String>,
390 agent_message: &str,
391 idempotency_key: uuid::Uuid,
392 ) -> Result<pb::RequestStarkPixOutflowResponse> {
393 let request = Request::new(pb::RequestStarkPixOutflowRequest {
394 target: Some(
395 pb::request_stark_pix_outflow_request::Target::PixQrcodePaymentId(
396 pix_qrcode_payment_id,
397 ),
398 ),
399 funding: Some(
400 pb::request_stark_pix_outflow_request::Funding::CheckingCents(
401 u64::try_from(amount_cents)
402 .map_err(|_| anyhow!("amount_cents must be non-negative"))?,
403 ),
404 ),
405 uuid: idempotency_key.to_string(),
406 message: note,
407 optin_cardv2: true,
408 schedule: None,
409 agent_message: agent_message.to_string(),
410 });
411 let request = self.authorize(request);
412 let response = self
413 .inner
414 .request_stark_pix_outflow(request)
415 .await?
416 .into_inner();
417 Ok(response)
418 }
419
420 pub async fn get_stark_pix_outflow_status(
421 &mut self,
422 id: i32,
423 ) -> Result<pb::GetStarkPixOutflowRequestStatusResponse> {
424 let request = Request::new(pb::GetStarkPixOutflowRequestStatusRequest { id });
425 let request = self.authorize(request);
426 let response = self
427 .inner
428 .get_stark_pix_outflow_request_status(request)
429 .await?
430 .into_inner();
431 Ok(response)
432 }
433
434 pub async fn poll_outflow_status(&mut self, id: i32) -> Result<OutflowPollResult> {
436 use pb::get_stark_pix_outflow_request_status_response::Status;
437
438 for _ in 0..POLL_MAX_ATTEMPTS {
439 tokio::time::sleep(POLL_INTERVAL).await;
440
441 let resp = self.get_stark_pix_outflow_status(id).await?;
442
443 match &resp.status {
444 Some(Status::Sent(_)) => {
445 return Ok(OutflowPollResult::Sent {
446 recipient_name: resp.recipient_name,
447 });
448 }
449 Some(Status::Approved(_)) | Some(Status::AwaitingUserApproval(_)) => {
450 }
452 Some(Status::DeniedByUser(_)) => bail!("payment denied by user"),
453 Some(Status::Failed(_)) => bail!("pix payment failed"),
454 Some(Status::ManualReview(_)) => {
455 return Ok(OutflowPollResult::ManualReview {
456 recipient_name: resp.recipient_name,
457 });
458 }
459 None => bail!("empty status response while polling"),
460 }
461 }
462 bail!("pix payment timed out after polling; check history for status")
463 }
464
465 pub async fn get_pix_limits(&mut self) -> Result<pb::GetPixLimitsResponse> {
466 let request = Request::new(pb::Empty {});
467 let request = self.authorize(request);
468 let response = self.inner.get_pix_limits(request).await?;
469 Ok(response.into_inner())
470 }
471
472 pub async fn load_user_cli(&mut self) -> Result<pb::LoadUserCliResponse> {
473 let request = Request::new(());
474 let request = self.authorize(request);
475 let response = self.inner.load_user_cli(request).await?;
476 Ok(response.into_inner())
477 }
478
479 pub async fn balances(&mut self) -> Result<pb::BalancesResponse> {
480 let request = Request::new(pb::Empty {});
481 let request = self.authorize(request);
482 let response = self.inner.balances(request).await?;
483 Ok(response.into_inner())
484 }
485
486 pub async fn list_pix_transactions(
487 &mut self,
488 page_size: i32,
489 cursor: Option<String>,
490 ) -> Result<pb::ListPixTransactionsResponse> {
491 let request = Request::new(pb::ListPixTransactionsRequest {
492 page_size: page_size.clamp(1, 50),
493 cursor,
494 });
495 let request = self.authorize(request);
496 let response = self.inner.list_pix_transactions(request).await?;
497 Ok(response.into_inner())
498 }
499
500 pub async fn get_pix_transaction(
501 &mut self,
502 transaction_id: &str,
503 ) -> Result<pb::PixTransaction> {
504 let request = Request::new(pb::GetPixTransactionRequest {
505 id: transaction_id.to_string(),
506 });
507 let request = self.authorize(request);
508 let response = self.inner.get_pix_transaction(request).await?;
509 Ok(response.into_inner())
510 }
511}
512
513pub enum OutflowPollResult {
515 Sent { recipient_name: String },
516 ManualReview { recipient_name: String },
517}
518
519pub fn outflow_poll_id(response: &pb::RequestStarkPixOutflowResponse) -> Result<Option<i32>> {
524 use pb::request_stark_pix_outflow_response::Outcome;
525 match &response.outcome {
526 Some(Outcome::Started(id)) => Ok(Some(*id)),
527 Some(Outcome::AwaitingUserApproval(id)) => Ok(Some(*id)),
528 Some(Outcome::ImmediateScheduled(s)) => Ok(Some(s.outflow_request_id)),
529 Some(Outcome::Scheduled(_)) => Ok(None),
530 Some(Outcome::AboveMax(max)) => {
531 bail!("amount exceeds max limit (max: {max} cents)")
532 }
533 Some(Outcome::NoLimit(limit)) => {
534 bail!("no limit available (limit: {limit} cents)")
535 }
536 Some(Outcome::SelfPayment(_)) => bail!("cannot send pix to yourself"),
537 Some(Outcome::InsufficientFunds(_)) => bail!("insufficient funds"),
538 Some(Outcome::HasInflightOutflow(_)) => {
539 bail!("there is already an in-flight outflow")
540 }
541 Some(Outcome::DynamicBrcodeCannotBeScheduled(_)) => {
542 bail!("dynamic brcode cannot be scheduled")
543 }
544 Some(Outcome::ScheduledDateTooLong(_)) => {
545 bail!("scheduled date is too far in the future")
546 }
547 None => bail!("empty response from pix outflow request"),
548 }
549}
550
551fn request_idempotency_key() -> String {
552 Uuid::now_v7().to_string()
553}
554
555fn normalize_agent_name(raw: &str) -> String {
557 raw.chars()
558 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
559 .collect()
560}