Skip to content
Last updated

Simulador CLI

::: info Ferramenta de exploração local Este simulador é uma ferramenta de teste rápido para entender o funcionamento da API. Não é um SDK e não deve ser usado em produção. O propósito é "rode e veja a API responder em 2 minutos, sem montar requests à mão". :::

O simulador é um único script (sem dependências externas além do runtime) que cobre todos os endpoints da API v2 de Parceiros: descoberta de identidade, webhooks, placas, transações e pedidos. Está disponível em 5 linguagens, todas com a mesma experiência: mesmo menu, mesmo formato de output, mesmo .env.

Pré-requisitos

LinguagemRuntime mínimo
Node.jsNode 18+
PythonPython 3.8+
GoGo 1.21+
JavaJava 11+
BashBash 4+ + curl + jq

Você também precisa de credenciais de homologação. Veja Sandbox para solicitar.

Passo 1 — Criar a pasta

mkdir simulador-movvia && cd simulador-movvia

Passo 2 — Salvar o simulador

Escolha uma das linguagens abaixo e copie o conteúdo do code block para o arquivo correspondente.

#!/usr/bin/env node
// Utilitário de teste para API Movvia Arrecada+ (PE)
// Requer Node.js 18+

import { createInterface } from 'readline';
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dir = dirname(fileURLToPath(import.meta.url));

// ─── Carregar .env ────────────────────────────────────────────────────────────
const envPath = resolve(__dir, '.env');
if (existsSync(envPath)) {
  for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
    const t = line.trim();
    if (!t || t.startsWith('#')) continue;
    const eq = t.indexOf('=');
    if (eq < 1) continue;
    const key = t.slice(0, eq).trim();
    const val = t.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
    if (key && !process.env[key]) process.env[key] = val;
  }
}

const BASE_URL =
  process.env.BASE_URL ||
  'https://hml.api.pedagioeletronico.com.br/gestao-webhooks-api/v1';

// ─── Identidade do parceiro ───────────────────────────────────────────────────
// Resolvido no startup: lê do .env (PE_PARCEIRO_ID) ou chama GET /me
let cachedParceiroId = process.env.PE_PARCEIRO_ID || null;

async function resolveParceiroId() {
  if (cachedParceiroId) return cachedParceiroId;

  const res = await fetch(`${BASE_URL}/me`, {
    method: 'GET',
    headers: { Authorization: authHeader(), 'Content-Type': 'application/json' },
  });
  const json = await res.json().catch(() => null);

  if (!res.ok || !json?.data?.parceiroId) {
    console.error('❌  Não foi possível descobrir o parceiroId via GET /me');
    console.error('   ', JSON.stringify(json));
    process.exit(1);
  }

  cachedParceiroId = String(json.data.parceiroId);
  console.log(`  ℹ️  parceiroId descoberto via GET /me: ${cachedParceiroId}`);
  return cachedParceiroId;
}

// ─── Helpers ──────────────────────────────────────────────────────────────────
function authHeader() {
  const u = process.env.PE_USERNAME;
  const p = process.env.PE_PASSWORD;
  if (!u || !p) {
    console.error('\n❌  Configure PE_USERNAME e PE_PASSWORD no arquivo .env');
    console.error('   Copie .env.example → .env e preencha as credenciais.\n');
    process.exit(1);
  }
  return 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
}

async function req(method, path, body, queryParams, customHeaders = {}) {
  let url = `${BASE_URL}${path}`;
  if (queryParams) {
    const qs = new URLSearchParams(
      Object.entries(queryParams).filter(([, v]) => v !== '' && v != null)
    ).toString();
    if (qs) url += '?' + qs;
  }

  // GET /me não precisa de x-parceiro-id (parceiro é inferido do Basic Auth)
  const headers = { Authorization: authHeader(), 'Content-Type': 'application/json' };
  if (path !== '/me') headers['x-parceiro-id'] = await resolveParceiroId();
  Object.assign(headers, customHeaders);

  const options = { method, headers };
  if (body) options.body = JSON.stringify(body);

  console.log(`\n→ ${method} ${url}`);
  if (body) console.log('  Body:', JSON.stringify(body));
  if (Object.keys(customHeaders).length) console.log('  Headers extras:', JSON.stringify(customHeaders));

  let res;
  try {
    res = await fetch(url, options);
  } catch (e) {
    console.error('❌  Erro de rede:', e.message);
    return null;
  }

  const text = await res.text();
  let json;
  try { json = JSON.parse(text); } catch { json = text; }

  console.log(`${res.ok ? '✅' : '❌'} ${res.status} ${res.statusText}`);
  console.log(JSON.stringify(json, null, 2));
  return { ok: res.ok, status: res.status, json };
}

// ─── Prompt readline ─────────────────────────────────────────────────────────
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise((r) => rl.question(q, r));
const askOpt = async (q) => { const v = (await ask(q)).trim(); return v || null; };

// ─── Ações ───────────────────────────────────────────────────────────────────

async function descobrirParceiro() {
  await req('GET', '/me');
}

const EVENTOS_STATIC = [
  'pe.pedido.criado',
  'pe.pedido.confirmado',
  'pe.pedido.cancelado',
  'pe.pedido.expirado',
  'pe.pedido.erro',
  'pe.transacao.recebida',
  'pe.transacao.atualizada',
  'pe.transacao.cancelada',
];

async function listarEventosDisponiveis() {
  return await req('GET', '/webhook/eventos');
}

async function cadastrarWebhook() {
  const url = (await ask('  URL HTTPS do endpoint: ')).trim();
  const chaveSecreta = (await ask('  Chave secreta HMAC: ')).trim();

  console.log('\n  Buscando eventos disponíveis...');
  const res = await listarEventosDisponiveis();
  let eventos = EVENTOS_STATIC;
  if (res && res.ok && Array.isArray(res.json?.data)) {
    eventos = res.json.data.map((e) => (typeof e === 'string' ? e : e.evento));
  }

  console.log('\n  Eventos disponíveis:');
  eventos.forEach((e, i) => console.log(`    ${i + 1}. ${e}`));

  const rawChoice = (await ask('\n  Escolha o número do evento ou digite o nome: ')).trim();
  const idx = parseInt(rawChoice, 10);
  const tipo = (!isNaN(idx) && idx > 0 && idx <= eventos.length)
    ? eventos[idx - 1]
    : rawChoice || (await ask('  Tipo do evento: ')).trim();

  if (!tipo) { console.log('❌ Tipo inválido.'); return; }
  await req('POST', '/webhook', { url, chaveSecreta, tipo });
}

async function listarWebhooks() {
  await req('GET', '/webhook');
}

async function testarConectividadeWebhook() {
  const tipo = (await ask('  Tipo do evento (ex: pe.pedido.confirmado): ')).trim();
  if (!tipo) return;
  await req('POST', `/webhook/teste-conectividade/${encodeURIComponent(tipo)}`);
}

async function removerWebhook() {
  const tipo = (await ask('  Tipo do webhook a remover (ex: pe.pedido.confirmado): ')).trim();
  if (!tipo) return;
  await req('DELETE', `/webhook/${encodeURIComponent(tipo)}`);
}

async function cadastrarPlaca() {
  const placa = (await ask('  Placa (ex: ABC1D23): ')).trim().toUpperCase();
  const dataInicio = await askOpt('  Data início monitoramento ISO 8601 (Enter p/ pular): ');
  const dataFim = await askOpt('  Data fim monitoramento ISO 8601 (Enter p/ pular): ');
  const body = { placa };
  if (dataInicio) body.dataInicioMonitoramento = dataInicio;
  if (dataFim) body.dataFimMonitoramento = dataFim;
  await req('POST', '/placas', body);
}

async function removerPlaca() {
  const placa = (await ask('  Placa a remover (ex: ABC1D23): ')).trim().toUpperCase();
  await req('DELETE', `/placas/${placa}`);
}

async function listarTransacoes() {
  const placa = await askOpt('  Filtrar por placa (Enter p/ pular): ');
  const status = await askOpt('  Status [PENDENTE/PAGO/CANCELADA] (Enter p/ pular): ');
  const dataInicio = await askOpt('  Data início ISO 8601 (Enter p/ pular): ');
  const dataFim = await askOpt('  Data fim ISO 8601 (Enter p/ pular): ');
  const pagina = (await askOpt('  Página (Enter = 0): ')) || '0';
  const tamanhoPagina = (await askOpt('  Tamanho página (Enter = 50): ')) || '50';
  await req('GET', '/transacoes', null, { placa, status, dataInicio, dataFim, pagina, tamanhoPagina });
}

async function criarPedido() {
  const raw = (await ask('  IDs de transações separados por vírgula: ')).trim();
  const transacoes = raw.split(',').map((s) => ({ transacaoId: s.trim() })).filter((t) => t.transacaoId);
  const idempotencia = await askOpt('  Chave de idempotência (Opcional): ');
  const headers = idempotencia ? { 'x-chave-idempotencia': idempotencia } : {};
  await req('POST', '/pedidos', { transacoes }, null, headers);
}

async function consultarPedido() {
  const pedidoId = (await ask('  ID do pedido: ')).trim();
  await req('GET', `/pedidos/${pedidoId}`);
}

async function confirmarPagamento() {
  const pedidoId = (await ask('  ID do pedido: ')).trim();
  const referenciaExterna = await askOpt('  Referência externa (Enter p/ pular): ');
  const dataLiquidacaoExterna = await askOpt('  Data liquidação externa ISO 8601 (Enter p/ pular): ');
  const idempotencia = await askOpt('  Chave de idempotência (Opcional): ');
  const body = {};
  if (referenciaExterna) body.referenciaExterna = referenciaExterna;
  if (dataLiquidacaoExterna) body.dataLiquidacaoExterna = dataLiquidacaoExterna;
  const headers = idempotencia ? { 'x-chave-idempotencia': idempotencia } : {};
  await req('POST', `/pedidos/${pedidoId}/confirmar`, Object.keys(body).length ? body : undefined, null, headers);
}

