Skip to main content

agentis_pay_shared/
client.rs

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    /// Poll `GetStarkPixOutflowRequestStatus` until a terminal status is reached.
381    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                    // still processing, keep polling
397                }
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_detailed_transaction(
447        &mut self,
448        transaction_id: &str,
449    ) -> Result<pb::DetailedTransaction> {
450        let request = Request::new(pb::GetDetailedTransactionRequest {
451            id: transaction_id.to_string(),
452        });
453        let request = self.authorize(request);
454        let response = self.inner.get_detailed_transaction(request).await?;
455        Ok(response.into_inner())
456    }
457}
458
459/// Terminal outcome of polling a pix outflow request.
460pub enum OutflowPollResult {
461    Sent { recipient_name: String },
462    ManualReview { recipient_name: String },
463}
464
465/// Extract the poll ID from the outflow request response, or `None` for the
466/// `Scheduled` variant (which is terminal and doesn't need polling).
467///
468/// Returns `Err` for error outcomes (`AboveMax`, `InsufficientFunds`, etc.).
469pub 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
501/// Sanitize an agent name to only contain safe characters for HTTP headers.
502fn normalize_agent_name(raw: &str) -> String {
503    raw.chars()
504        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
505        .collect()
506}