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: &str,
318 agent_message: &str,
319 ) -> Result<pb::RequestStarkPixOutflowResponse> {
320 self.request_stark_pix_outflow_with_request_id(
321 pix_key_payment_id,
322 amount_cents,
323 note,
324 agent_message,
325 None,
326 )
327 .await
328 }
329
330 pub async fn request_stark_pix_outflow_with_request_id(
331 &mut self,
332 pix_key_payment_id: i32,
333 amount_cents: i64,
334 note: &str,
335 agent_message: &str,
336 request_id: Option<&str>,
337 ) -> Result<pb::RequestStarkPixOutflowResponse> {
338 let request_id = request_id
339 .filter(|value| !value.trim().is_empty())
340 .map(ToString::to_string)
341 .unwrap_or_else(request_idempotency_key);
342 let request = Request::new(pb::StartStarkPixOutflowRequest {
343 target: Some(
344 pb::start_stark_pix_outflow_request::Target::PixKeyPaymentId(pix_key_payment_id),
345 ),
346 funding: Some(pb::start_stark_pix_outflow_request::Funding::CheckingCents(
347 u64::try_from(amount_cents)
348 .map_err(|_| anyhow!("amount_cents must be non-negative"))?,
349 )),
350 uuid: request_id,
351 message: if note.is_empty() {
352 None
353 } else {
354 Some(note.to_string())
355 },
356 optin_cardv2: true,
357 schedule: None,
358 agent_message: agent_message.to_string(),
359 });
360 let request = self.authorize(request);
361 let response = self
362 .inner
363 .request_stark_pix_outflow(request)
364 .await?
365 .into_inner();
366
367 Ok(response)
368 }
369
370 pub async fn get_stark_pix_outflow_status(
371 &mut self,
372 id: i32,
373 ) -> Result<pb::GetStarkPixOutflowRequestStatusResponse> {
374 let request = Request::new(pb::GetStarkPixOutflowRequestStatusRequest { id });
375 let request = self.authorize(request);
376 let response = self
377 .inner
378 .get_stark_pix_outflow_request_status(request)
379 .await?
380 .into_inner();
381 Ok(response)
382 }
383
384 pub async fn poll_outflow_status(&mut self, id: i32) -> Result<OutflowPollResult> {
386 use pb::get_stark_pix_outflow_request_status_response::Status;
387
388 for _ in 0..POLL_MAX_ATTEMPTS {
389 tokio::time::sleep(POLL_INTERVAL).await;
390
391 let resp = self.get_stark_pix_outflow_status(id).await?;
392
393 match &resp.status {
394 Some(Status::Sent(_)) => {
395 return Ok(OutflowPollResult::Sent {
396 recipient_name: resp.recipient_name,
397 });
398 }
399 Some(Status::Approved(_)) | Some(Status::AwaitingUserApproval(_)) => {
400 }
402 Some(Status::DeniedByUser(_)) => bail!("payment denied by user"),
403 Some(Status::Failed(_)) => bail!("pix payment failed"),
404 Some(Status::ManualReview(_)) => {
405 return Ok(OutflowPollResult::ManualReview {
406 recipient_name: resp.recipient_name,
407 });
408 }
409 None => bail!("empty status response while polling"),
410 }
411 }
412 bail!("pix payment timed out after polling; check history for status")
413 }
414
415 pub async fn get_pix_limits(&mut self) -> Result<pb::GetPixLimitsResponse> {
416 let request = Request::new(pb::Empty {});
417 let request = self.authorize(request);
418 let response = self.inner.get_pix_limits(request).await?;
419 Ok(response.into_inner())
420 }
421
422 pub async fn load_user_cli(&mut self) -> Result<pb::LoadUserCliResponse> {
423 let request = Request::new(());
424 let request = self.authorize(request);
425 let response = self.inner.load_user_cli(request).await?;
426 Ok(response.into_inner())
427 }
428
429 pub async fn balances(&mut self) -> Result<pb::BalancesResponse> {
430 let request = Request::new(pb::Empty {});
431 let request = self.authorize(request);
432 let response = self.inner.balances(request).await?;
433 Ok(response.into_inner())
434 }
435
436 pub async fn list_pix_transactions(
437 &mut self,
438 page_size: i32,
439 cursor: Option<String>,
440 ) -> Result<pb::ListPixTransactionsResponse> {
441 let request = Request::new(pb::ListPixTransactionsRequest {
442 page_size: page_size.clamp(1, 50),
443 cursor,
444 });
445 let request = self.authorize(request);
446 let response = self.inner.list_pix_transactions(request).await?;
447 Ok(response.into_inner())
448 }
449
450 pub async fn get_detailed_transaction(
451 &mut self,
452 transaction_id: &str,
453 ) -> Result<pb::DetailedTransaction> {
454 let request = Request::new(pb::GetDetailedTransactionRequest {
455 id: transaction_id.to_string(),
456 });
457 let request = self.authorize(request);
458 let response = self.inner.get_detailed_transaction(request).await?;
459 Ok(response.into_inner())
460 }
461}
462
463pub enum OutflowPollResult {
465 Sent { recipient_name: String },
466 ManualReview { recipient_name: String },
467}
468
469pub fn outflow_poll_id(response: &pb::RequestStarkPixOutflowResponse) -> Result<Option<i32>> {
474 use pb::request_stark_pix_outflow_response::Outcome;
475 match &response.outcome {
476 Some(Outcome::Started(id)) => Ok(Some(*id)),
477 Some(Outcome::AwaitingUserApproval(id)) => Ok(Some(*id)),
478 Some(Outcome::ImmediateScheduled(s)) => Ok(Some(s.outflow_request_id)),
479 Some(Outcome::Scheduled(_)) => Ok(None),
480 Some(Outcome::AboveMax(max)) => {
481 bail!("amount exceeds max limit (max: {max} cents)")
482 }
483 Some(Outcome::NoLimit(limit)) => {
484 bail!("no limit available (limit: {limit} cents)")
485 }
486 Some(Outcome::SelfPayment(_)) => bail!("cannot send pix to yourself"),
487 Some(Outcome::InsufficientFunds(_)) => bail!("insufficient funds"),
488 Some(Outcome::HasInflightOutflow(_)) => {
489 bail!("there is already an in-flight outflow")
490 }
491 Some(Outcome::DynamicBrcodeCannotBeScheduled(_)) => {
492 bail!("dynamic brcode cannot be scheduled")
493 }
494 Some(Outcome::ScheduledDateTooLong(_)) => {
495 bail!("scheduled date is too far in the future")
496 }
497 None => bail!("empty response from pix outflow request"),
498 }
499}
500
501fn request_idempotency_key() -> String {
502 Uuid::now_v7().to_string()
503}
504
505fn normalize_agent_name(raw: &str) -> String {
507 raw.chars()
508 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
509 .collect()
510}