async function cancelarPedido() {
  const pedidoId = (await ask('  ID do pedido: ')).trim();
  const motivo = await askOpt('  Motivo [DESISTENCIA_PARCEIRO/ERRO_INTERNO/OUTROS] (Enter p/ pular): ');
  const observacao = await askOpt('  Observação até 500 chars (Enter p/ pular): ');
  const idempotencia = await askOpt('  Chave de idempotência (Opcional): ');
  const body = {};
  if (motivo) body.motivo = motivo.toUpperCase();
  if (observacao) body.observacao = observacao;
  const headers = idempotencia ? { 'x-chave-idempotencia': idempotencia } : {};
  await req('POST', `/pedidos/${pedidoId}/cancelar`, Object.keys(body).length ? body : undefined, null, headers);
}

// ─── Menu ─────────────────────────────────────────────────────────────────────
const MENU = [
  { label: '── Primeiros Passos ─────────────────', action: null },
  { label: 'Descobrir parceiroId (GET /me)',       action: descobrirParceiro },
  { label: '── Webhooks Config ──────────────────', action: null },
  { label: 'Listar eventos disponíveis',           action: listarEventosDisponiveis },
  { label: 'Cadastrar webhook',                    action: cadastrarWebhook },
  { label: 'Listar webhooks',                      action: listarWebhooks },
  { label: 'Testar conectividade webhook',         action: testarConectividadeWebhook },
  { label: 'Remover webhook',                      action: removerWebhook },
  { label: '── Placas ───────────────────────────', action: null },
  { label: 'Cadastrar placa',                      action: cadastrarPlaca },
  { label: 'Remover placa',                        action: removerPlaca },
  { label: '── Transações ───────────────────────', action: null },
  { label: 'Listar transações',                    action: listarTransacoes },
  { label: '── Pedidos ──────────────────────────', action: null },
  { label: 'Criar pedido',                         action: criarPedido },
  { label: 'Consultar pedido',                     action: consultarPedido },
  { label: 'Confirmar pagamento',                  action: confirmarPagamento },
  { label: 'Cancelar pedido',                      action: cancelarPedido },
];

function printMenu() {
  console.log('\n╔══════════════════════════════════════════════╗');
  console.log('║   Movvia Arrecada+ — PE API Tester           ║');
  const urlShort = BASE_URL.replace('https://', '');
  const urlLine = urlShort.length > 43 ? urlShort.slice(0, 40) + '...' : urlShort.padEnd(43);
  console.log(`║   ${urlLine}║`);
  const pid = cachedParceiroId ? `parceiroId: ${cachedParceiroId}` : 'parceiroId: (auto via GET /me)';
  console.log(`║   ${pid.padEnd(43)}║`);
  console.log('╠══════════════════════════════════════════════╣');
  let idx = 1;
  for (const item of MENU) {
    if (!item.action) {
      console.log(`║  ${item.label.padEnd(44)}║`);
    } else {
      console.log(`║   ${String(idx).padStart(2)}.  ${item.label.padEnd(39)}║`);
      idx++;
    }
  }
  console.log('║                                              ║');
  console.log('║    0.  Sair                                  ║');
  console.log('╚══════════════════════════════════════════════╝');
}

async function main() {
  // Passo zero: resolver parceiroId antes de qualquer interação com o menu
  if (!cachedParceiroId) {
    console.log('\n  ℹ️  PE_PARCEIRO_ID não configurado — chamando GET /me para descobrir...');
    await resolveParceiroId();
  }

  const actions = MENU.filter((m) => m.action).map((m) => m.action);

  while (true) {
    printMenu();
    const choice = (await ask('\nEscolha uma opção: ')).trim();
    if (choice === '0' || choice.toLowerCase() === 'sair') break;

    const n = parseInt(choice, 10);
    if (isNaN(n) || n < 1 || n > actions.length) {
      console.log('Opção inválida.');
      continue;
    }

    try {
      await actions[n - 1]();
    } catch (e) {
      console.error('Erro inesperado:', e.message);
    }

    await ask('\n[Enter para voltar ao menu]');
  }

  console.log('\nAté logo!\n');
  rl.close();
}

main();
#!/usr/bin/env python3
# Utilitário de teste para API Movvia Arrecada+ (PE)
# Requer Python 3.8+

import base64
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

# ─── Carregar .env ────────────────────────────────────────────────────────────
ENV_PATH = Path(__file__).resolve().parent / ".env"
if ENV_PATH.exists():
    for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
        t = line.strip()
        if not t or t.startswith("#") or "=" not in t:
            continue
        key, val = t.split("=", 1)
        key = key.strip()
        val = val.strip().strip('"').strip("'")
        if key and key not in os.environ:
            os.environ[key] = val

BASE_URL = os.environ.get("BASE_URL") or "https://hml.api.pedagioeletronico.com.br/gestao-webhooks-api/v1"

# ─── Identidade do parceiro ───────────────────────────────────────────────────
cached_parceiro_id = os.environ.get("PE_PARCEIRO_ID") or None


def auth_header() -> str:
    u = os.environ.get("PE_USERNAME")
    p = os.environ.get("PE_PASSWORD")
    if not u or not p:
        print("\n❌  Configure PE_USERNAME e PE_PASSWORD no arquivo .env")
        print("   Copie .env.example → .env e preencha as credenciais.\n")
        sys.exit(1)
    raw = f"{u}:{p}".encode("utf-8")
    return "Basic " + base64.b64encode(raw).decode("ascii")


def resolve_parceiro_id() -> str:
    global cached_parceiro_id
    if cached_parceiro_id:
        return cached_parceiro_id
    res = req("GET", "/me", quiet=True)
    if not res or not res.get("ok"):
        print("❌  Não foi possível descobrir o parceiroId via GET /me")
        sys.exit(1)
    body = res.get("json")
    pid = (body or {}).get("data", {}).get("parceiroId") if isinstance(body, dict) else None
    if not pid:
        print("❌  Resposta sem data.parceiroId")
        sys.exit(1)
    cached_parceiro_id = str(pid)
    print(f"  ℹ️  parceiroId descoberto via GET /me: {cached_parceiro_id}")
    return cached_parceiro_id


def req(method, path, body=None, query=None, headers=None, quiet=False):
    url = BASE_URL + path
    if query:
        items = [(k, v) for k, v in query.items() if v not in (None, "")]
        if items:
            url += "?" + urllib.parse.urlencode(items)

    h = {"Authorization": auth_header(), "Content-Type": "application/json"}
    if path != "/me":
        h["x-parceiro-id"] = resolve_parceiro_id()
    if headers:
        h.update(headers)

    data = json.dumps(body).encode("utf-8") if body is not None else None

    if not quiet:
        print(f"\n{method} {url}")
        if body is not None:
            print(f"  Body: {json.dumps(body, ensure_ascii=False)}")
        if headers:
            print(f"  Headers extras: {json.dumps(headers, ensure_ascii=False)}")

    request = urllib.request.Request(url, data=data, method=method, headers=h)
    try:
        with urllib.request.urlopen(request) as resp:
            status = resp.status
            text = resp.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        status = e.code
        text = e.read().decode("utf-8")
    except Exception as e:
        print(f"❌  Erro de rede: {e}")
        return None

    try:
        parsed = json.loads(text)
    except Exception:
        parsed = text

    if not quiet:
        ok = 200 <= status < 400
        print(f"{'✅' if ok else '❌'} {status}")
        if isinstance(parsed, (dict, list)):
            print(json.dumps(parsed, ensure_ascii=False, indent=2))
        else:
            print(parsed)

    return {"ok": 200 <= status < 400, "status": status, "json": parsed}


# ─── Prompts ──────────────────────────────────────────────────────────────────
def ask(q):
    return input(q)


def ask_opt(q):
    v = input(q).strip()
    return v or None


# ─── Ações ────────────────────────────────────────────────────────────────────
def descobrir_parceiro():
    req("GET", "/me")


EVENTOS_STATIC = [
    "pe.pedido.criado",
    "pe.pedido.confirmado",
    "pe.pedido.cancelado",
    "pe.pedido.expirado",
    "pe.pedido.erro",
    "pe.transacao.recebida",
    "pe.transacao.atualizada",
    "pe.transacao.cancelada",
]


def listar_eventos_disponiveis():
    return req("GET", "/webhook/eventos")


def cadastrar_webhook():
    url = ask("  URL HTTPS do endpoint: ").strip()
    chave = ask("  Chave secreta HMAC: ").strip()
    print("\n  Buscando eventos disponíveis...")
    res = listar_eventos_disponiveis()
    eventos = EVENTOS_STATIC
    if res and res.get("ok"):
        body = res.get("json")
        data = body.get("data") if isinstance(body, dict) else None
        if isinstance(data, list) and data:
            eventos = [e if isinstance(e, str) else (e.get("evento") if isinstance(e, dict) else None) for e in data]
            eventos = [e for e in eventos if e]
    print("\n  Eventos disponíveis:")
    for i, e in enumerate(eventos, start=1):
        print(f"    {i}. {e}")
    raw = ask("\n  Escolha o número do evento ou digite o nome: ").strip()
    tipo = raw
    try:
        idx = int(raw)
        if 1 <= idx <= len(eventos):
            tipo = eventos[idx - 1]
    except ValueError:
        pass
    if not tipo:
        tipo = ask("  Tipo do evento: ").strip()
    if not tipo:
        print("❌ Tipo inválido.")
        return
    req("POST", "/webhook", {"url": url, "chaveSecreta": chave, "tipo": tipo})


def listar_webhooks():
    req("GET", "/webhook")


def testar_conectividade_webhook():
    tipo = ask("  Tipo do evento (ex: pe.pedido.confirmado): ").strip()
    if not tipo:
        return
    req("POST", f"/webhook/teste-conectividade/{urllib.parse.quote(tipo, safe='')}")


def remover_webhook():
    tipo = ask("  Tipo do webhook a remover (ex: pe.pedido.confirmado): ").strip()
    if not tipo:
        return
    req("DELETE", f"/webhook/{urllib.parse.quote(tipo, safe='')}")


