pagbank-cli 0.2.0

CLI para as APIs do PagBank
use pagbank_sdk::PagBankError;

pub fn translate(err: &PagBankError) -> String {
    match err {
        PagBankError::Api {
            status,
            code,
            message,
        } => {
            let friendly = api_message(status, code, message);
            format!("[{}] {friendly}", status)
        }
        PagBankError::ApiRaw { status, body } => {
            let friendly = api_raw_message(status, body);
            format!("[{}] {friendly}", status)
        }
        PagBankError::Network(_) => {
            "erro de rede: verifique sua conexão com a internet".to_string()
        }
        PagBankError::Serialization(_) => "erro interno ao processar a resposta da API".to_string(),
        PagBankError::Url(_) => "URL inválida na requisição".to_string(),
        PagBankError::NoToken => {
            "token de autenticação não configurado. Use 'pb auth login --token <TOKEN>'".to_string()
        }
        PagBankError::NoRecurringToken => {
            "token de recorrência não configurado. Configure 'recurring_token' no config.toml"
                .to_string()
        }
        PagBankError::NoTransferToken => {
            "token de transferência não configurado. Use 'pb config set transfer_token <TOKEN>'"
                .to_string()
        }
        PagBankError::NoEdiCredentials => {
            "credenciais EDI não configuradas. Use 'pb config set edi_user <NÚMERO>' e 'pb config set edi_token <TOKEN>'"
                .to_string()
        }
        PagBankError::InvalidIdempotencyKey(_) => format!("{}", err),
        PagBankError::Auth(msg) => format!("erro de autenticação: {msg}"),
        PagBankError::Other(msg) => msg.clone(),
    }
}

fn api_message(status: &u16, code: &str, message: &str) -> String {
    let friendly = translate_description(message);
    if friendly != message {
        return friendly;
    }

    if let Some((param, detail)) = api_detail(message) {
        return match param {
            Some(p) => format!("parâmetro '{p}': {detail}"),
            None => detail,
        };
    }

    if let Some(msg) = match code {
        "40001" => Some("campo obrigatório não informado. Verifique se todos os campos obrigatórios foram preenchidos"),
        "40002" => Some("formato inválido. Verifique o formato dos dados enviados"),
        "42001" => Some("conta já existe no PagBank. Use a API Connect (OAuth2) para solicitar permissão de acesso"),
        "42002" => Some("criação de conta iniciada por outro canal. O usuário precisa acessar o e-mail para finalizar"),
        _ => None,
    } {
        return msg.to_string();
    }

    match *status {
        401 => {
            "token inválido. Use 'pb auth login --token <TOKEN>' com um token válido".to_string()
        }
        403 => "sem permissão para acessar este recurso na sua conta PagBank".to_string(),
        404 => "recurso não encontrado. Verifique o ID informado".to_string(),
        422 => "dados inválidos. Verifique os campos enviados".to_string(),
        429 => "muitas requisições. Aguarde alguns segundos e tente novamente".to_string(),
        500..=599 => "erro interno do PagBank. Tente novamente mais tarde".to_string(),
        _ => message.to_string(),
    }
}

fn api_raw_message(status: &u16, body: &str) -> String {
    match status {
        401 => "token inválido ou não autorizado".to_string(),
        403 => "sem permissão para acessar este recurso".to_string(),
        404 => "recurso não encontrado".to_string(),
        429 => "muitas requisições. Aguarde alguns segundos".to_string(),
        500..=599 => "erro interno do PagBank. Tente novamente".to_string(),
        _ => format!("erro inesperado: {body}"),
    }
}

fn api_detail(message: &str) -> Option<(Option<String>, String)> {
    if let Ok(json) = serde_json::from_str::<serde_json::Value>(message) {
        if let Some(errors) = json.get("error_messages").and_then(|v| v.as_array()) {
            for e in errors {
                let desc = e.get("description").and_then(|v| v.as_str());
                let param = e.get("parameter_name").and_then(|v| v.as_str());
                if let Some(d) = desc {
                    return Some((param.map(String::from), translate_description(d)));
                }
            }
        }
    }
    None
}

fn split_flat(desc: &str) -> (&str, &str) {
    if desc.starts_with('{') {
        return ("", desc);
    }
    let parts: Vec<&str> = desc.splitn(2, ": ").collect();
    if parts.len() == 2 {
        (parts[0].trim(), parts[1].trim())
    } else {
        ("", desc)
    }
}

fn translate_description(desc: &str) -> String {
    let (param, description) = split_flat(desc);

    if description.contains("must have at least 1 element") {
        return if param.is_empty() {
            "é necessário informar ao menos 1 item".to_string()
        } else {
            format!("é necessário informar ao menos 1 item em '{param}'")
        };
    }

    if description.contains("Invalid credential") || description.contains("Review AUTHORIZATION") {
        return "token inválido. Use 'pb auth login --token <TOKEN>' com um token válido"
            .to_string();
    }

    if description.contains("No known parameter was given")
        || description.contains("Invalid search parameters")
    {
        let example = "Exemplo: pb orders list --charge-id CHAR_XXXX".to_string();
        let base = if param.is_empty() {
            "nenhum parâmetro de busca informado".to_string()
        } else {
            format!("parâmetro '{param}': nenhum parâmetro de busca informado")
        };
        return format!("{base}\n{example}");
    }

    if description.contains("not authorized") || description.contains("explicit deny") {
        return "token sem permissão para este recurso na sua conta PagBank".to_string();
    }

    if description.contains("not found") {
        return "recurso não encontrado. Verifique o ID informado".to_string();
    }

    if description.contains("rate limit") || description.contains("too many requests") {
        return "muitas requisições. Aguarde alguns segundos e tente novamente".to_string();
    }

    desc.to_string()
}

