HEX
Server: Apache/2.4.58 (Ubuntu)
System: Linux Bradford-Sitios 6.14.0-1017-azure #17~24.04.1-Ubuntu SMP Mon Dec 1 20:10:50 UTC 2025 x86_64
User: www-data (33)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: /var/www/matriculas_api_dev/tests/Manual/TokuSmokeTest.php
<?php
/**
 * SMOKE TEST: validación end-to-end del refactor de external_ids contra Toku sandbox real.
 *
 * Uso:
 *   php tests/Manual/TokuSmokeTest.php emit-onetime <contract_id> <detail_id>
 *   php tests/Manual/TokuSmokeTest.php emit-pac      <contract_id> <detail_ids_csv>
 *   php tests/Manual/TokuSmokeTest.php monitor       <detail_id>
 *   php tests/Manual/TokuSmokeTest.php inspect       <detail_id>
 *   php tests/Manual/TokuSmokeTest.php cancel        <payment_id>
 *   php tests/Manual/TokuSmokeTest.php preflight
 *
 * Casos típicos en servidor de pruebas:
 *   1. php tests/Manual/TokuSmokeTest.php preflight
 *   2. php tests/Manual/TokuSmokeTest.php emit-onetime 144 23913
 *      → te da el link Toku, vas a pagar
 *   3. php tests/Manual/TokuSmokeTest.php monitor 23913
 *      → espera hasta que llegue el webhook y valida
 *   4. php tests/Manual/TokuSmokeTest.php inspect 23913
 *      → muestra estado final del detail + presencial_payment + Toku
 */

require_once __DIR__ . '/../../vendor/autoload.php';
$app = require_once __DIR__ . '/../../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();

use App\Models\Contract;
use App\Models\ContractDetail;
use App\Models\PresencialPayment;
use App\Models\WebhookLog;
use App\Models\User;
use App\Services\PresencialService;
use App\Services\ApiTokuService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

// Auth fake como primer admin para que registered_by se popule
$adminUser = User::first();
if ($adminUser) {
    Auth::login($adminUser);
}

// ─────────────────────────────────────────────
// COLORES
// ─────────────────────────────────────────────
function green($s)  { return "\033[32m$s\033[0m"; }
function red($s)    { return "\033[31m$s\033[0m"; }
function yellow($s) { return "\033[33m$s\033[0m"; }
function cyan($s)   { return "\033[36m$s\033[0m"; }
function bold($s)   { return "\033[1m$s\033[0m"; }
function ok($s)     { echo "  " . green("✓ ") . "$s\n"; }
function fail($s)   { echo "  " . red("✗ ") . "$s\n"; }
function info($s)   { echo "  " . cyan("ℹ ") . "$s\n"; }
function warn($s)   { echo "  " . yellow("⚠ ") . "$s\n"; }
function section($s){ echo "\n" . bold(cyan("═══ $s ═══")) . "\n"; }

// ─────────────────────────────────────────────
// COMANDOS
// ─────────────────────────────────────────────
$cmd = $argv[1] ?? 'help';

switch ($cmd) {
    case 'preflight':       preflight();              break;
    case 'emit-onetime':    emitOnetime($argv[2] ?? null, $argv[3] ?? null);  break;
    case 'emit-pac':        emitPac($argv[2] ?? null, $argv[3] ?? null);      break;
    case 'monitor':         monitor($argv[2] ?? null);                        break;
    case 'inspect':         inspect($argv[2] ?? null);                        break;
    case 'cancel':          cancel($argv[2] ?? null);                         break;
    case 'list-candidates': listCandidates();                                 break;
    default:                showHelp();                                       break;
}

