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;
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    /// Poll `GetStarkPixOutflowRequestStatus` until a terminal status is reached.
380    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                    // still processing, keep polling
396                }
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
458/// Terminal outcome of polling a pix outflow request.
459pub enum OutflowPollResult {
460    Sent { recipient_name: String },
461    ManualReview { recipient_name: String },
462}
463
464/// Extract the poll ID from the outflow request response, or `None` for the
465/// `Scheduled` variant (which is terminal and doesn't need polling).
466///
467/// Returns `Err` for error outcomes (`AboveMax`, `InsufficientFunds`, etc.).
468pub 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
500/// Sanitize an agent name to only contain safe characters for HTTP headers.
501fn normalize_agent_name(raw: &str) -> String {
502    raw.chars()
503        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
504        .collect()
505}