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    #[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    /// Poll `GetStarkPixOutflowRequestStatus` until a terminal status is reached.
435    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                    // still processing, keep polling
451                }
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
513/// Terminal outcome of polling a pix outflow request.
514pub enum OutflowPollResult {
515    Sent { recipient_name: String },
516    ManualReview { recipient_name: String },
517}
518
519/// Extract the poll ID from the outflow request response, or `None` for the
520/// `Scheduled` variant (which is terminal and doesn't need polling).
521///
522/// Returns `Err` for error outcomes (`AboveMax`, `InsufficientFunds`, etc.).
523pub 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
555/// Sanitize an agent name to only contain safe characters for HTTP headers.
556fn normalize_agent_name(raw: &str) -> String {
557    raw.chars()
558        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
559        .collect()
560}