def cadastrar_placa():
    placa = ask("  Placa (ex: ABC1D23): ").strip().upper()
    di = ask_opt("  Data início monitoramento ISO 8601 (Enter p/ pular): ")
    df = ask_opt("  Data fim monitoramento ISO 8601 (Enter p/ pular): ")
    body = {"placa": placa}
    if di:
        body["dataInicioMonitoramento"] = di
    if df:
        body["dataFimMonitoramento"] = df
    req("POST", "/placas", body)


def remover_placa():
    placa = ask("  Placa a remover (ex: ABC1D23): ").strip().upper()
    req("DELETE", f"/placas/{placa}")


def listar_transacoes():
    placa = ask_opt("  Filtrar por placa (Enter p/ pular): ")
    status = ask_opt("  Status [PENDENTE/PAGO/CANCELADA] (Enter p/ pular): ")
    di = ask_opt("  Data início ISO 8601 (Enter p/ pular): ")
    df = ask_opt("  Data fim ISO 8601 (Enter p/ pular): ")
    pagina = ask_opt("  Página (Enter = 0): ") or "0"
    tamanho = ask_opt("  Tamanho página (Enter = 50): ") or "50"
    req("GET", "/transacoes", query={
        "placa": placa, "status": status,
        "dataInicio": di, "dataFim": df,
        "pagina": pagina, "tamanhoPagina": tamanho,
    })


def criar_pedido():
    raw = ask("  IDs de transações separados por vírgula: ").strip()
    transacoes = [{"transacaoId": s.strip()} for s in raw.split(",") if s.strip()]
    idemp = ask_opt("  Chave de idempotência (Opcional): ")
    headers = {"x-chave-idempotencia": idemp} if idemp else None
    req("POST", "/pedidos", {"transacoes": transacoes}, headers=headers)


def consultar_pedido():
    pid = ask("  ID do pedido: ").strip()
    req("GET", f"/pedidos/{pid}")


def confirmar_pagamento():
    pid = ask("  ID do pedido: ").strip()
    ref = ask_opt("  Referência externa (Enter p/ pular): ")
    dt = ask_opt("  Data liquidação externa ISO 8601 (Enter p/ pular): ")
    idemp = ask_opt("  Chave de idempotência (Opcional): ")
    body = {}
    if ref:
        body["referenciaExterna"] = ref
    if dt:
        body["dataLiquidacaoExterna"] = dt
    headers = {"x-chave-idempotencia": idemp} if idemp else None
    req("POST", f"/pedidos/{pid}/confirmar", body or None, headers=headers)


def cancelar_pedido():
    pid = ask("  ID do pedido: ").strip()
    motivo = ask_opt("  Motivo [DESISTENCIA_PARCEIRO/ERRO_INTERNO/OUTROS] (Enter p/ pular): ")
    obs = ask_opt("  Observação até 500 chars (Enter p/ pular): ")
    idemp = ask_opt("  Chave de idempotência (Opcional): ")
    body = {}
    if motivo:
        body["motivo"] = motivo.upper()
    if obs:
        body["observacao"] = obs
    headers = {"x-chave-idempotencia": idemp} if idemp else None
    req("POST", f"/pedidos/{pid}/cancelar", body or None, headers=headers)


# ─── Menu ─────────────────────────────────────────────────────────────────────
MENU = [
    ("── Primeiros Passos ─────────────────", None),
    ("Descobrir parceiroId (GET /me)", descobrir_parceiro),
    ("── Webhooks Config ──────────────────", None),
    ("Listar eventos disponíveis", listar_eventos_disponiveis),
    ("Cadastrar webhook", cadastrar_webhook),
    ("Listar webhooks", listar_webhooks),
    ("Testar conectividade webhook", testar_conectividade_webhook),
    ("Remover webhook", remover_webhook),
    ("── Placas ───────────────────────────", None),
    ("Cadastrar placa", cadastrar_placa),
    ("Remover placa", remover_placa),
    ("── Transações ───────────────────────", None),
    ("Listar transações", listar_transacoes),
    ("── Pedidos ──────────────────────────", None),
    ("Criar pedido", criar_pedido),
    ("Consultar pedido", consultar_pedido),
    ("Confirmar pagamento", confirmar_pagamento),
    ("Cancelar pedido", cancelar_pedido),
]


def print_menu():
    print("\n╔══════════════════════════════════════════════╗")
    print("║   Movvia Arrecada+ — PE API Tester           ║")
    short = BASE_URL.replace("https://", "")
    line = (short[:40] + "...") if len(short) > 43 else short.ljust(43)
    print(f"║   {line}║")
    pid = f"parceiroId: {cached_parceiro_id}" if cached_parceiro_id else "parceiroId: (auto via GET /me)"
    print(f"║   {pid.ljust(43)}║")
    print("╠══════════════════════════════════════════════╣")
    idx = 1
    for label, action in MENU:
        if action is None:
            print(f"║  {label.ljust(44)}║")
        else:
            print(f"║   {str(idx).rjust(2)}.  {label.ljust(39)}║")
            idx += 1
    print("║                                              ║")
    print("║    0.  Sair                                  ║")
    print("╚══════════════════════════════════════════════╝")


def main():
    if not cached_parceiro_id:
        print("\n  ℹ️  PE_PARCEIRO_ID não configurado — chamando GET /me para descobrir...")
        resolve_parceiro_id()

    actions = [a for _, a in MENU if a is not None]

    while True:
        print_menu()
        choice = input("\nEscolha uma opção: ").strip().lower()
        if choice in ("0", "sair"):
            break
        try:
            n = int(choice)
        except ValueError:
            print("Opção inválida.")
            continue
        if n < 1 or n > len(actions):
            print("Opção inválida.")
            continue
        try:
            actions[n - 1]()
        except Exception as e:
            print(f"Erro inesperado: {e}")
        input("\n[Enter para voltar ao menu]")

    print("\nAté logo!\n")


if __name__ == "__main__":
    main()
// Utilitário de teste para API Movvia Arrecada+ (PE)
// Requer Go 1.21+. Roda com: go run simulador.go
package main

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
)

var (
	baseURL          = ""
	cachedParceiroID = ""
	stdin            = bufio.NewReader(os.Stdin)
)

// ─── Carregar .env ────────────────────────────────────────────────────────────
func loadEnv() {
	data, err := os.ReadFile(".env")
	if err != nil {
		return
	}
	for _, line := range strings.Split(string(data), "\n") {
		t := strings.TrimSpace(line)
		if t == "" || strings.HasPrefix(t, "#") {
			continue
		}
		idx := strings.Index(t, "=")
		if idx < 1 {
			continue
		}
		key := strings.TrimSpace(t[:idx])
		val := strings.TrimSpace(t[idx+1:])
		val = strings.Trim(val, "\"'")
		if key != "" && os.Getenv(key) == "" {
			os.Setenv(key, val)
		}
	}
}

func getenvDefault(k, d string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return d
}

// ─── Helpers ──────────────────────────────────────────────────────────────────
func authHeader() string {
	u := os.Getenv("PE_USERNAME")
	p := os.Getenv("PE_PASSWORD")
	if u == "" || p == "" {
		fmt.Println("\n❌  Configure PE_USERNAME e PE_PASSWORD no arquivo .env")
		fmt.Println("   Copie .env.example → .env e preencha as credenciais.")
		fmt.Println()
		os.Exit(1)
	}
	return "Basic " + base64.StdEncoding.EncodeToString([]byte(u+":"+p))
}

type result struct {
	ok     bool
	status int
	body   string
}

func req(method, path string, body any, query map[string]string, headers map[string]string, quiet bool) *result {
	u := baseURL + path
	if len(query) > 0 {
		q := url.Values{}
		for k, v := range query {
			if v != "" {
				q.Set(k, v)
			}
		}
		if encoded := q.Encode(); encoded != "" {
			u += "?" + encoded
		}
	}

	var bodyReader io.Reader
	var bodyJSON []byte
	if body != nil {
		var err error
		bodyJSON, err = json.Marshal(body)
		if err != nil {
			fmt.Printf("❌  Erro ao serializar body: %v\n", err)
			return nil
		}
		bodyReader = bytes.NewReader(bodyJSON)
	}

	r, err := http.NewRequest(method, u, bodyReader)
	if err != nil {
		fmt.Printf("❌  Erro ao montar request: %v\n", err)
		return nil
	}
	r.Header.Set("Authorization", authHeader())
	r.Header.Set("Content-Type", "application/json")
	if path != "/me" {
		r.Header.Set("x-parceiro-id", resolveParceiroID())
	}
	for k, v := range headers {
		r.Header.Set(k, v)
	}

	if !quiet {
		fmt.Printf("\n%s %s\n", method, u)
		if bodyJSON != nil {
			fmt.Printf("  Body: %s\n", string(bodyJSON))
		}
		if len(headers) > 0 {
			h, _ := json.Marshal(headers)
			fmt.Printf("  Headers extras: %s\n", string(h))
		}
	}

	resp, err := http.DefaultClient.Do(r)
	if err != nil {
		fmt.Printf("❌  Erro de rede: %v\n", err)
		return nil
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)

	if !quiet {
		ok := resp.StatusCode < 400
		mark := "❌"
		if ok {
			mark = "✅"
		}
		fmt.Printf("%s %d %s\n", mark, resp.StatusCode, http.StatusText(resp.StatusCode))
		var pretty bytes.Buffer
		if err := json.Indent(&pretty, raw, "", "  "); err == nil {
			fmt.Println(pretty.String())
		} else {
			fmt.Println(string(raw))
		}
	}

	return &result{ok: resp.StatusCode < 400, status: resp.StatusCode, body: string(raw)}
}

