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