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";
}