pub fn validate_order_create(
    method: &str,
    qr_amount: Option<i64>,
    card: Option<&crate::validators::CardInfo>,
) -> Result<(), String> {
    match method.to_lowercase().as_str() {
        "pix" => {
            if let Some(qa) = qr_amount {
                crate::validators::validate_amount(qa, "qr_amount")?;
            }
            Ok(())
        }
        "credit_card" | "debit_card" => {
            if let Some(card) = card {
                crate::validators::validate_card(card)?;
            }
            Ok(())
        }
        _ => Err(format!(
            "método de pagamento inválido: {method}. Use pix, credit_card ou debit_card"
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn translate_400_qr_codes() {
        let err = PagBankError::Api {
            status: 400,
            code: "40002".to_string(),
            message: "qr_codes: must have at least 1 element".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("qr_codes"));
    }

    #[test]
    fn translate_400_qr_codes_raw_json() {
        let err = PagBankError::Api {
            status: 400,
            code: "40002".to_string(),
            message: "qr_codes: must have at least 1 element".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("qr_codes"));
    }

    #[test]
    fn translate_403_message() {
        let err = PagBankError::Api {
            status: 403,
            code: "ACCESS_DENIED".to_string(),
            message: "User is not authorized to access this resource with an explicit deny in an identity-based policy".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("sem permissão"));
    }

    #[test]
    fn translate_401() {
        let err = PagBankError::Api {
            status: 401,
            code: "UNAUTHORIZED".to_string(),
            message: "invalid token".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("token inválido"));
    }

    #[test]
    fn translate_403() {
        let err = PagBankError::Api {
            status: 403,
            code: "FORBIDDEN".to_string(),
            message: "denied".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("sem permissão"));
    }

    #[test]
    fn translate_404() {
        let err = PagBankError::Api {
            status: 404,
            code: "NOT_FOUND".to_string(),
            message: "order not found".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("não encontrado"));
    }

    #[test]
    fn translate_429() {
        let err = PagBankError::Api {
            status: 429,
            code: "RATE_LIMIT".to_string(),
            message: "too many".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("muitas requisições"));
    }

    #[test]
    fn translate_500() {
        let err = PagBankError::Api {
            status: 500,
            code: "INTERNAL".to_string(),
            message: "server error".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("erro interno do PagBank"));
    }

    #[test]
    fn translate_no_token() {
        let err = PagBankError::NoToken;
        let msg = translate(&err);
        assert!(msg.contains("auth login"));
    }

    #[test]
    fn translate_no_transfer_token() {
        let msg = translate(&PagBankError::NoTransferToken);
        assert!(msg.contains("transfer_token"));
    }

    #[test]
    fn translate_no_edi_credentials() {
        let msg = translate(&PagBankError::NoEdiCredentials);
        assert!(msg.contains("edi_user"));
        assert!(msg.contains("edi_token"));
    }

    #[test]
    fn translate_api_raw_401() {
        let err = PagBankError::ApiRaw {
            status: 401,
            body: "unauthorized".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("token inválido"));
    }

    #[test]
    fn translate_account_code_40001() {
        let err = PagBankError::Api {
            status: 400,
            code: "40001".to_string(),
            message: "required".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("campo obrigatório"));
    }

    #[test]
    fn translate_account_code_42001() {
        let err = PagBankError::Api {
            status: 400,
            code: "42001".to_string(),
            message: "account exists".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("já existe"));
        assert!(msg.contains("Connect"));
    }

    #[test]
    fn translate_account_code_42002() {
        let err = PagBankError::Api {
            status: 400,
            code: "42002".to_string(),
            message: "another channel".to_string(),
        };
        let msg = translate(&err);
        assert!(msg.contains("e-mail"));
    }

    #[test]
    fn validate_order_create_pix_without_qr() {
        let result = validate_order_create("pix", None, None);
        assert!(result.is_ok());
    }

    #[test]
    fn validate_order_create_pix_with_qr() {
        let result = validate_order_create("pix", Some(100), None);
        assert!(result.is_ok());
    }

    #[test]
    fn validate_order_create_pix_with_invalid_qr() {
        let result = validate_order_create("pix", Some(0), None);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("qr_amount"));
    }

    #[test]
    fn validate_order_create_credit_card() {
        let card = crate::validators::CardInfo {
            number: "4242424242424242",
            exp_month: Some(12),
            exp_year: Some(2027),
            cvv: Some("123"),
        };
        let result = validate_order_create("credit_card", None, Some(&card));
        assert!(result.is_ok());
    }

    #[test]
    fn validate_order_create_credit_card_invalid() {
        let card = crate::validators::CardInfo {
            number: "4242424242424241",
            exp_month: Some(12),
            exp_year: Some(2027),
            cvv: Some("123"),
        };
        let result = validate_order_create("credit_card", None, Some(&card));
        assert!(result.is_err());
    }

    #[test]
    fn validate_order_create_invalid_method() {
        let result = validate_order_create("boleto", None, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("método"));
    }
}