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: &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    /// Poll `GetStarkPixOutflowRequestStatus` until a terminal status is reached.
385    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                    // still processing, keep polling
401                }
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
463/// Terminal outcome of polling a pix outflow request.
464pub enum OutflowPollResult {
465    Sent { recipient_name: String },
466    ManualReview { recipient_name: String },
467}
468
469/// Extract the poll ID from the outflow request response, or `None` for the
470/// `Scheduled` variant (which is terminal and doesn't need polling).
471///
472/// Returns `Err` for error outcomes (`AboveMax`, `InsufficientFunds`, etc.).
473pub 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
505/// Sanitize an agent name to only contain safe characters for HTTP headers.
506fn normalize_agent_name(raw: &str) -> String {
507    raw.chars()
508        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
509        .collect()
510}