// ─────────────────────────────────────────────
// 0. PREFLIGHT — verifica que el deploy esté OK
// ─────────────────────────────────────────────
function preflight() {
    section('PREFLIGHT: verificando deploy');

    // 1. WebhookTokuService tiene findDetailForWebhook
    $file = file_get_contents(__DIR__ . '/../../app/Services/WebhookTokuService.php');
    if (strpos($file, 'findDetailForWebhook') !== false) {
        ok("WebhookTokuService::findDetailForWebhook presente (Fase 1 deployada)");
    } else {
        fail("findDetailForWebhook NO encontrado — Fase 1 NO deployada");
        return;
    }

    if (strpos($file, 'whereJsonContains(\'toku_debt_ids\'') !== false) {
        ok("Match por id_invoice (whereJsonContains toku_debt_ids) presente");
    } else {
        fail("Match por id_invoice NO encontrado");
    }

    if (strpos($file, 'skip update paid_at') !== false) {
        ok("Guard contra sobreescribir paid_at presente");
    } else {
        warn("Guard paid_at NO encontrado (mejora menor opcional)");
    }

    // 2. PresencialService SIN timestamp
    $ps = file_get_contents(__DIR__ . '/../../app/Services/PresencialService.php');
    if (strpos($ps, "format('His')") === false) {
        ok("PresencialService: timestamp 'HHMMSS' eliminado (Fase 2 deployada)");
    } else {
        fail("Timestamp 'format(His)' AÚN PRESENTE en PresencialService — Fase 2 NO deployada");
        $count = substr_count($ps, "format('His')");
        info("Encontradas $count ocurrencias");
    }

    // 3. Toku config válida
    try {
        $config = getConfiguration('toku');
        if (!empty($config['api_token'])) {
            ok("Toku config presente: token=" . substr($config['api_token'], 0, 10) . "...");
            info("URL: " . ($config['url_toku'] ?? 'N/A'));
        } else {
            fail("Toku api_token vacío");
        }
    } catch (\Throwable $e) {
        fail("Error leyendo config Toku: " . $e->getMessage());
    }

    // 4. BD: distribución actual
    section('Estado BD actual');
    $stats = DB::select("
        SELECT
            SUM(CASE WHEN code_toku IS NULL AND toku_debt_ids IS NULL THEN 1 ELSE 0 END) AS both_null,
            SUM(CASE WHEN code_toku IS NOT NULL AND toku_debt_ids IS NULL THEN 1 ELSE 0 END) AS solo_code_toku,
            SUM(CASE WHEN code_toku IS NULL AND toku_debt_ids IS NOT NULL THEN 1 ELSE 0 END) AS solo_debt_ids,
            SUM(CASE WHEN code_toku IS NOT NULL AND toku_debt_ids IS NOT NULL THEN 1 ELSE 0 END) AS ambos
        FROM contracts_detail
    ");
    $s = $stats[0];
    info("Details con ambos populated:  " . $s->ambos);
    info("Details solo code_toku (legacy): " . $s->solo_code_toku);
    info("Details solo toku_debt_ids:   " . $s->solo_debt_ids);
    info("Details ambos NULL:           " . $s->both_null);

    // 5. ¿Hay detalles con timestamp en code_toku?
    $withTimestamp = DB::select("
        SELECT COUNT(*) AS n FROM contracts_detail
        WHERE code_toku REGEXP '_[0-9]{6}\$'
    ");
    info("Details con code_toku terminando en _HHMMSS: " . $withTimestamp[0]->n . " (legacy pre-deploy, OK)");

    section('LISTO');
    echo "Si todo OK, podés proceder con: emit-onetime / emit-pac\n";
    echo "Para ver candidatos: " . cyan("php tests/Manual/TokuSmokeTest.php list-candidates") . "\n\n";
}

// ─────────────────────────────────────────────
// list-candidates — encuentra contratos/details aptos para probar
// ─────────────────────────────────────────────
function listCandidates() {
    section('CANDIDATOS PARA TESTS');

    echo bold("\nDetalles unpaid no-colegiatura (para pago one-time):\n");
    $rows = DB::select("
        SELECT cd.id AS detail_id, cd.contract_id, ct.code_contract, pcd.code AS concept, cd.amount
        FROM contracts_detail cd
        JOIN contracts ct ON ct.id = cd.contract_id
        JOIN status_contract sc ON sc.id = ct.status_contract_id
        JOIN payment_concept_details pcd ON pcd.id = cd.payment_concept_detail_id
        WHERE cd.paid = 0
          AND sc.code != 'canceled'
          AND pcd.code NOT LIKE 'COLEGIATURA%'
        ORDER BY cd.id DESC
        LIMIT 10
    ");
    foreach ($rows as $r) {
        echo "  detail_id=" . str_pad($r->detail_id, 6) . " contract=" . $r->code_contract . " concept=" . $r->concept . " amount=" . $r->amount . "\n";
    }

    echo bold("\nDetalles unpaid de COLEGIATURA (para pago PAC/PAT):\n");
    $rows = DB::select("
        SELECT cd.id AS detail_id, cd.contract_id, ct.code_contract, pcd.code AS concept, cd.amount
        FROM contracts_detail cd
        JOIN contracts ct ON ct.id = cd.contract_id
        JOIN status_contract sc ON sc.id = ct.status_contract_id
        JOIN payment_concept_details pcd ON pcd.id = cd.payment_concept_detail_id
        WHERE cd.paid = 0
          AND sc.code != 'canceled'
          AND pcd.code LIKE 'COLEGIATURA%'
        ORDER BY cd.id DESC
        LIMIT 5
    ");
    foreach ($rows as $r) {
        echo "  detail_id=" . str_pad($r->detail_id, 6) . " contract=" . $r->code_contract . " concept=" . $r->concept . " amount=" . $r->amount . "\n";
    }
    echo "\n";
}

// ─────────────────────────────────────────────
// emit-onetime — crea invoice en Toku para un detail
// ─────────────────────────────────────────────
function emitOnetime($contractId, $detailId) {
    if (!$contractId || !$detailId) {
        fail("Uso: emit-onetime <contract_id> <detail_id>");
        return;
    }
    section("EMIT ONE-TIME (contract=$contractId, detail=$detailId)");

    $detail = ContractDetail::find($detailId);
    if (!$detail) { fail("Detail $detailId no existe"); return; }
    if ($detail->contract_id != $contractId) { fail("Detail no pertenece a contrato $contractId"); return; }
    if ($detail->paid) { fail("Detail ya está pagado"); return; }

    info("Estado pre-emisión:");
    info("  code_toku = " . ($detail->code_toku ?? 'NULL'));
    info("  toku_debt_ids = " . json_encode($detail->toku_debt_ids));

    try {
        $svc = app(PresencialService::class);
        $result = $svc->createTokuPayment((int)$contractId, [(int)$detailId]);

        $detail->refresh();
        echo "\n";
        ok("Invoice emitida en Toku");
        info("  code_toku NUEVO: " . $detail->code_toku);
        info("  toku_debt_ids NUEVO: " . json_encode($detail->toku_debt_ids));

        // Validar formato sin timestamp
        if (preg_match('/_[0-9]{6}$/', $detail->code_toku)) {
            fail("❌ El external_id TIENE timestamp! La Fase 2 no está deployada");
        } else {
            ok("✅ external_id SIN timestamp — Fase 2 OK");
        }

        // Mostrar link
        $pp = PresencialPayment::where('contract_id', $contractId)
            ->orderBy('id', 'desc')->first();
        if ($pp && !empty($pp->subscription_link)) {
            echo "\n" . bold("Link para pagar:") . "\n";
            echo "  " . cyan($pp->subscription_link) . "\n";
            echo "\n" . bold("PresencialPayment id:") . " " . $pp->id . "\n";
        }

        echo "\n" . bold("Siguiente paso:") . "\n";
        echo "  1. Pagar con tarjeta test: 4111 1111 1111 1111, CVV 123, fecha futura\n";
        echo "  2. Ejecutar: " . cyan("php tests/Manual/TokuSmokeTest.php monitor $detailId") . "\n";
        echo "  3. O inspeccionar: " . cyan("php tests/Manual/TokuSmokeTest.php inspect $detailId") . "\n\n";

    } catch (\Throwable $e) {
        fail("Error: " . $e->getMessage());
        echo "Trace:\n" . $e->getTraceAsString() . "\n";
    }
}

// ─────────────────────────────────────────────
// emit-pac — crea suscripción PAC/PAT
// ─────────────────────────────────────────────
function emitPac($contractId, $detailIdsCsv) {
    if (!$contractId || !$detailIdsCsv) {
        fail("Uso: emit-pac <contract_id> <detail_id1,detail_id2,...>");
        return;
    }
    section("EMIT PAC/PAT (contract=$contractId, details=$detailIdsCsv)");

    $detailIds = array_map('intval', explode(',', $detailIdsCsv));

    try {
        // Necesitamos un payment_method_id válido para PAC. Buscar uno.
        $pm = DB::table('payment_methods')->where('allows_payment_plan', 1)->first();
        if (!$pm) { fail("No hay payment_method con allows_payment_plan=1"); return; }
        info("Payment method PAC: id=" . $pm->id . " (" . $pm->payment_method . ")");

        $svc = app(PresencialService::class);
        // Firma: ($contractId, $detailIds, $paymentMethodId, $referenceNumber=null, $notes=null, $attachmentFile=null, $isSubscription=false)
        $result = $svc->processPosPayment(
            (int)$contractId, $detailIds, (int)$pm->id, null, null, null, true
        );

        echo "\n";
        ok("PAC/PAT emitido");

        // Inspeccionar los details
        foreach ($detailIds as $did) {
            $d = ContractDetail::find($did);
            if (!$d) continue;
            info("Detail $did: code_toku = " . $d->code_toku);
            info("Detail $did: " . count($d->toku_debt_ids ?? []) . " invoice IDs (cuotas)");
            if (preg_match('/_[0-9]{6}$/', $d->code_toku ?? '')) {
                fail("❌ Detail $did tiene timestamp!");
            }
        }

        $pp = PresencialPayment::where('contract_id', $contractId)
            ->orderBy('id', 'desc')->first();
        if ($pp && !empty($pp->subscription_link)) {
            echo "\n" . bold("Link para activar PAC:") . "\n";
            echo "  " . cyan($pp->subscription_link) . "\n";
            echo "\n" . bold("PresencialPayment id:") . " " . $pp->id . "\n";
        }

        echo "\n" . bold("Siguiente paso:") . "\n";
        echo "  1. Activar PAC en el link (datos de tarjeta test)\n";
        echo "  2. Monitor: " . cyan("php tests/Manual/TokuSmokeTest.php monitor " . $detailIds[0]) . "\n\n";

    } catch (\Throwable $e) {
        fail("Error: " . $e->getMessage());
        echo $e->getTraceAsString() . "\n";
    }
}

// ─────────────────────────────────────────────
// monitor — polling esperando webhook
// ─────────────────────────────────────────────
function monitor($detailId) {
    if (!$detailId) { fail("Uso: monitor <detail_id>"); return; }
    section("MONITOR (esperando webhook para detail $detailId)");

    $detail = ContractDetail::find($detailId);
    if (!$detail) { fail("Detail no existe"); return; }

    $initialPaid = $detail->paid;
    $startTime = time();
    $maxWait = 300; // 5 min
    $lastLogId = WebhookLog::max('id') ?? 0;

    info("Estado inicial: paid=" . ($initialPaid ? 'true' : 'false'));
    info("Esperando hasta " . $maxWait . "s (Ctrl+C para abortar)");
    echo "\n";

    while (time() - $startTime < $maxWait) {
        // Check nuevos webhooks (transaction + payment_method.attached_products para PAC)
        $newLogs = WebhookLog::where('id', '>', $lastLogId)
            ->where(function($q) {
                $q->where('event_type', 'LIKE', 'transaction.%')
                  ->orWhere('event_type', 'payment_method.attached_products');
            })
            ->orderBy('id', 'asc')
            ->get();

        foreach ($newLogs as $log) {
            $lastLogId = $log->id;
            $payload = json_decode($log->payload, true);

            // CASO 1: payment_method.attached_products (PAC activado)
            if ($log->event_type === 'payment_method.attached_products') {
                $detail->refresh();
                $ourSubId = $detail->toku_subscription_id;
                $payloadSubIds = $payload['subscription_ids'] ?? [];

                if (in_array($ourSubId, $payloadSubIds)) {
                    echo "\n" . green("🎯 WEBHOOK MATCH (PAC activado)!") . " log_id={$log->id}\n";
                    ok("event: payment_method.attached_products");
                    ok("subscription_ids: " . json_encode($payloadSubIds));
                    ok("nuestra toku_subscription_id: " . $ourSubId);
                    ok("webhook status_code: " . $log->status_code);
                    ok("webhook reason: " . $log->reason);

                    echo "\n" . bold("Validación post-webhook:") . "\n";
                    if ($detail->paid) {
                        ok("detail.paid = TRUE");
                        ok("detail.paid_at = " . $detail->paid_at);

                        $pp = PresencialPayment::where('contract_id', $detail->contract_id)
                            ->orderBy('id', 'desc')->first();
                        if ($pp) {
                            info("PresencialPayment status: " . $pp->subscription_status);
                            $rd = $pp->receipt_data ?? [];
                            info("  toku_subscription_ids guardados: " . json_encode($rd['toku_subscription_ids'] ?? []));
                            info("  toku_invoice_ids: " . count($rd['toku_invoice_ids'] ?? []) . " cuotas");
                        }
                    } else {
                        fail("PAC activado pero detail.paid sigue FALSE");
                    }
                    return;
                }
                continue;
            }

            // CASO 2: transaction.success / transaction.failed
            $intents = $payload['payment_intents'] ?? [];

            // ¿Algún intent matchea nuestro detail?
            $detail->refresh();
            $ourIds = $detail->toku_debt_ids ?? [];
            $matchingIntent = null;
            foreach ($intents as $i) {
                if (in_array($i['id_invoice'] ?? '', $ourIds)) {
                    $matchingIntent = $i;
                    break;
                }
            }

            if ($matchingIntent) {
                echo "\n" . green("🎯 WEBHOOK MATCH!") . " log_id={$log->id}, event={$log->event_type}\n";
                ok("transaction_id: " . $log->external_id);
                ok("id_invoice: " . $matchingIntent['id_invoice']);
                ok("invoice_external_id: " . $matchingIntent['invoice_external_id']);
                ok("status: " . ($matchingIntent['status'] ?? '?'));
                ok("webhook status_code: " . $log->status_code);
                ok("webhook reason: " . $log->reason);

                // Validar matching
                $detail->refresh();
                echo "\n" . bold("Validación post-webhook:") . "\n";
                if ($detail->paid) {
                    ok("detail.paid = TRUE");
                    ok("detail.paid_at = " . $detail->paid_at);
                    ok("detail.toku_payment_id = " . $detail->toku_payment_id);

                    // Validar que se usó la nueva lógica (id_invoice)
                    if ($detail->toku_payment_id === $log->external_id) {
                        ok("✅ toku_payment_id matchea con transaction_id del webhook");
                    }

                    // Validar PresencialPayment
                    $pp = PresencialPayment::where('contract_id', $detail->contract_id)
                        ->orderBy('id', 'desc')->first();
                    if ($pp) {
                        info("PresencialPayment status: " . $pp->subscription_status);
                    }
                } else {
                    if (strtoupper($matchingIntent['status'] ?? '') === 'AUTHORIZED') {
                        fail("Webhook AUTHORIZED pero detail.paid sigue FALSE — posible bug");
                    } else {
                        info("Webhook no autorizado (status=" . $matchingIntent['status'] . "), detail no pagado");
                    }
                }
                return;
            }
        }

        echo ".";
        sleep(2);
    }
    echo "\n";
    warn("Timeout — webhook no llegó en {$maxWait}s. Verifica logs de Toku.");
}

// ─────────────────────────────────────────────
// inspect — estado completo de un detail
// ─────────────────────────────────────────────
function inspect($detailId) {
    if (!$detailId) { fail("Uso: inspect <detail_id>"); return; }
    section("INSPECT detail $detailId");

    $d = ContractDetail::with(['contract', 'paymentConceptDetail', 'student'])->find($detailId);
    if (!$d) { fail("Detail no existe"); return; }

    echo bold("Detail $detailId:") . "\n";
    info("contract: " . $d->contract->code_contract . " (id=" . $d->contract_id . ")");
    info("concept: " . ($d->paymentConceptDetail->code ?? 'N/A'));
    info("amount: " . $d->amount);
    info("paid: " . ($d->paid ? "TRUE (paid_at=" . $d->paid_at . ")" : "FALSE"));
    info("code_toku: " . ($d->code_toku ?? 'NULL'));
    info("toku_subscription_id: " . ($d->toku_subscription_id ?? 'NULL'));
    info("toku_debt_ids: " . json_encode($d->toku_debt_ids));
    info("toku_payment_id: " . ($d->toku_payment_id ?? 'NULL'));

    if ($d->code_toku && preg_match('/_[0-9]{6}$/', $d->code_toku)) {
        warn("⚠ code_toku TIENE timestamp HHMMSS (legacy)");
    }

    // PresencialPayments relacionados
    echo "\n" . bold("PresencialPayments del contrato:") . "\n";
    $pps = PresencialPayment::where('contract_id', $d->contract_id)
        ->orderBy('id', 'desc')->limit(3)->get();
    foreach ($pps as $pp) {
        info("payment id=" . $pp->id . " status=" . $pp->subscription_status . " amount=" . $pp->amount);
        $rd = $pp->receipt_data ?? [];
        if (isset($rd['toku_invoice_ids'])) {
            info("  toku_invoice_ids: " . json_encode($rd['toku_invoice_ids']));
        }
        if (isset($rd['toku_subscription_ids'])) {
            info("  toku_subscription_ids: " . json_encode($rd['toku_subscription_ids']));
        }
    }

    // Webhooks recientes que pudieron afectarlo
    echo "\n" . bold("WebhookLogs últimos 24h:") . "\n";
    $logs = WebhookLog::where('event_type', 'LIKE', 'transaction.%')
        ->where('created_at', '>', now()->subDay())
        ->orderBy('id', 'desc')
        ->limit(20)
        ->get();
    $ourIds = $d->toku_debt_ids ?? [];
    $found = 0;
    foreach ($logs as $log) {
        $payload = json_decode($log->payload, true);
        foreach (($payload['payment_intents'] ?? []) as $i) {
            if (in_array($i['id_invoice'] ?? '', $ourIds)) {
                info("log=" . $log->id . " event=" . $log->event_type . " status=" . $log->status_code . " trs=" . $log->external_id);
                $found++;
            }
        }
    }
    if ($found === 0) {
        info("(ningún webhook reciente toca este detail)");
    }
}

// ─────────────────────────────────────────────
// cancel — cancelar un PresencialPayment pending
// ─────────────────────────────────────────────
function cancel($paymentId) {
    if (!$paymentId) { fail("Uso: cancel <presencial_payment_id>"); return; }
    section("CANCEL presencial_payment $paymentId");

    try {
        $svc = app(PresencialService::class);
        $result = $svc->cancelTokuPayment((int)$paymentId);
        ok("Cancelado");
        info("Invoices voided: " . ($result['voided_invoices'] ?? 0));
    } catch (\Throwable $e) {
        fail("Error: " . $e->getMessage());
    }
}

// ─────────────────────────────────────────────
// help
// ─────────────────────────────────────────────
function showHelp() {
    echo bold("\nTOKU SMOKE TEST\n");
    echo "Validación end-to-end del refactor de external_ids.\n\n";
    echo bold("COMANDOS:\n");
    echo "  " . cyan("preflight") . "                              Verifica deploy\n";
    echo "  " . cyan("list-candidates") . "                        Lista details aptos para test\n";
    echo "  " . cyan("emit-onetime <contract_id> <detail_id>") . "  Emite invoice one-time\n";
    echo "  " . cyan("emit-pac <contract_id> <details_csv>") . "    Emite suscripción PAC/PAT\n";
    echo "  " . cyan("monitor <detail_id>") . "                    Espera webhook + valida\n";
    echo "  " . cyan("inspect <detail_id>") . "                    Estado actual del detail\n";
    echo "  " . cyan("cancel <payment_id>") . "                    Cancela un PresencialPayment\n\n";
    echo bold("FLUJO TÍPICO:\n");
    echo "  1) preflight\n";
    echo "  2) list-candidates\n";
    echo "  3) emit-onetime 144 23913\n";
    echo "  4) (pagar el link en el navegador)\n";
    echo "  5) monitor 23913\n";
    echo "  6) inspect 23913\n\n";
    echo bold("ROLLBACK rápido:\n");
    echo "  cancel <payment_id>     ← anula invoice en Toku + limpia detail\n\n";
}