Skip to main content

agentis_pay/
commands.rs

1use std::io::{self, Write};
2
3use anyhow::{Context, Result, bail};
4use clap::{ArgGroup, Args, value_parser};
5use std::fmt;
6
7pub mod account;
8pub mod balance;
9pub mod brcode;
10pub mod deposit;
11pub mod history;
12pub mod limits;
13pub mod login;
14pub mod logout;
15pub mod mcp;
16pub mod pix;
17pub mod signup;
18pub mod skill;
19pub mod update;
20pub mod verify;
21pub mod whoami;
22
23#[derive(Args, Debug, Clone)]
24pub struct SignupArgs {
25    /// Email used to begin registration and OTP flow.
26    #[arg(short, long, value_name = "EMAIL", value_parser = value_parser!(String))]
27    pub email: Option<String>,
28
29    /// CPF for registration (digits only; formatting is handled later).
30    #[arg(short = 'c', long, value_name = "CPF", value_parser = parse_digits)]
31    pub cpf: Option<String>,
32
33    /// Phone in international format, e.g. +5511999999999.
34    #[arg(short, long, value_name = "PHONE", value_parser = parse_digits)]
35    pub phone: Option<String>,
36
37    /// Optional agent identity name (only used when explicitly provided).
38    #[arg(
39        short = 'a',
40        long = "agent-name",
41        value_name = "NAME",
42        env = "AGENTIS_PAY_AGENT_NAME"
43    )]
44    pub agent_name: Option<String>,
45}
46
47#[derive(Args, Debug, Clone)]
48pub struct LoginArgs {
49    /// Use OAuth PKCE web login (default if a method is chosen).
50    #[arg(long)]
51    pub web: bool,
52
53    /// Use legacy PIN-based login (requires separate `verify` step).
54    #[arg(long, conflicts_with = "web")]
55    pub pin: bool,
56
57    /// Login email (only used with --pin).
58    #[arg(short, long, value_name = "EMAIL", value_parser = value_parser!(String))]
59    pub email: Option<String>,
60
61    /// Login phone number (only used with --pin).
62    #[arg(short = 'p', long = "phone", value_name = "PHONE", value_parser = parse_digits)]
63    pub phone: Option<String>,
64
65    /// Open the authorization URL in the default browser automatically.
66    /// Without this flag, the URL is printed to stdout for agent-friendly workflows.
67    #[arg(long)]
68    pub open: bool,
69
70    /// Optional agent identity name (only used when explicitly provided).
71    #[arg(
72        short = 'a',
73        long = "agent-name",
74        value_name = "NAME",
75        env = "AGENTIS_PAY_AGENT_NAME"
76    )]
77    pub agent_name: Option<String>,
78}
79
80#[derive(Args, Debug, Clone)]
81pub struct VerifyArgs {
82    /// PIN received via email or phone after running `agentis-pay login --pin`.
83    #[arg(value_name = "PIN")]
84    pub pin: String,
85}
86
87#[derive(Debug, Clone)]
88pub enum LoginIdentifier {
89    Email(String),
90    Phone(String),
91}
92
93impl LoginIdentifier {
94    pub fn label(&self) -> &'static str {
95        match self {
96            Self::Email(_) => "email",
97            Self::Phone(_) => "phone",
98        }
99    }
100}
101
102impl fmt::Display for LoginIdentifier {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::Email(value) => write!(f, "Email {value}"),
106            Self::Phone(value) => write!(f, "Phone {value}"),
107        }
108    }
109}
110
111pub fn prompt_login_identifier() -> Result<LoginIdentifier> {
112    println!("> Select login method:");
113    println!("  1) Email");
114    println!("  2) Phone");
115    let choice = prompt("Select [1/2]:")?;
116
117    match choice.as_str() {
118        "1" => Ok(LoginIdentifier::Email(prompt("Email:")?)),
119        "2" => Ok(LoginIdentifier::Phone(prompt("Phone:")?)),
120        _ => bail!("invalid selection"),
121    }
122}
123
124#[derive(Args, Debug, Clone)]
125#[command(
126    group(ArgGroup::new("pix-recipient").required(true).args(["key", "brcode"])),
127    group(
128        ArgGroup::new("pix-amount")
129            .required(true)
130            .args(["amount_cents", "amount"])
131    )
132)]
133pub struct PixSendArgs {
134    /// Recipient Pix key.
135    #[arg(short, long, value_name = "KEY", conflicts_with = "brcode")]
136    pub key: Option<String>,
137
138    /// PIX Copia e Cola payload.
139    #[arg(
140        short = 'b',
141        long,
142        value_name = "BR_CODE",
143        required = false,
144        conflicts_with = "key"
145    )]
146    pub brcode: Option<String>,
147
148    /// Amount in cents.
149    #[arg(
150        short = 'a',
151        long,
152        value_name = "CENTS",
153        value_parser = parse_positive_cents,
154        conflicts_with = "amount"
155    )]
156    pub amount_cents: Option<i64>,
157
158    /// Amount in BRL (e.g. 12.34 or 12,34).
159    #[arg(long, value_name = "BRL", value_parser = parse_brl_amount, conflicts_with = "amount_cents")]
160    pub amount: Option<i64>,
161
162    /// Optional memo for the transfer.
163    #[arg(short, long, value_name = "NOTE")]
164    pub note: Option<String>,
165
166    /// Agent explanation shown to the user during approval.
167    #[arg(long, value_name = "AGENT_MESSAGE")]
168    pub agent_message: String,
169}
170
171#[derive(Args, Debug, Clone)]
172pub struct BrcodeArgs {
173    /// RAW BR Code / Copia e Cola string.
174    #[arg(value_name = "BRCODE")]
175    pub code: String,
176}
177
178#[derive(Args, Debug, Clone)]
179pub struct HistoryArgs {
180    /// Optional transaction id. When omitted, shows a compact list.
181    #[arg(value_name = "TRANSACTION_ID")]
182    pub id: Option<String>,
183
184    /// Maximum number of transactions to fetch (max 50). If omitted, defaults to 20.
185    #[arg(
186        short,
187        long,
188        default_value_t = 20u32,
189        value_name = "COUNT",
190        value_parser = parse_history_limit
191    )]
192    pub limit: u32,
193
194    /// Cursor for pagination. Use the `next_cursor` value from a previous response.
195    #[arg(short, long, value_name = "CURSOR")]
196    pub cursor: Option<String>,
197
198    /// Render sample data without server connection (debug builds only).
199    #[cfg(debug_assertions)]
200    #[arg(long)]
201    pub demo: bool,
202}
203
204pub fn prompt(label: &str) -> Result<String> {
205    print!("{label} ");
206    io::stdout()
207        .flush()
208        .context("failed to flush stdout for prompt")?;
209    let mut value = String::new();
210    io::stdin()
211        .read_line(&mut value)
212        .context("failed to read user input")?;
213    let value = value.trim().to_string();
214    if value.is_empty() {
215        bail!("input cannot be empty");
216    }
217
218    Ok(value)
219}
220
221fn parse_positive_cents(value: &str) -> std::result::Result<i64, String> {
222    let parsed = value
223        .parse::<i64>()
224        .map_err(|_| format!("{value} is not a valid amount in cents"))?;
225
226    if parsed <= 0 {
227        return Err("amount must be greater than zero".into());
228    }
229
230    Ok(parsed)
231}
232
233/// Parse a plain numeric BRL amount into cents.
234///
235/// Accepts a number with an optional decimal separator (`.` or `,`)
236/// followed by 1-2 digits. No thousand separators or formatting.
237///
238/// Examples: `"12.34"` → 1234, `"50"` → 5000, `"0.5"` → 50
239fn parse_brl_amount(value: &str) -> std::result::Result<i64, String> {
240    let trimmed = value.trim();
241    if trimmed.is_empty() {
242        return Err("amount cannot be empty".into());
243    }
244
245    if !trimmed
246        .chars()
247        .all(|c| c.is_ascii_digit() || matches!(c, '.' | ','))
248    {
249        return Err(format!(
250            "{value} is not a valid amount; pass a plain number like 12.34"
251        ));
252    }
253
254    let separator_count = trimmed.chars().filter(|c| matches!(c, '.' | ',')).count();
255    if separator_count > 1 {
256        return Err(format!(
257            "{value} is not a valid amount; use a single decimal separator like 12.34 or 12,34"
258        ));
259    }
260
261    let amount_cents = if let Some(sep_index) = trimmed.find(['.', ',']) {
262        let integer_part = &trimmed[..sep_index];
263        let fraction_part = &trimmed[sep_index + 1..];
264
265        if fraction_part.len() > 2 {
266            return Err(format!(
267                "{value} has too many decimal digits; use at most 2 (e.g. 12.34)"
268            ));
269        }
270
271        let integer: i64 = if integer_part.is_empty() {
272            0
273        } else {
274            integer_part
275                .parse()
276                .map_err(|_| format!("{value} is not a valid amount"))?
277        };
278
279        let cents: i64 = if fraction_part.is_empty() {
280            0
281        } else {
282            let parsed: i64 = fraction_part
283                .parse()
284                .map_err(|_| format!("{value} is not a valid amount"))?;
285            if fraction_part.len() == 1 {
286                parsed * 10
287            } else {
288                parsed
289            }
290        };
291
292        integer
293            .checked_mul(100)
294            .and_then(|base| base.checked_add(cents))
295            .ok_or_else(|| String::from("amount is too large"))?
296    } else {
297        trimmed
298            .parse::<i64>()
299            .map_err(|_| format!("{value} is not a valid amount"))?
300            .checked_mul(100)
301            .ok_or_else(|| String::from("amount is too large"))?
302    };
303
304    if amount_cents <= 0 {
305        return Err("amount must be greater than zero".into());
306    }
307
308    Ok(amount_cents)
309}
310
311fn parse_history_limit(value: &str) -> std::result::Result<u32, String> {
312    let parsed = value
313        .parse::<u32>()
314        .map_err(|_| format!("{value} is not a valid history limit"))?;
315
316    if parsed == 0 {
317        return Err("history limit must be greater than zero".into());
318    }
319
320    if parsed > 50 {
321        return Err("history limit cannot exceed 50".into());
322    }
323
324    Ok(parsed)
325}
326
327fn parse_digits(value: &str) -> std::result::Result<String, String> {
328    let trimmed = value.trim();
329    let has_digits_only = trimmed
330        .chars()
331        .all(|c| c.is_ascii_digit() || matches!(c, '+' | '.' | ' ' | '(' | ')'));
332
333    if !has_digits_only {
334        return Err(
335            "must contain only digits and common separators (+, ., spaces, parentheses)".into(),
336        );
337    }
338
339    let normalized: String = trimmed.chars().filter(|c| c.is_ascii_digit()).collect();
340    if normalized.is_empty() {
341        return Err("must contain at least one digit".into());
342    }
343
344    Ok(normalized)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn parse_positive_cents_rejects_zero() {
353        assert!(parse_positive_cents("0").is_err());
354    }
355
356    #[test]
357    fn parse_positive_cents_rejects_negative() {
358        assert!(parse_positive_cents("-1").is_err());
359    }
360
361    #[test]
362    fn parse_positive_cents_accepts_valid_value() {
363        assert_eq!(parse_positive_cents("1250").expect("valid"), 1250);
364    }
365
366    #[test]
367    fn parse_amount_brl_accepts_dot_decimal() {
368        assert_eq!(parse_brl_amount("12.34").expect("valid"), 1234);
369    }
370
371    #[test]
372    fn parse_amount_brl_accepts_comma_decimal() {
373        assert_eq!(parse_brl_amount("12,34").expect("valid"), 1234);
374    }
375
376    #[test]
377    fn parse_amount_brl_rejects_zero() {
378        assert!(parse_brl_amount("0").is_err());
379    }
380
381    #[test]
382    fn parse_amount_brl_accepts_whole_number() {
383        assert_eq!(parse_brl_amount("50").expect("valid"), 5000);
384    }
385
386    #[test]
387    fn parse_amount_brl_accepts_single_decimal_digit() {
388        assert_eq!(parse_brl_amount("12.5").expect("valid"), 1250);
389        assert_eq!(parse_brl_amount("12,5").expect("valid"), 1250);
390    }
391
392    #[test]
393    fn parse_amount_brl_accepts_leading_decimal() {
394        assert_eq!(parse_brl_amount(".50").expect("valid"), 50);
395        assert_eq!(parse_brl_amount(",99").expect("valid"), 99);
396    }
397
398    #[test]
399    fn parse_amount_brl_rejects_multiple_separators() {
400        assert!(parse_brl_amount("12,34,56").is_err());
401        assert!(parse_brl_amount("1.234,56").is_err());
402        assert!(parse_brl_amount("1,234.56").is_err());
403    }
404
405    #[test]
406    fn parse_amount_brl_rejects_three_decimal_digits() {
407        assert!(parse_brl_amount("12.345").is_err());
408        assert!(parse_brl_amount("12,345").is_err());
409    }
410
411    #[test]
412    fn parse_amount_brl_rejects_letters() {
413        assert!(parse_brl_amount("R$12.34").is_err());
414        assert!(parse_brl_amount("abc").is_err());
415    }
416
417    #[test]
418    fn parse_amount_brl_rejects_empty() {
419        assert!(parse_brl_amount("").is_err());
420        assert!(parse_brl_amount("   ").is_err());
421    }
422
423    #[test]
424    fn parse_digits_accepts_international_phone() {
425        assert_eq!(
426            parse_digits("+55 (11) 9 9999 9999").expect("valid"),
427            "5511999999999".to_string()
428        );
429    }
430
431    #[test]
432    fn parse_digits_rejects_hyphens() {
433        assert!(parse_digits("1-2-3").is_err());
434        assert!(parse_digits("+55 (11) 9 9999-9999").is_err());
435    }
436
437    #[test]
438    fn parse_digits_rejects_non_digits() {
439        assert!(parse_digits("abc-123").is_err());
440    }
441
442    #[test]
443    fn parse_digits_rejects_empty_string() {
444        assert!(parse_digits("   + .().").is_err());
445    }
446}