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