func resolveParceiroID() string {
	if cachedParceiroID != "" {
		return cachedParceiroID
	}
	r := req("GET", "/me", nil, nil, nil, true)
	if r == nil || !r.ok {
		fmt.Println("❌  Não foi possível descobrir o parceiroId via GET /me")
		os.Exit(1)
	}
	var parsed struct {
		Data struct {
			ParceiroID json.RawMessage `json:"parceiroId"`
		} `json:"data"`
	}
	if err := json.Unmarshal([]byte(r.body), &parsed); err != nil {
		fmt.Printf("❌  Erro ao parsear resposta de /me: %v\n", err)
		os.Exit(1)
	}
	pid := strings.Trim(string(parsed.Data.ParceiroID), `"`)
	if pid == "" || pid == "null" {
		fmt.Println("❌  Resposta sem data.parceiroId")
		os.Exit(1)
	}
	cachedParceiroID = pid
	fmt.Printf("  ℹ️  parceiroId descoberto via GET /me: %s\n", pid)
	return pid
}

// ─── Prompts ──────────────────────────────────────────────────────────────────
func prompt(p string) string {
	fmt.Print(p)
	line, _ := stdin.ReadString('\n')
	return strings.TrimRight(line, "\r\n")
}

func promptOpt(p string) string {
	return strings.TrimSpace(prompt(p))
}

// ─── Ações ───────────────────────────────────────────────────────────────────
func descobrirParceiro() { req("GET", "/me", nil, nil, nil, false) }
func listarWebhooks()    { req("GET", "/webhook", nil, nil, nil, false) }
func listarEventosDisponiveis() *result {
	return req("GET", "/webhook/eventos", nil, nil, nil, false)
}

var eventosStatic = []string{
	"pe.pedido.criado",
	"pe.pedido.confirmado",
	"pe.pedido.cancelado",
	"pe.pedido.expirado",
	"pe.pedido.erro",
	"pe.transacao.recebida",
	"pe.transacao.atualizada",
	"pe.transacao.cancelada",
}

func cadastrarWebhook() {
	urlStr := strings.TrimSpace(prompt("  URL HTTPS do endpoint: "))
	chave := strings.TrimSpace(prompt("  Chave secreta HMAC: "))
	fmt.Println("\n  Buscando eventos disponíveis...")
	res := listarEventosDisponiveis()
	eventos := eventosStatic
	if res != nil && res.ok {
		var parsed struct {
			Data []json.RawMessage `json:"data"`
		}
		if err := json.Unmarshal([]byte(res.body), &parsed); err == nil && len(parsed.Data) > 0 {
			ev := []string{}
			for _, e := range parsed.Data {
				s := string(e)
				if strings.HasPrefix(s, "{") {
					var obj struct {
						Evento string `json:"evento"`
					}
					if json.Unmarshal(e, &obj) == nil && obj.Evento != "" {
						ev = append(ev, obj.Evento)
					}
				} else {
					var str string
					if json.Unmarshal(e, &str) == nil && str != "" {
						ev = append(ev, str)
					}
				}
			}
			if len(ev) > 0 {
				eventos = ev
			}
		}
	}

	fmt.Println("\n  Eventos disponíveis:")
	for i, e := range eventos {
		fmt.Printf("    %d. %s\n", i+1, e)
	}
	raw := strings.TrimSpace(prompt("\n  Escolha o número do evento ou digite o nome: "))
	tipo := raw
	if n, err := strconv.Atoi(raw); err == nil && n >= 1 && n <= len(eventos) {
		tipo = eventos[n-1]
	}
	if tipo == "" {
		tipo = strings.TrimSpace(prompt("  Tipo do evento: "))
	}
	if tipo == "" {
		fmt.Println("❌ Tipo inválido.")
		return
	}
	req("POST", "/webhook", map[string]any{
		"url":          urlStr,
		"chaveSecreta": chave,
		"tipo":         tipo,
	}, nil, nil, false)
}

func testarConectividadeWebhook() {
	tipo := strings.TrimSpace(prompt("  Tipo do evento (ex: pe.pedido.confirmado): "))
	if tipo == "" {
		return
	}
	req("POST", "/webhook/teste-conectividade/"+url.PathEscape(tipo), nil, nil, nil, false)
}

func removerWebhook() {
	tipo := strings.TrimSpace(prompt("  Tipo do webhook a remover (ex: pe.pedido.confirmado): "))
	if tipo == "" {
		return
	}
	req("DELETE", "/webhook/"+url.PathEscape(tipo), nil, nil, nil, false)
}

func cadastrarPlaca() {
	placa := strings.ToUpper(strings.TrimSpace(prompt("  Placa (ex: ABC1D23): ")))
	di := promptOpt("  Data início monitoramento ISO 8601 (Enter p/ pular): ")
	df := promptOpt("  Data fim monitoramento ISO 8601 (Enter p/ pular): ")
	body := map[string]any{"placa": placa}
	if di != "" {
		body["dataInicioMonitoramento"] = di
	}
	if df != "" {
		body["dataFimMonitoramento"] = df
	}
	req("POST", "/placas", body, nil, nil, false)
}

func removerPlaca() {
	placa := strings.ToUpper(strings.TrimSpace(prompt("  Placa a remover (ex: ABC1D23): ")))
	req("DELETE", "/placas/"+placa, nil, nil, nil, false)
}

func listarTransacoes() {
	q := map[string]string{
		"placa":      promptOpt("  Filtrar por placa (Enter p/ pular): "),
		"status":     promptOpt("  Status [PENDENTE/PAGO/CANCELADA] (Enter p/ pular): "),
		"dataInicio": promptOpt("  Data início ISO 8601 (Enter p/ pular): "),
		"dataFim":    promptOpt("  Data fim ISO 8601 (Enter p/ pular): "),
	}
	pagina := promptOpt("  Página (Enter = 0): ")
	if pagina == "" {
		pagina = "0"
	}
	tamanho := promptOpt("  Tamanho página (Enter = 50): ")
	if tamanho == "" {
		tamanho = "50"
	}
	q["pagina"] = pagina
	q["tamanhoPagina"] = tamanho
	req("GET", "/transacoes", nil, q, nil, false)
}

func criarPedido() {
	raw := strings.TrimSpace(prompt("  IDs de transações separados por vírgula: "))
	transacoes := []map[string]string{}
	for _, s := range strings.Split(raw, ",") {
		s = strings.TrimSpace(s)
		if s != "" {
			transacoes = append(transacoes, map[string]string{"transacaoId": s})
		}
	}
	idemp := promptOpt("  Chave de idempotência (Opcional): ")
	headers := map[string]string{}
	if idemp != "" {
		headers["x-chave-idempotencia"] = idemp
	}
	req("POST", "/pedidos", map[string]any{"transacoes": transacoes}, nil, headers, false)
}

func consultarPedido() {
	pid := strings.TrimSpace(prompt("  ID do pedido: "))
	req("GET", "/pedidos/"+pid, nil, nil, nil, false)
}

func confirmarPagamento() {
	pid := strings.TrimSpace(prompt("  ID do pedido: "))
	ref := promptOpt("  Referência externa (Enter p/ pular): ")
	dt := promptOpt("  Data liquidação externa ISO 8601 (Enter p/ pular): ")
	idemp := promptOpt("  Chave de idempotência (Opcional): ")
	body := map[string]any{}
	if ref != "" {
		body["referenciaExterna"] = ref
	}
	if dt != "" {
		body["dataLiquidacaoExterna"] = dt
	}
	headers := map[string]string{}
	if idemp != "" {
		headers["x-chave-idempotencia"] = idemp
	}
	var bodyArg any
	if len(body) > 0 {
		bodyArg = body
	}
	req("POST", "/pedidos/"+pid+"/confirmar", bodyArg, nil, headers, false)
}

func cancelarPedido() {
	pid := strings.TrimSpace(prompt("  ID do pedido: "))
	motivo := promptOpt("  Motivo [DESISTENCIA_PARCEIRO/ERRO_INTERNO/OUTROS] (Enter p/ pular): ")
	obs := promptOpt("  Observação até 500 chars (Enter p/ pular): ")
	idemp := promptOpt("  Chave de idempotência (Opcional): ")
	body := map[string]any{}
	if motivo != "" {
		body["motivo"] = strings.ToUpper(motivo)
	}
	if obs != "" {
		body["observacao"] = obs
	}
	headers := map[string]string{}
	if idemp != "" {
		headers["x-chave-idempotencia"] = idemp
	}
	var bodyArg any
	if len(body) > 0 {
		bodyArg = body
	}
	req("POST", "/pedidos/"+pid+"/cancelar", bodyArg, nil, headers, false)
}

// ─── Menu ─────────────────────────────────────────────────────────────────────
type menuItem struct {
	label  string
	action func()
}

func menu() []menuItem {
	return []menuItem{
		{"── Primeiros Passos ─────────────────", nil},
		{"Descobrir parceiroId (GET /me)", descobrirParceiro},
		{"── Webhooks Config ──────────────────", nil},
		{"Listar eventos disponíveis", func() { listarEventosDisponiveis() }},
		{"Cadastrar webhook", cadastrarWebhook},
		{"Listar webhooks", listarWebhooks},
		{"Testar conectividade webhook", testarConectividadeWebhook},
		{"Remover webhook", removerWebhook},
		{"── Placas ───────────────────────────", nil},
		{"Cadastrar placa", cadastrarPlaca},
		{"Remover placa", removerPlaca},
		{"── Transações ───────────────────────", nil},
		{"Listar transações", listarTransacoes},
		{"── Pedidos ──────────────────────────", nil},
		{"Criar pedido", criarPedido},
		{"Consultar pedido", consultarPedido},
		{"Confirmar pagamento", confirmarPagamento},
		{"Cancelar pedido", cancelarPedido},
	}
}

func padRight(s string, n int) string {
	if len(s) >= n {
		return s
	}
	return s + strings.Repeat(" ", n-len(s))
}

