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