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