func printMenu() {
	items := menu()
	fmt.Println("\n╔══════════════════════════════════════════════╗")
	fmt.Println("║   Movvia Arrecada+ — PE API Tester           ║")
	short := strings.Replace(baseURL, "https://", "", 1)
	if len(short) > 43 {
		short = short[:40] + "..."
	}
	fmt.Printf("║   %s\n", padRight(short, 43))
	pid := "parceiroId: (auto via GET /me)"
	if cachedParceiroID != "" {
		pid = "parceiroId: " + cachedParceiroID
	}
	fmt.Printf("║   %s\n", padRight(pid, 43))
	fmt.Println("╠══════════════════════════════════════════════╣")
	idx := 1
	for _, it := range items {
		if it.action == nil {
			fmt.Printf("║  %s\n", padRight(it.label, 44))
		} else {
			line := fmt.Sprintf("%2d.  %s", idx, it.label)
			fmt.Printf("║   %s\n", padRight(line, 43))
			idx++
		}
	}
	fmt.Println("║                                              ║")
	fmt.Println("║    0.  Sair                                  ║")
	fmt.Println("╚══════════════════════════════════════════════╝")
}

func main() {
	loadEnv()
	baseURL = getenvDefault("BASE_URL", "https://hml.api.pedagioeletronico.com.br/gestao-webhooks-api/v1")
	cachedParceiroID = os.Getenv("PE_PARCEIRO_ID")

	if cachedParceiroID == "" {
		fmt.Println("\n  ℹ️  PE_PARCEIRO_ID não configurado — chamando GET /me para descobrir...")
		resolveParceiroID()
	}

	items := menu()
	actions := []func(){}
	for _, it := range items {
		if it.action != nil {
			actions = append(actions, it.action)
		}
	}

	for {
		printMenu()
		choice := strings.ToLower(strings.TrimSpace(prompt("\nEscolha uma opção: ")))
		if choice == "0" || choice == "sair" {
			break
		}
		n, err := strconv.Atoi(choice)
		if err != nil || n < 1 || n > len(actions) {
			fmt.Println("Opção inválida.")
			continue
		}
		func() {
			defer func() {
				if r := recover(); r != nil {
					fmt.Printf("Erro inesperado: %v\n", r)
				}
			}()
			actions[n-1]()
		}()
		prompt("\n[Enter para voltar ao menu]")
	}

	fmt.Println("\nAté logo!")
}
// Utilitário de teste para API Movvia Arrecada+ (PE)
// Requer Java 11+. Roda com: java Simulador.java

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Simulador {

    static final HttpClient HTTP = HttpClient.newHttpClient();
    static final Scanner IN = new Scanner(System.in);
    static final List<String> EVENTOS_STATIC = List.of(
        "pe.pedido.criado", "pe.pedido.confirmado", "pe.pedido.cancelado",
        "pe.pedido.expirado", "pe.pedido.erro",
        "pe.transacao.recebida", "pe.transacao.atualizada", "pe.transacao.cancelada"
    );

    static String baseUrl;
    static String cachedParceiroId;

    static class Result {
        final boolean ok;
        final int status;
        final String body;
        Result(boolean ok, int status, String body) { this.ok = ok; this.status = status; this.body = body; }
    }

    // ─── Carregar .env ────────────────────────────────────────────────────────
    static void loadEnv() {
        Path p = Paths.get(".env");
        if (!Files.exists(p)) return;
        try {
            for (String line : Files.readAllLines(p, StandardCharsets.UTF_8)) {
                String t = line.trim();
                if (t.isEmpty() || t.startsWith("#")) continue;
                int eq = t.indexOf('=');
                if (eq < 1) continue;
                String key = t.substring(0, eq).trim();
                String val = t.substring(eq + 1).trim().replaceAll("^[\"']|[\"']$", "");
                if (!key.isEmpty() && env(key) == null) {
                    System.setProperty(key, val);
                }
            }
        } catch (IOException ignored) { /* arquivo opcional */ }
    }

    static String env(String k) {
        String v = System.getProperty(k);
        if (v == null || v.isEmpty()) v = System.getenv(k);
        return (v == null || v.isEmpty()) ? null : v;
    }

    static String envOr(String k, String d) {
        String v = env(k);
        return v == null ? d : v;
    }

    // ─── Helpers ──────────────────────────────────────────────────────────────
    static String authHeader() {
        String u = env("PE_USERNAME"), p = env("PE_PASSWORD");
        if (u == null || p == null) {
            System.out.println("\n❌  Configure PE_USERNAME e PE_PASSWORD no arquivo .env");
            System.out.println("   Copie .env.example → .env e preencha as credenciais.\n");
            System.exit(1);
        }
        return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes(StandardCharsets.UTF_8));
    }

    /** Constrói JSON inline a partir de pares chave/valor.
     *  Strings ganham aspas; numbers, booleans e null não. */
    static String json(Object... kv) {
        StringBuilder sb = new StringBuilder("{");
        for (int i = 0; i < kv.length; i += 2) {
            if (i > 0) sb.append(',');
            sb.append('"').append(escape(String.valueOf(kv[i]))).append("\":").append(jsonValue(kv[i + 1]));
        }
        return sb.append('}').toString();
    }

    static String jsonValue(Object v) {
        if (v == null) return "null";
        if (v instanceof Number || v instanceof Boolean) return v.toString();
        return "\"" + escape(String.valueOf(v)) + "\"";
    }

    static String escape(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
    }

    // ─── Função abstratora única ──────────────────────────────────────────────
    static Result req(String method, String path) { return req(method, path, null, null, null, false); }

    static Result req(String method, String path, String body, Map<String,String> query,
                      Map<String,String> extraHeaders, boolean quiet) {
        StringBuilder u = new StringBuilder(baseUrl).append(path);
        if (query != null && !query.isEmpty()) {
            StringBuilder qs = new StringBuilder();
            for (Map.Entry<String,String> e : query.entrySet()) {
                if (e.getValue() == null || e.getValue().isEmpty()) continue;
                if (qs.length() > 0) qs.append('&');
                qs.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
                  .append('=')
                  .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
            }
            if (qs.length() > 0) u.append('?').append(qs);
        }

        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(u.toString()))
            .header("Authorization", authHeader())
            .header("Content-Type", "application/json");
        if (!"/me".equals(path)) b.header("x-parceiro-id", resolveParceiroId());
        if (extraHeaders != null) extraHeaders.forEach(b::header);

        HttpRequest.BodyPublisher pub = body == null
            ? HttpRequest.BodyPublishers.noBody()
            : HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8);
        b.method(method, pub);

        if (!quiet) {
            System.out.println("\n→ " + method + " " + u);
            if (body != null) System.out.println("  Body: " + body);
            if (extraHeaders != null && !extraHeaders.isEmpty())
                System.out.println("  Headers extras: " + extraHeaders);
        }

        try {
            HttpResponse<String> resp = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
            boolean ok = resp.statusCode() < 400;
            if (!quiet) {
                System.out.println((ok ? "✅" : "❌") + " " + resp.statusCode());
                System.out.println(resp.body());
            }
            return new Result(ok, resp.statusCode(), resp.body());
        } catch (IOException | InterruptedException e) {
            System.out.println("❌  Erro de rede: " + e.getMessage());
            return null;
        }
    }

    static String resolveParceiroId() {
        if (cachedParceiroId != null && !cachedParceiroId.isEmpty()) return cachedParceiroId;
        Result r = req("GET", "/me", null, null, null, true);
        if (r == null || !r.ok) {
            System.out.println("❌  Não foi possível descobrir o parceiroId via GET /me");
            System.exit(1);
        }
        Matcher m = Pattern.compile("\"parceiroId\"\\s*:\\s*\"([^\"]+)\"").matcher(r.body);
        if (!m.find()) {
            System.out.println("❌  Resposta sem data.parceiroId: " + r.body);
            System.exit(1);
        }
        cachedParceiroId = m.group(1);
        System.out.println("  ℹ️  parceiroId descoberto via GET /me: " + cachedParceiroId);
        return cachedParceiroId;
    }

    // ─── Prompts ──────────────────────────────────────────────────────────────
    static String prompt(String q) {
        System.out.print(q);
        return IN.hasNextLine() ? IN.nextLine() : "";
    }

    static String promptOpt(String q) {
        String v = prompt(q).trim();
        return v.isEmpty() ? null : v;
    }

    // ─── Ações ────────────────────────────────────────────────────────────────
    static void descobrirParceiro() { req("GET", "/me"); }

    static Result listarEventosDisponiveis() { return req("GET", "/webhook/eventos"); }

    static void cadastrarWebhook() {
        String url = prompt("  URL HTTPS do endpoint: ").trim();
        String chave = prompt("  Chave secreta HMAC: ").trim();
        System.out.println("\n  Buscando eventos disponíveis...");
        Result r = listarEventosDisponiveis();
        List<String> eventos = EVENTOS_STATIC;
        if (r != null && r.ok) {
            List<String> parsed = new ArrayList<>();
            // formato {data: [{evento: "..."}]}
            Matcher me = Pattern.compile("\"evento\"\\s*:\\s*\"([^\"]+)\"").matcher(r.body);
            while (me.find()) parsed.add(me.group(1));
            // fallback: data: ["...", "..."]
            if (parsed.isEmpty()) {
                Matcher md = Pattern.compile("\"data\"\\s*:\\s*\\[([^\\]]+)\\]").matcher(r.body);
                if (md.find()) {
                    Matcher ms = Pattern.compile("\"([^\"]+)\"").matcher(md.group(1));
                    while (ms.find()) parsed.add(ms.group(1));
                }
            }
            if (!parsed.isEmpty()) eventos = parsed;
        }
        System.out.println("\n  Eventos disponíveis:");
        for (int i = 0; i < eventos.size(); i++)
            System.out.println("    " + (i + 1) + ". " + eventos.get(i));
        String raw = prompt("\n  Escolha o número do evento ou digite o nome: ").trim();
        String tipo = raw;
        try {
            int idx = Integer.parseInt(raw);
            if (idx >= 1 && idx <= eventos.size()) tipo = eventos.get(idx - 1);
        } catch (NumberFormatException ignored) {}
        if (tipo.isEmpty()) tipo = prompt("  Tipo do evento: ").trim();
        if (tipo.isEmpty()) { System.out.println("❌ Tipo inválido."); return; }
        req("POST", "/webhook", json("url", url, "chaveSecreta", chave, "tipo", tipo), null, null, false);
    }

    static void listarWebhooks() { req("GET", "/webhook"); }

    static void testarConectividadeWebhook() {
        String tipo = prompt("  Tipo do evento (ex: pe.pedido.confirmado): ").trim();
        if (tipo.isEmpty()) return;
        req("POST", "/webhook/teste-conectividade/" + URLEncoder.encode(tipo, StandardCharsets.UTF_8),
            null, null, null, false);
    }

    static void removerWebhook() {
        String tipo = prompt("  Tipo do webhook a remover (ex: pe.pedido.confirmado): ").trim();
        if (tipo.isEmpty()) return;
        req("DELETE", "/webhook/" + URLEncoder.encode(tipo, StandardCharsets.UTF_8),
            null, null, null, false);
    }

    static void cadastrarPlaca() {
        String placa = prompt("  Placa (ex: ABC1D23): ").trim().toUpperCase();
        String di = promptOpt("  Data início monitoramento ISO 8601 (Enter p/ pular): ");
        String df = promptOpt("  Data fim monitoramento ISO 8601 (Enter p/ pular): ");
        StringBuilder body = new StringBuilder("{\"placa\":\"").append(escape(placa)).append("\"");
        if (di != null) body.append(",\"dataInicioMonitoramento\":\"").append(escape(di)).append("\"");
        if (df != null) body.append(",\"dataFimMonitoramento\":\"").append(escape(df)).append("\"");
        body.append("}");
        req("POST", "/placas", body.toString(), null, null, false);
    }

    static void removerPlaca() {
        String placa = prompt("  Placa a remover (ex: ABC1D23): ").trim().toUpperCase();
        req("DELETE", "/placas/" + placa);
    }

    static void listarTransacoes() {
        Map<String,String> q = new LinkedHashMap<>();
        q.put("placa", promptOpt("  Filtrar por placa (Enter p/ pular): "));
        q.put("status", promptOpt("  Status [PENDENTE/PAGO/CANCELADA] (Enter p/ pular): "));
        q.put("dataInicio", promptOpt("  Data início ISO 8601 (Enter p/ pular): "));
        q.put("dataFim", promptOpt("  Data fim ISO 8601 (Enter p/ pular): "));
        String pagina = promptOpt("  Página (Enter = 0): ");  if (pagina == null) pagina = "0";
        String tamanho = promptOpt("  Tamanho página (Enter = 50): "); if (tamanho == null) tamanho = "50";
        q.put("pagina", pagina);
        q.put("tamanhoPagina", tamanho);
        q.values().removeIf(Objects::isNull);
        req("GET", "/transacoes", null, q, null, false);
    }

    static void criarPedido() {
        String raw = prompt("  IDs de transações separados por vírgula: ").trim();
        StringBuilder transacoes = new StringBuilder("[");
        boolean first = true;
        for (String s : raw.split(",")) {
            String id = s.trim();
            if (id.isEmpty()) continue;
            if (!first) transacoes.append(',');
            transacoes.append("{\"transacaoId\":\"").append(escape(id)).append("\"}");
            first = false;
        }
        transacoes.append("]");
        String idemp = promptOpt("  Chave de idempotência (Opcional): ");
        Map<String,String> headers = idemp != null ? Map.of("x-chave-idempotencia", idemp) : null;
        req("POST", "/pedidos", "{\"transacoes\":" + transacoes + "}", null, headers, false);
    }

    static void consultarPedido() {
        String pid = prompt("  ID do pedido: ").trim();
        req("GET", "/pedidos/" + pid);
    }

    static void confirmarPagamento() {
        String pid = prompt("  ID do pedido: ").trim();
        String ref = promptOpt("  Referência externa (Enter p/ pular): ");
        String dt = promptOpt("  Data liquidação externa ISO 8601 (Enter p/ pular): ");
        String idemp = promptOpt("  Chave de idempotência (Opcional): ");
        StringBuilder body = new StringBuilder("{");
        boolean first = true;
        if (ref != null) {
            body.append("\"referenciaExterna\":\"").append(escape(ref)).append("\"");
            first = false;
        }
        if (dt != null) {
            if (!first) body.append(',');
            body.append("\"dataLiquidacaoExterna\":\"").append(escape(dt)).append("\"");
        }
        body.append("}");
        Map<String,String> headers = idemp != null ? Map.of("x-chave-idempotencia", idemp) : null;
        String b = body.length() > 2 ? body.toString() : null;
        req("POST", "/pedidos/" + pid + "/confirmar", b, null, headers, false);
    }

    static void cancelarPedido() {
        String pid = prompt("  ID do pedido: ").trim();
        String motivo = promptOpt("  Motivo [DESISTENCIA_PARCEIRO/ERRO_INTERNO/OUTROS] (Enter p/ pular): ");
        String obs = promptOpt("  Observação até 500 chars (Enter p/ pular): ");
        String idemp = promptOpt("  Chave de idempotência (Opcional): ");
        StringBuilder body = new StringBuilder("{");
        boolean first = true;
        if (motivo != null) {
            body.append("\"motivo\":\"").append(escape(motivo.toUpperCase())).append("\"");
            first = false;
        }
        if (obs != null) {
            if (!first) body.append(',');
            body.append("\"observacao\":\"").append(escape(obs)).append("\"");
        }
        body.append("}");
        Map<String,String> headers = idemp != null ? Map.of("x-chave-idempotencia", idemp) : null;
        String b = body.length() > 2 ? body.toString() : null;
        req("POST", "/pedidos/" + pid + "/cancelar", b, null, headers, false);
    }

    // ─── Menu ─────────────────────────────────────────────────────────────────
    static class Item {
        final String label;
        final Runnable action;
        Item(String l, Runnable a) { label = l; action = a; }
    }

    static List<Item> menu() {
        return List.of(
            new Item("── Primeiros Passos ─────────────────", null),
            new Item("Descobrir parceiroId (GET /me)", Simulador::descobrirParceiro),
            new Item("── Webhooks Config ──────────────────", null),
            new Item("Listar eventos disponíveis", () -> listarEventosDisponiveis()),
            new Item("Cadastrar webhook", Simulador::cadastrarWebhook),
            new Item("Listar webhooks", Simulador::listarWebhooks),
            new Item("Testar conectividade webhook", Simulador::testarConectividadeWebhook),
            new Item("Remover webhook", Simulador::removerWebhook),
            new Item("── Placas ───────────────────────────", null),
            new Item("Cadastrar placa", Simulador::cadastrarPlaca),
            new Item("Remover placa", Simulador::removerPlaca),
            new Item("── Transações ───────────────────────", null),
            new Item("Listar transações", Simulador::listarTransacoes),
            new Item("── Pedidos ──────────────────────────", null),
            new Item("Criar pedido", Simulador::criarPedido),
            new Item("Consultar pedido", Simulador::consultarPedido),
            new Item("Confirmar pagamento", Simulador::confirmarPagamento),
            new Item("Cancelar pedido", Simulador::cancelarPedido)
        );
    }

    static String padRight(String s, int n) {
        return s.length() >= n ? s : s + " ".repeat(n - s.length());
    }

    static void printMenu() {
        System.out.println("\n╔══════════════════════════════════════════════╗");
        System.out.println("║   Movvia Arrecada+ — PE API Tester           ║");
        String shortUrl = baseUrl.replace("https://", "");
        if (shortUrl.length() > 43) shortUrl = shortUrl.substring(0, 40) + "...";
        System.out.println("║   " + padRight(shortUrl, 43) + "║");
        String pid = cachedParceiroId != null
            ? "parceiroId: " + cachedParceiroId
            : "parceiroId: (auto via GET /me)";
        System.out.println("║   " + padRight(pid, 43) + "║");
        System.out.println("╠══════════════════════════════════════════════╣");
        int idx = 1;
        for (Item it : menu()) {
            if (it.action == null) {
                System.out.println("║  " + padRight(it.label, 44) + "║");
            } else {
                System.out.println("║   " + padRight(String.format("%2d.  %s", idx, it.label), 43) + "║");
                idx++;
            }
        }
        System.out.println("║                                              ║");
        System.out.println("║    0.  Sair                                  ║");
        System.out.println("╚══════════════════════════════════════════════╝");
    }

    public static void main(String[] args) {
        loadEnv();
        baseUrl = envOr("BASE_URL", "https://hml.api.pedagioeletronico.com.br/gestao-webhooks-api/v1");
        cachedParceiroId = env("PE_PARCEIRO_ID");

        if (cachedParceiroId == null) {
            System.out.println("\n  ℹ️  PE_PARCEIRO_ID não configurado — chamando GET /me para descobrir...");
            resolveParceiroId();
        }

        List<Runnable> actions = new ArrayList<>();
        for (Item it : menu()) if (it.action != null) actions.add(it.action);

        while (true) {
            printMenu();
            String choice = prompt("\nEscolha uma opção: ").trim().toLowerCase();
            if (choice.equals("0") || choice.equals("sair")) break;
            int n;
            try { n = Integer.parseInt(choice); }
            catch (NumberFormatException e) { System.out.println("Opção inválida."); continue; }
            if (n < 1 || n > actions.size()) { System.out.println("Opção inválida."); continue; }
            try { actions.get(n - 1).run(); }
            catch (Exception e) { System.out.println("Erro inesperado: " + e.getMessage()); }
            prompt("\n[Enter para voltar ao menu]");
        }
        System.out.println("\nAté logo!\n");
    }
}
#!/usr/bin/env bash
# Utilitário de teste para API Movvia Arrecada+ (PE)
# Requer: bash 4+, curl, jq
set -o pipefail

