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 pub async fn get_stark_pix_outflow_status(
367 &mut self,
368 id: i32,
369 ) -> Result<pb::GetStarkPixOutflowRequestStatusResponse> {
370 let request = Request::new(pb::GetStarkPixOutflowRequestStatusRequest { id });
371 let request = self.authorize(request);
372 let response = self
373 .inner
374 .get_stark_pix_outflow_request_status(request)
375 .await?
376 .into_inner();
377 Ok(response)
378 }
379
380 pub async fn poll_outflow_status(&mut self, id: i32) -> Result<OutflowPollResult> {
382 use pb::get_stark_pix_outflow_request_status_response::Status;
383
384 for _ in 0..POLL_MAX_ATTEMPTS {
385 tokio::time::sleep(POLL_INTERVAL).await;
386
387 let resp = self.get_stark_pix_outflow_status(id).await?;
388
389 match &resp.status {
390 Some(Status::Sent(_)) => {
391 return Ok(OutflowPollResult::Sent {
392 recipient_name: resp.recipient_name,
393 });
394 }
395 Some(Status::Approved(_)) | Some(Status::AwaitingUserApproval(_)) => {
396 }
398 Some(Status::DeniedByUser(_)) => bail!("payment denied by user"),
399 Some(Status::Failed(_)) => bail!("pix payment failed"),
400 Some(Status::ManualReview(_)) => {
401 return Ok(OutflowPollResult::ManualReview {
402 recipient_name: resp.recipient_name,
403 });
404 }
405 None => bail!("empty status response while polling"),
406 }
407 }
408 bail!("pix payment timed out after polling; check history for status")
409 }
410
411 pub async fn get_pix_limits(&mut self) -> Result<pb::GetPixLimitsResponse> {
412 let request = Request::new(pb::Empty {});
413 let request = self.authorize(request);
414 let response = self.inner.get_pix_limits(request).await?;
415 Ok(response.into_inner())
416 }
417
418 pub async fn load_user_cli(&mut self) -> Result<pb::LoadUserCliResponse> {
419 let request = Request::new(());
420 let request = self.authorize(request);
421 let response = self.inner.load_user_cli(request).await?;
422 Ok(response.into_inner())
423 }
424
425 pub async fn balances(&mut self) -> Result<pb::BalancesResponse> {
426 let request = Request::new(pb::Empty {});
427 let request = self.authorize(request);
428 let response = self.inner.balances(request).await?;
429 Ok(response.into_inner())
430 }
431
432 pub async fn list_pix_transactions(
433 &mut self,
434 page_size: i32,
435 cursor: Option<String>,
436 ) -> Result<pb::ListPixTransactionsResponse> {
437 let request = Request::new(pb::ListPixTransactionsRequest {
438 page_size: page_size.clamp(1, 50),
439 cursor,
440 });
441 let request = self.authorize(request);
442 let response = self.inner.list_pix_transactions(request).await?;
443 Ok(response.into_inner())
444 }
445
446 pub async fn get_pix_transaction(
447 &mut self,
448 transaction_id: &str,
449 ) -> Result<pb::PixTransaction> {
450 let request = Request::new(pb::GetPixTransactionRequest {
451 id: transaction_id.to_string(),
452 });
453 let request = self.authorize(request);
454 let response = self.inner.get_pix_transaction(request).await?;
455 Ok(response.into_inner())
456 }
457}
458
459pub enum OutflowPollResult {
461 Sent { recipient_name: String },
462 ManualReview { recipient_name: String },
463}
464
465pub fn outflow_poll_id(response: &pb::RequestStarkPixOutflowResponse) -> Result<Option<i32>> {
470 use pb::request_stark_pix_outflow_response::Outcome;
471 match &response.outcome {
472 Some(Outcome::Started(id)) => Ok(Some(*id)),
473 Some(Outcome::AwaitingUserApproval(id)) => Ok(Some(*id)),
474 Some(Outcome::ImmediateScheduled(s)) => Ok(Some(s.outflow_request_id)),
475 Some(Outcome::Scheduled(_)) => Ok(None),
476 Some(Outcome::AboveMax(max)) => {
477 bail!("amount exceeds max limit (max: {max} cents)")
478 }
479 Some(Outcome::NoLimit(limit)) => {
480 bail!("no limit available (limit: {limit} cents)")
481 }
482 Some(Outcome::SelfPayment(_)) => bail!("cannot send pix to yourself"),
483 Some(Outcome::InsufficientFunds(_)) => bail!("insufficient funds"),
484 Some(Outcome::HasInflightOutflow(_)) => {
485 bail!("there is already an in-flight outflow")
486 }
487 Some(Outcome::DynamicBrcodeCannotBeScheduled(_)) => {
488 bail!("dynamic brcode cannot be scheduled")
489 }
490 Some(Outcome::ScheduledDateTooLong(_)) => {
491 bail!("scheduled date is too far in the future")
492 }
493 None => bail!("empty response from pix outflow request"),
494 }
495}
496
497fn request_idempotency_key() -> String {
498 Uuid::now_v7().to_string()
499}
500
501fn normalize_agent_name(raw: &str) -> String {
503 raw.chars()
504 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
505 .collect()
506}