# ─── Pré-requisitos ───────────────────────────────────────────────────────────
command -v curl >/dev/null 2>&1 || {
    echo "❌  curl não encontrado. Instale curl e rode novamente."
    exit 1
}
command -v jq >/dev/null 2>&1 || {
    echo "❌  jq não encontrado. Instale jq (brew install jq / apt install jq) e rode novamente."
    exit 1
}

# ─── Carregar .env ────────────────────────────────────────────────────────────
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$DIR/.env" ]; then
    while IFS= read -r line || [ -n "$line" ]; do
        case "$line" in
            ''|\#*) continue ;;
            *=*)
                key="${line%%=*}"
                val="${line#*=}"
                key="$(echo "$key" | tr -d '[:space:]')"
                val="${val#\"}"; val="${val%\"}"
                val="${val#\'}"; val="${val%\'}"
                [ -n "$key" ] && [ -z "${!key:-}" ] && export "$key=$val"
                ;;
        esac
    done < "$DIR/.env"
fi

BASE_URL="${BASE_URL:-https://hml.api.pedagioeletronico.com.br/gestao-webhooks-api/v1}"
CACHED_PARCEIRO_ID="${PE_PARCEIRO_ID:-}"

check_creds() {
    if [ -z "${PE_USERNAME:-}" ] || [ -z "${PE_PASSWORD:-}" ]; then
        echo
        echo "❌  Configure PE_USERNAME e PE_PASSWORD no arquivo .env"
        echo "   Copie .env.example → .env e preencha as credenciais."
        echo
        exit 1
    fi
}

auth_header() {
    printf 'Basic %s' "$(printf '%s:%s' "$PE_USERNAME" "$PE_PASSWORD" | base64 | tr -d '\n')"
}

uri_encode() {
    jq -rn --arg s "$1" '$s|@uri'
}

# ─── Resolução de parceiroId ──────────────────────────────────────────────────
resolve_parceiro_id() {
    if [ -n "$CACHED_PARCEIRO_ID" ]; then
        printf '%s' "$CACHED_PARCEIRO_ID"
        return
    fi
    local body status
    status=$(curl -sS -o /tmp/movvia_me_body -w '%{http_code}' \
        -H "Authorization: $(auth_header)" \
        -H "Content-Type: application/json" \
        "$BASE_URL/me" 2>/tmp/movvia_me_err) || {
        echo "❌  Erro de rede em GET /me: $(cat /tmp/movvia_me_err)" >&2
        exit 1
    }
    if [ "$status" -ge 400 ]; then
        echo "❌  Não foi possível descobrir o parceiroId via GET /me ($status)" >&2
        cat /tmp/movvia_me_body >&2
        exit 1
    fi
    body=$(cat /tmp/movvia_me_body)
    local pid
    pid=$(echo "$body" | jq -r '.data.parceiroId // empty')
    if [ -z "$pid" ]; then
        echo "❌  Resposta sem data.parceiroId: $body" >&2
        exit 1
    fi
    CACHED_PARCEIRO_ID="$pid"
    echo "  ℹ️  parceiroId descoberto via GET /me: $CACHED_PARCEIRO_ID" >&2
    printf '%s' "$pid"
}

# ─── Função abstratora ────────────────────────────────────────────────────────
# req METHOD PATH [BODY] [QUERY_STRING] [EXTRA_HEADER...]
req() {
    local method="$1" path="$2" body="${3:-}" query="${4:-}"
    if [ "$#" -ge 4 ]; then shift 4; else shift "$#"; fi

    local url="$BASE_URL$path"
    [ -n "$query" ] && url="$url?$query"

    local -a headers
    headers=(-H "Authorization: $(auth_header)" -H "Content-Type: application/json")
    if [ "$path" != "/me" ]; then
        local pid
        pid=$(resolve_parceiro_id)
        headers+=(-H "x-parceiro-id: $pid")
    fi
    while [ "$#" -gt 0 ]; do
        headers+=(-H "$1")
        shift
    done

    echo
    echo "→ $method $url"
    [ -n "$body" ] && echo "  Body: $body"

    local -a curl_args
    curl_args=(-sS -o /tmp/movvia_resp_body -w '%{http_code}' -X "$method" "$url" "${headers[@]}")
    [ -n "$body" ] && curl_args+=(-d "$body")

    local status
    status=$(curl "${curl_args[@]}" 2>/tmp/movvia_resp_err) || {
        echo "❌  Erro de rede: $(cat /tmp/movvia_resp_err)"
        return 1
    }

    local mark="❌"
    [ "$status" -lt 400 ] && mark="✅"
    echo "$mark $status"
    if [ -s /tmp/movvia_resp_body ]; then
        if jq -e . /tmp/movvia_resp_body >/dev/null 2>&1; then
            jq . /tmp/movvia_resp_body
        else
            cat /tmp/movvia_resp_body
        fi
    fi
}

# ─── Prompts ──────────────────────────────────────────────────────────────────
ask() {
    local q="$1" v
    read -r -p "$q" v
    printf '%s' "$v"
}

# ─── Ações ────────────────────────────────────────────────────────────────────
EVENTOS_STATIC=(
    "pe.pedido.criado"
    "pe.pedido.confirmado"
    "pe.pedido.cancelado"
    "pe.pedido.expirado"
    "pe.pedido.erro"
    "pe.transacao.recebida"
    "pe.transacao.atualizada"
    "pe.transacao.cancelada"
)

descobrir_parceiro() { req GET "/me"; }
listar_eventos_disponiveis() { req GET "/webhook/eventos"; }
listar_webhooks() { req GET "/webhook"; }

cadastrar_webhook() {
    local url chave
    url=$(ask "  URL HTTPS do endpoint: ")
    chave=$(ask "  Chave secreta HMAC: ")
    echo
    echo "  Buscando eventos disponíveis..."
    local body status
    local pid
    pid=$(resolve_parceiro_id)
    body=$(curl -sS \
        -H "Authorization: $(auth_header)" \
        -H "x-parceiro-id: $pid" \
        "$BASE_URL/webhook/eventos" 2>/dev/null) || body=""
    local -a eventos
    if [ -n "$body" ]; then
        mapfile -t eventos < <(echo "$body" | jq -r '
            .data | if type == "array" then
                .[] | (if type == "string" then . else .evento end)
            else empty end' 2>/dev/null)
    fi
    if [ "${#eventos[@]}" -eq 0 ]; then
        eventos=("${EVENTOS_STATIC[@]}")
    fi
    echo
    echo "  Eventos disponíveis:"
    local i=1
    for e in "${eventos[@]}"; do
        printf "    %d. %s\n" "$i" "$e"
        i=$((i + 1))
    done
    echo
    local raw tipo
    raw=$(ask "  Escolha o número do evento ou digite o nome: ")
    if [[ "$raw" =~ ^[0-9]+$ ]] && [ "$raw" -ge 1 ] && [ "$raw" -le "${#eventos[@]}" ]; then
        tipo="${eventos[$((raw - 1))]}"
    else
        tipo="$raw"
    fi
    if [ -z "$tipo" ]; then
        echo "❌ Tipo inválido."
        return
    fi
    local body_json
    body_json=$(jq -nc --arg url "$url" --arg c "$chave" --arg t "$tipo" \
        '{url:$url, chaveSecreta:$c, tipo:$t}')
    req POST "/webhook" "$body_json"
}

testar_conectividade_webhook() {
    local tipo enc
    tipo=$(ask "  Tipo do evento (ex: pe.pedido.confirmado): ")
    [ -z "$tipo" ] && return
    enc=$(uri_encode "$tipo")
    req POST "/webhook/teste-conectividade/$enc"
}

remover_webhook() {
    local tipo enc
    tipo=$(ask "  Tipo do webhook a remover (ex: pe.pedido.confirmado): ")
    [ -z "$tipo" ] && return
    enc=$(uri_encode "$tipo")
    req DELETE "/webhook/$enc"
}

cadastrar_placa() {
    local placa di df body
    placa=$(ask "  Placa (ex: ABC1D23): ")
    placa=$(echo "$placa" | tr '[:lower:]' '[:upper:]')
    di=$(ask "  Data início monitoramento ISO 8601 (Enter p/ pular): ")
    df=$(ask "  Data fim monitoramento ISO 8601 (Enter p/ pular): ")
    body=$(jq -nc --arg p "$placa" --arg di "$di" --arg df "$df" '
        {placa:$p}
        + (if $di != "" then {dataInicioMonitoramento:$di} else {} end)
        + (if $df != "" then {dataFimMonitoramento:$df} else {} end)')
    req POST "/placas" "$body"
}

remover_placa() {
    local placa
    placa=$(ask "  Placa a remover (ex: ABC1D23): ")
    placa=$(echo "$placa" | tr '[:lower:]' '[:upper:]')
    req DELETE "/placas/$placa"
}

build_query() {
    local out=""
    while [ "$#" -gt 0 ]; do
        local k="$1" v="$2"
        shift 2
        [ -z "$v" ] && continue
        local enc_k enc_v
        enc_k=$(uri_encode "$k")
        enc_v=$(uri_encode "$v")
        [ -n "$out" ] && out="$out&"
        out="$out$enc_k=$enc_v"
    done
    printf '%s' "$out"
}

listar_transacoes() {
    local placa status di df pagina tamanho q
    placa=$(ask "  Filtrar por placa (Enter p/ pular): ")
    status=$(ask "  Status [PENDENTE/PAGO/CANCELADA] (Enter p/ pular): ")
    di=$(ask "  Data início ISO 8601 (Enter p/ pular): ")
    df=$(ask "  Data fim ISO 8601 (Enter p/ pular): ")
    pagina=$(ask "  Página (Enter = 0): "); pagina="${pagina:-0}"
    tamanho=$(ask "  Tamanho página (Enter = 50): "); tamanho="${tamanho:-50}"
    q=$(build_query placa "$placa" status "$status" \
        dataInicio "$di" dataFim "$df" \
        pagina "$pagina" tamanhoPagina "$tamanho")
    req GET "/transacoes" "" "$q"
}

criar_pedido() {
    local raw idemp body
    raw=$(ask "  IDs de transações separados por vírgula: ")
    body=$(jq -nc --arg s "$raw" '{
        transacoes: ($s | split(",")
            | map(. | gsub("^\\s+|\\s+$"; ""))
            | map(select(length > 0))
            | map({transacaoId: .}))
    }')
    idemp=$(ask "  Chave de idempotência (Opcional): ")
    if [ -n "$idemp" ]; then
        req POST "/pedidos" "$body" "" "x-chave-idempotencia: $idemp"
    else
        req POST "/pedidos" "$body"
    fi
}

consultar_pedido() {
    local pid
    pid=$(ask "  ID do pedido: ")
    req GET "/pedidos/$pid"
}

confirmar_pagamento() {
    local pid ref dt idemp body
    pid=$(ask "  ID do pedido: ")
    ref=$(ask "  Referência externa (Enter p/ pular): ")
    dt=$(ask "  Data liquidação externa ISO 8601 (Enter p/ pular): ")
    idemp=$(ask "  Chave de idempotência (Opcional): ")
    body=$(jq -nc --arg ref "$ref" --arg dt "$dt" '
        (if $ref != "" then {referenciaExterna:$ref} else {} end)
        + (if $dt != "" then {dataLiquidacaoExterna:$dt} else {} end)')
    [ "$body" = "{}" ] && body=""
    if [ -n "$idemp" ]; then
        req POST "/pedidos/$pid/confirmar" "$body" "" "x-chave-idempotencia: $idemp"
    else
        req POST "/pedidos/$pid/confirmar" "$body"
    fi
}

cancelar_pedido() {
    local pid motivo obs idemp body
    pid=$(ask "  ID do pedido: ")
    motivo=$(ask "  Motivo [DESISTENCIA_PARCEIRO/ERRO_INTERNO/OUTROS] (Enter p/ pular): ")
    obs=$(ask "  Observação até 500 chars (Enter p/ pular): ")
    idemp=$(ask "  Chave de idempotência (Opcional): ")
    if [ -n "$motivo" ]; then
        motivo=$(echo "$motivo" | tr '[:lower:]' '[:upper:]')
    fi
    body=$(jq -nc --arg m "$motivo" --arg o "$obs" '
        (if $m != "" then {motivo:$m} else {} end)
        + (if $o != "" then {observacao:$o} else {} end)')
    [ "$body" = "{}" ] && body=""
    if [ -n "$idemp" ]; then
        req POST "/pedidos/$pid/cancelar" "$body" "" "x-chave-idempotencia: $idemp"
    else
        req POST "/pedidos/$pid/cancelar" "$body"
    fi
}

# ─── Menu ─────────────────────────────────────────────────────────────────────
MENU_LABELS=(
    "── Primeiros Passos ─────────────────"
    "Descobrir parceiroId (GET /me)"
    "── Webhooks Config ──────────────────"
    "Listar eventos disponíveis"
    "Cadastrar webhook"
    "Listar webhooks"
    "Testar conectividade webhook"
    "Remover webhook"
    "── Placas ───────────────────────────"
    "Cadastrar placa"
    "Remover placa"
    "── Transações ───────────────────────"
    "Listar transações"
    "── Pedidos ──────────────────────────"
    "Criar pedido"
    "Consultar pedido"
    "Confirmar pagamento"
    "Cancelar pedido"
)

MENU_ACTIONS=(
    ""
    "descobrir_parceiro"
    ""
    "listar_eventos_disponiveis"
    "cadastrar_webhook"
    "listar_webhooks"
    "testar_conectividade_webhook"
    "remover_webhook"
    ""
    "cadastrar_placa"
    "remover_placa"
    ""
    "listar_transacoes"
    ""
    "criar_pedido"
    "consultar_pedido"
    "confirmar_pagamento"
    "cancelar_pedido"
)

print_menu() {
    echo
    echo "╔══════════════════════════════════════════════╗"
    echo "║   Movvia Arrecada+ — PE API Tester           ║"
    local short="${BASE_URL#https://}"
    if [ "${#short}" -gt 43 ]; then
        short="${short:0:40}..."
    fi
    printf "║   %-43s║\n" "$short"
    local pid
    if [ -n "$CACHED_PARCEIRO_ID" ]; then
        pid="parceiroId: $CACHED_PARCEIRO_ID"
    else
        pid="parceiroId: (auto via GET /me)"
    fi
    printf "║   %-43s║\n" "$pid"
    echo "╠══════════════════════════════════════════════╣"
    local idx=1
    for i in "${!MENU_LABELS[@]}"; do
        if [ -z "${MENU_ACTIONS[$i]}" ]; then
            printf "║  %-44s║\n" "${MENU_LABELS[$i]}"
        else
            printf "║   %2d.  %-39s║\n" "$idx" "${MENU_LABELS[$i]}"
            idx=$((idx + 1))
        fi
    done
    echo "║                                              ║"
    echo "║    0.  Sair                                  ║"
    echo "╚══════════════════════════════════════════════╝"
}

main() {
    check_creds
    if [ -z "$CACHED_PARCEIRO_ID" ]; then
        echo
        echo "  ℹ️  PE_PARCEIRO_ID não configurado — chamando GET /me para descobrir..."
        CACHED_PARCEIRO_ID=$(resolve_parceiro_id)
    fi

    local -a active=()
    for a in "${MENU_ACTIONS[@]}"; do
        [ -n "$a" ] && active+=("$a")
    done

    while true; do
        print_menu
        echo
        local choice
        read -r -p "Escolha uma opção: " choice
        choice="${choice,,}"
        if [ "$choice" = "0" ] || [ "$choice" = "sair" ]; then
            break
        fi
        if ! [[ "$choice" =~ ^[0-9]+$ ]]; then
            echo "Opção inválida."
            continue
        fi
        if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#active[@]}" ]; then
            echo "Opção inválida."
            continue
        fi
        "${active[$((choice - 1))]}" || true
        echo
        read -r -p "[Enter para voltar ao menu] " _
    done

    echo
    echo "Até logo!"
    echo
}

main

Passo 3 — Salvar arquivos auxiliares

Crie .env.example com o template de credenciais:

# Credenciais Basic Auth (fornecidas pela Movvia no onboarding)
PE_USERNAME=seu_usuario
PE_PASSWORD=sua_senha

# ID do Parceiro — OPCIONAL. Se omitido, o script chama GET /me automaticamente
# para descobrir o parceiroId vinculado às credenciais acima.
# PE_PARCEIRO_ID=prc_conectcar

# URL base — padrão é homologação (descomente para sobrescrever)
# BASE_URL=https://hml.api.pedagioeletronico.com.br/gestao-webhooks-api/v1

E o start.sh, que pede usuário/senha na primeira run e despacha pra linguagem escolhida:

#!/bin/sh
# Bootstrap do simulador. Onboarding de credenciais na primeira run e despacho
# pra linguagem escolhida via --lang=node|python|go|java|bash (default node).
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR" || exit 1

SIM_LANG="node"
for arg in "$@"; do
    case "$arg" in
        --lang=*) SIM_LANG="${arg#--lang=}" ;;
        -h|--help)
            echo "Uso: $0 [--lang=node|python|go|java|bash]"
            echo "Default: node"
            exit 0
            ;;
    esac
done

if [ ! -f .env ]; then
    echo "Primeira execução — configurando credenciais."
    printf "Usuário: "; read -r u
    printf "Senha:   "; stty -echo; read -r p; stty echo; echo
    printf "PE_USERNAME=%s\nPE_PASSWORD=%s\n" "$u" "$p" > .env
    echo "Credenciais salvas em .env."
fi

case "$SIM_LANG" in
    node)        node simulador.js ;;
    python|py)   python3 simulador.py ;;
    go)          go run simulador.go ;;
    java)        java Simulador.java ;;
    bash|sh)     bash simulador.sh ;;
    *)
        echo "Linguagem inválida: $SIM_LANG"
        echo "Uso: $0 [--lang=node|python|go|java|bash]"
        exit 1
        ;;
esac

Passo 4 — Configurar credenciais e rodar

chmod +x start.sh
./start.sh                 # roda Node.js (default)
./start.sh --lang=python   # Python
./start.sh --lang=go       # Go
./start.sh --lang=java     # Java
./start.sh --lang=bash     # Bash + curl + jq

Na primeira execução, o start.sh pergunta usuário e senha, salva no .env e abre o menu. As próximas execuções usam o .env direto.

Se preferir invocar a linguagem manualmente, sem o start.sh:

node simulador.js
python3 simulador.py
go run simulador.go
java Simulador.java
bash simulador.sh

O que o simulador cobre

GrupoOperações
Primeiros PassosDescobrir parceiroId via GET /me (automático no startup)
WebhooksListar eventos, cadastrar, listar, testar conectividade, remover
PlacasCadastrar, remover
TransaçõesListar com filtros (paginação inicia em 0)
PedidosCriar, consultar, confirmar, cancelar

Toda chamada HTTP é impressa no formato:

→ METHOD URL
  Body: { ... }
✅ 200
{ "data": ... }

Exemplo do menu

╔══════════════════════════════════════════════╗
║   Movvia Arrecada+ — PE API Tester           ║
║   hml.api.pedagioeletronico.com.br/...       ║
║   parceiroId: par_7f3a1b2c                   ║
╠══════════════════════════════════════════════╣
║  ── Primeiros Passos ─────────────────       ║
║    1.  Descobrir parceiroId (GET /me)        ║
║  ── Webhooks Config ──────────────────       ║
║    2.  Listar eventos disponíveis            ║
║    3.  Cadastrar webhook                     ║
║    4.  Listar webhooks                       ║
║    5.  Testar conectividade webhook          ║
║    6.  Remover webhook                       ║
║  ── Placas ───────────────────────────       ║
║    7.  Cadastrar placa                       ║
║    8.  Remover placa                         ║
║  ── Transações ───────────────────────       ║
║    9.  Listar transações                     ║
║  ── Pedidos ──────────────────────────       ║
║   10.  Criar pedido                          ║
║   11.  Consultar pedido                      ║
║   12.  Confirmar pagamento                   ║
║   13.  Cancelar pedido                       ║
║                                              ║
║    0.  Sair                                  ║
╚══════════════════════════════════════════════╝

Próximos passos