File: /var/www/api_matriculas/app/Services/WebhookTokuService.php
<?php
namespace App\Services;
use App\Models\ContractDetail;
use App\Models\PresencialPayment;
use App\Models\WebhookLog;
use App\Repositories\ContractRepository;
use App\Repositories\ParentRepository;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookTokuService
{
public function process(array $payload, array $headers, object $webhookLog): array
{
$event = data_get($payload, 'event_type');
Log::info("[WebhookToku] Webhook recibido", ['event' => $event]);
$webhookLog->event_type = $event;
switch ($event) {
case 'customer.created':
case 'customer.updated':
try {
// 🔹 Determinar tipo de acción
$action = $event === 'customer.created' ? 'CREAR' : 'ACTUALIZAR';
$webhookLog->event_code = $action . '_APODERADO';
$webhookLog->integration_type = 'PARENTS';
// 🔹 Obtener datos del cliente desde Toku
$customerToku = data_get($payload, 'customer');
$rutToku = data_get($customerToku, 'external_id') ?? data_get($customerToku, 'government_id');
$webhookLog->external_id = data_get($customerToku, 'id');
Log::info("Procesando evento {$event}", ['rut' => $rutToku ?? null, 'payload' => $customerToku]);
if (!empty($rutToku)) {
$rutToku = formatterRut($rutToku);
$validateRut = validateRut($rutToku);
if ($validateRut) {
try {
$repositoryParent = new ParentRepository();
$parentData = $repositoryParent->getByRut($rutToku);
if ($parentData) {
$parentData->code_toku = data_get($customerToku, 'id');
$parentData->updated_at = now();
$parentData->save();
$webhookLog->status_code = 'SUCCESS';
$webhookLog->reason = 'Apoderado actualizado correctamente';
$webhookLog->internal_id = $parentData->id;
} else {
$webhookLog->status_code = 'FAILED';
$webhookLog->reason = 'Apoderado no existe en Matrículas';
}
} catch (\Exception $ex) {
$webhookLog->status_code = 'FAILED';
$webhookLog->reason = 'Error al buscar o actualizar apoderado: ' . $ex->getMessage();
}
} else {
$webhookLog->status_code = 'FAILED';
$webhookLog->reason = 'RUT de apoderado inválido';
}
} else {
$webhookLog->status_code = 'FAILED';
$webhookLog->reason = 'No se recibió RUT en el payload';
}
Log::info("Finalizando evento {$event}", ['status_code' => $webhookLog->status_code, 'reason' => $webhookLog->reason]);
Log::info('Antes de guardar webhookLog', [
'id' => $webhookLog->id ?? null,
'external_id' => $webhookLog->external_id ?? null,
'internal_id' => $webhookLog->internal_id ?? null,
]);
$webhookLog->save(); // ✅ Guarda los valores reales
Log::info("WebhookLog guardado correctamente", [
'id' => $webhookLog->id,
'external_id' => $webhookLog->external_id,
'internal_id' => $webhookLog->internal_id,
]);
} catch (\Throwable $th) {
$webhookLog->status_code = 'ERROR';
$webhookLog->reason = 'Error al procesar transaction.success: ' . $th->getMessage();
Log::error('Error en transaction.success', [
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString()
]);
}
break;
case 'transaction.success':
$webhookLog->event_code = 'PAGO_REALIZADO';
$webhookLog->integration_type = 'CONTRACTS';
// Idempotency: skip if this transaction was already processed
$transactionId = data_get($payload, 'transaction.id');
$webhookLog->external_id = $transactionId;
if ($transactionId) {
$alreadyProcessed = WebhookLog::where('external_id', $transactionId)
->where('event_type', 'transaction.success')
->where('status_code', 'SUCCESS')
->exists();
if ($alreadyProcessed) {
$webhookLog->status_code = 'SKIPPED';
$webhookLog->reason = 'Webhook duplicado - transacción ya procesada';
$webhookLog->save();
Log::info('[WebhookToku] Webhook duplicado ignorado', ['transaction_id' => $transactionId]);
return ['handled' => true];
}
}
$reason = [];
try {
$transaction = data_get($payload, 'transaction');
$customer = data_get($payload, 'customer');
$paymentIntents = data_get($payload, 'payment_intents', []);
Log::info('[WebhookToku] transaction.success recibido', [
'transaction_id' => $transactionId,
'customer_id' => data_get($customer, 'id'),
'customer_rut' => data_get($customer, 'external_id') ?? data_get($customer, 'government_id'),
'total_intents' => count($paymentIntents),
'amount' => data_get($transaction, 'amount'),
'currency' => data_get($transaction, 'currency'),
]);
// 1️⃣ Validar RUT del cliente
$rutToku = data_get($customer, 'external_id') ?? data_get($customer, 'government_id');
if (!empty($rutToku)) {
$rutToku = formatterRut($rutToku);
$validateRut = validateRut($rutToku);
if ($validateRut) {
$repositoryParent = new ParentRepository();
$parentData = $repositoryParent->getByRut($rutToku);
if ($parentData) {
// Guardamos code_toku si no lo tiene
if (empty($parentData->code_toku)) {
$parentData->code_toku = data_get($customer, 'id');
$parentData->updated_at = now();
$parentData->save();
Log::info('[WebhookToku] code_toku asignado a apoderado', [
'parent_id' => $parentData->id,
'code_toku' => data_get($customer, 'id'),
]);
}
} else {
$reason[] = "Apoderado no existe en Matrículas para este pago ($rutToku)";
Log::warning('[WebhookToku] Apoderado no encontrado', ['rut' => $rutToku]);
}
} else {
$reason[] = "RUT inválido ($rutToku)";
Log::warning('[WebhookToku] RUT inválido', ['rut' => $rutToku]);
}
}
// 2️⃣ Procesar cada payment_intent
$repositoryContract = new ContractRepository();
foreach ($paymentIntents as $intent) {
$productCode = data_get($intent, 'product_id');
$invoiceExternalId = data_get($intent, 'invoice_external_id');
$statusIntent = strtoupper(data_get($intent, 'status'));
$amount = data_get($intent, 'amount');
$invoiceId = data_get($intent, 'id_invoice');
Log::info('[WebhookToku] Procesando payment_intent', [
'product_id' => $productCode,
'invoice_external_id' => $invoiceExternalId,
'id_invoice' => $invoiceId,
'status' => $statusIntent,
'amount' => $amount,
]);
// 3️⃣ Buscar el detalle de contrato:
// - Sistema viejo: code_toku = product_id (1 subscription por concepto)
// - Sistema nuevo: code_toku = invoice_external_id (1 subscription por alumno)
$contractDetail = null;
if (!empty($productCode)) {
$contractDetail = $repositoryContract->getDetailByCode($productCode);
}
if (!$contractDetail && !empty($invoiceExternalId)) {
$contractDetail = $repositoryContract->getDetailByCode($invoiceExternalId);
}
if (!$contractDetail) {
$reason[] = "No se encontró detalle para product_id=$productCode / invoice_external_id=$invoiceExternalId";
Log::warning('[WebhookToku] ContractDetail no encontrado', [
'product_id' => $productCode,
'invoice_external_id' => $invoiceExternalId,
]);
continue;
}
// 4️⃣ Marcar pago si el intent fue autorizado
if ($statusIntent === 'AUTHORIZED') {
$contractDetail->paid = true;
$contractDetail->paid_at = now();
$contractDetail->toku_payment_id = $transactionId;
$contractDetail->updated_at = now();
$contractDetail->save();
Log::info('[WebhookToku] Detalle marcado como pagado', [
'detail_id' => $contractDetail->id,
'contract_id' => $contractDetail->contract_id,
'toku_payment_id' => $transactionId,
'invoice_external_id' => $invoiceExternalId,
]);
}
// 5️⃣ Validar si quedan pendientes en el contrato
$contractId = $contractDetail->contract_id;
$webhookLog->internal_id = $contractId;
$hasPending = $repositoryContract->validateDetails($contractId) > 0;
// Obtener el contrato y actualizar sus estados
$contract = $repositoryContract->getById($contractId);
if ($contract) {
// No regresar estado si el contrato ya está finalizado o cancelado
$contractCode = $contract->statusContract->code ?? '';
$isAlreadyTerminal = in_array($contractCode, ['finished', 'canceled']);
if (!$isAlreadyTerminal) {
if ($hasPending) {
$contract->status_payment_id = $repositoryContract->getStatus('partial', 'payment');
} else {
$contract->status_payment_id = $repositoryContract->getStatus('completed', 'payment');
$contract->paid_at = now();
// PG → finalizar directamente (firma no requerida)
if ($contract->enrollment_type === 'playgroup') {
$contract->status_contract_id = $repositoryContract->getStatus('finished', 'contract');
$contract->finished_at = now();
} else {
$contract->status_contract_id = $repositoryContract->getStatus('pending_signature', 'contract');
}
}
$contract->updated_at = now();
$contract->save();
}
Log::info('[WebhookToku] Contrato actualizado tras pago', [
'contract_id' => $contractId,
'contract_code' => $contract->code_contract,
'status_payment' => $hasPending ? 'partial' : 'completed',
'skipped_status_update' => $isAlreadyTerminal,
]);
} else {
$reason[] = "Contrato no encontrado para ID $contractId";
}
}
// Extract payment instrument info from webhook
$paymentInstrument = data_get($payload, 'payment_instrument');
$tokuPaymentInfo = null;
if ($paymentInstrument) {
$card = data_get($paymentInstrument, 'card');
$tokuPaymentInfo = [
'type' => data_get($paymentInstrument, 'type'),
'card_brand' => $card ? data_get($card, 'card_brand') : null,
'card_type' => $card ? data_get($card, 'card_type') : null,
'last_digits' => $card ? data_get($card, 'last_digits') : null,
'card_holder' => $card ? data_get($card, 'card_holder') : null,
'issuer' => $card ? data_get($card, 'issuer') : null,
'transaction_id' => data_get($transaction, 'id'),
'authorization_code' => data_get($transaction, 'transaction_metadata.external_authorization_code'),
];
}
// Update PresencialPayment matching invoice IDs from webhook
$webhookInvoiceIds = collect($paymentIntents)->pluck('id_invoice')->filter()->values()->toArray();
Log::info('[WebhookToku] Buscando PresencialPayment por invoice IDs', [
'webhook_invoice_ids' => $webhookInvoiceIds,
]);
$matchedPresencialPayment = false;
if (!empty($webhookInvoiceIds)) {
$pendingPayments = PresencialPayment::where('subscription_status', 'pending_toku')->get();
Log::info('[WebhookToku] PresencialPayments pendientes encontrados', [
'total_pendientes' => $pendingPayments->count(),
]);
foreach ($pendingPayments as $pp) {
$storedInvoiceIds = $pp->receipt_data['toku_invoice_ids'] ?? [];
$match = !empty(array_intersect($webhookInvoiceIds, $storedInvoiceIds));
if ($match) {
$pp->subscription_status = 'paid';
// Store payment instrument info in receipt_data
if ($tokuPaymentInfo) {
$receiptData = $pp->receipt_data;
$receiptData['toku_payment_info'] = $tokuPaymentInfo;
$pp->receipt_data = $receiptData;
}
$pp->save();
$matchedPresencialPayment = true;
Log::info('[WebhookToku] PresencialPayment marcado como pagado', [
'payment_id' => $pp->id,
'contract_id' => $pp->contract_id,
'matched_invoices' => array_intersect($webhookInvoiceIds, $storedInvoiceIds),
]);
break;
}
}
}
if (!$matchedPresencialPayment && !empty($webhookInvoiceIds)) {
Log::warning('[WebhookToku] No se encontró PresencialPayment para invoice IDs del webhook', [
'webhook_invoice_ids' => $webhookInvoiceIds,
]);
}
$webhookLog->status_code = 'SUCCESS';
$webhookLog->reason = empty($reason)
? 'Pago procesado correctamente'
: implode(' | ', $reason);
Log::info('[WebhookToku] transaction.success procesado', [
'transaction_id' => $transactionId,
'status' => 'SUCCESS',
'presencial_payment_matched' => $matchedPresencialPayment,
'warnings' => $reason,
]);
} catch (\Throwable $th) {
$webhookLog->status_code = 'ERROR';
$webhookLog->reason = 'Error al procesar transaction.success: ' . $th->getMessage();
Log::error('[WebhookToku] Error en transaction.success', [
'transaction_id' => $transactionId ?? null,
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
}
$webhookLog->save();
break;
case 'transaction.failed':
$webhookLog->event_code = 'PAGO_FALLIDO';
$webhookLog->integration_type = 'CONTRACTS';
$transactionId = data_get($payload, 'transaction.id');
$webhookLog->external_id = $transactionId;
$failureCode = data_get($payload, 'transaction.transaction_metadata.code', 'unknown');
$failureMessage = data_get($payload, 'transaction.transaction_metadata.message', 'Sin detalle');
Log::warning('[WebhookToku] transaction.failed recibido', [
'transaction_id' => $transactionId,
'failure_code' => $failureCode,
'failure_message' => $failureMessage,
]);
try {
$paymentIntents = data_get($payload, 'payment_intents', []);
$repositoryContract = new ContractRepository();
$failedDetails = [];
foreach ($paymentIntents as $intent) {
$productCode = data_get($intent, 'product_id');
$invoiceExternalId = data_get($intent, 'invoice_external_id');
$invoiceId = data_get($intent, 'id_invoice');
// Buscar detail: sistema viejo (product_id) → sistema nuevo (invoice_external_id)
$contractDetail = null;
if (!empty($productCode)) {
$contractDetail = $repositoryContract->getDetailByCode($productCode);
}
if (!$contractDetail && !empty($invoiceExternalId)) {
$contractDetail = $repositoryContract->getDetailByCode($invoiceExternalId);
}
if ($contractDetail) {
$failedDetails[] = [
'detail_id' => $contractDetail->id,
'contract_id' => $contractDetail->contract_id,
'invoice_external_id' => $invoiceExternalId,
];
$webhookLog->internal_id = $contractDetail->contract_id;
} else {
Log::warning('[WebhookToku] Detail no encontrado para intent fallido', [
'product_id' => $productCode,
'invoice_external_id' => $invoiceExternalId,
]);
}
}
// Actualizar PresencialPayment si hay match por invoice IDs
$webhookInvoiceIds = collect($paymentIntents)->pluck('id_invoice')->filter()->values()->toArray();
if (!empty($webhookInvoiceIds)) {
$pendingPayments = PresencialPayment::where('subscription_status', 'pending_toku')->get();
foreach ($pendingPayments as $pp) {
$storedInvoiceIds = $pp->receipt_data['toku_invoice_ids'] ?? [];
if (!empty(array_intersect($webhookInvoiceIds, $storedInvoiceIds))) {
$receiptData = $pp->receipt_data;
$receiptData['last_failure'] = [
'transaction_id' => $transactionId,
'code' => $failureCode,
'message' => $failureMessage,
'timestamp' => now()->toIso8601String(),
];
$pp->receipt_data = $receiptData;
$pp->save();
Log::info('[WebhookToku] Intento fallido registrado en PresencialPayment', [
'payment_id' => $pp->id,
'failure_code' => $failureCode,
]);
break;
}
}
}
$webhookLog->status_code = 'SUCCESS';
$webhookLog->reason = "Pago fallido registrado: {$failureCode} - {$failureMessage}. " .
count($failedDetails) . ' detail(s) identificados';
Log::warning('[WebhookToku] transaction.failed procesado', [
'transaction_id' => $transactionId,
'failed_details' => $failedDetails,
'failure_code' => $failureCode,
]);
} catch (\Throwable $th) {
$webhookLog->status_code = 'ERROR';
$webhookLog->reason = 'Error al procesar transaction.failed: ' . $th->getMessage();
Log::error('[WebhookToku] Error en transaction.failed', [
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
}
$webhookLog->save();
break;
case 'subscription.created':
case 'subscription.updated':
$webhookLog->event_code = $event == 'subscription.created' ? 'CREAR' : 'ACTUALIZAR' . '_ESTUDIANTE';
Log::info("Procesando subscription.*", data_get($payload, 'subscription'));
break;
case 'invoice.created':
case 'invoice.updated':
$webhookLog->event_code = $event == 'invoice.created' ? 'CREAR' : 'ACTUALIZAR' . '_DEUDA';
Log::info("Procesando invoice.*", data_get($payload, 'invoice'));
break;
case 'payment_method.attached_products':
$webhookLog->event_code = 'SUSCRIPCION_ACTIVADA';
$webhookLog->integration_type = 'CONTRACTS';
try {
$subscriptionIds = data_get($payload, 'subscription_ids', []);
Log::info('[WebhookToku] payment_method.attached_products recibido', [
'subscription_ids' => $subscriptionIds,
]);
if (empty($subscriptionIds)) {
$webhookLog->status_code = 'FAILED';
$webhookLog->reason = 'No se recibieron subscription_ids';
$webhookLog->save();
break;
}
// Find PresencialPayment with matching subscription IDs
// Soporta sistema viejo (toku_subscription_ids array) y nuevo (toku_subscription_id singular)
$pendingPayments = PresencialPayment::where('subscription_status', 'pending_subscription')->get();
$matchedPayment = null;
foreach ($pendingPayments as $pp) {
$storedSubIds = $pp->receipt_data['toku_subscription_ids'] ?? [];
// Fallback: sistema nuevo guarda toku_subscription_id (singular)
if (empty($storedSubIds) && !empty($pp->receipt_data['toku_subscription_id'])) {
$storedSubIds = [$pp->receipt_data['toku_subscription_id']];
}
if (!empty(array_intersect($subscriptionIds, $storedSubIds))) {
$matchedPayment = $pp;
break;
}
}
if (!$matchedPayment) {
$webhookLog->status_code = 'FAILED';
$webhookLog->reason = 'No se encontró pago con subscription_ids: ' . implode(', ', $subscriptionIds);
Log::warning('[WebhookToku] No se encontró PresencialPayment para subscription_ids', [
'subscription_ids' => $subscriptionIds,
]);
$webhookLog->save();
break;
}
$contractId = $matchedPayment->contract_id;
$webhookLog->internal_id = $contractId;
$webhookLog->external_id = $subscriptionIds[0] ?? null;
// Update subscription status to active
$matchedPayment->subscription_status = 'active';
$matchedPayment->save();
Log::info('[WebhookToku] Suscripción PAC activada', [
'payment_id' => $matchedPayment->id,
'contract_id' => $contractId,
]);
// Mark contract details as paid
$detailIds = $matchedPayment->receipt_data['detail_ids'] ?? [];
if (!empty($detailIds)) {
ContractDetail::whereIn('id', $detailIds)
->where('paid', false)
->update([
'paid' => true,
'paid_at' => now(),
'updated_at' => now(),
]);
Log::info('[WebhookToku] Detalles marcados como pagados por PAC', ['detail_ids' => $detailIds]);
}
// Update contract payment status
$repositoryContract = new ContractRepository();
$hasPending = $repositoryContract->validateDetails($contractId) > 0;
$contract = $repositoryContract->getById($contractId);
if ($contract) {
// No regresar estado si el contrato ya está finalizado o cancelado
$contractCode = $contract->statusContract->code ?? '';
$isAlreadyTerminal = in_array($contractCode, ['finished', 'canceled']);
if (!$isAlreadyTerminal) {
if ($hasPending) {
$contract->status_payment_id = $repositoryContract->getStatus('partial', 'payment');
} else {
$contract->status_payment_id = $repositoryContract->getStatus('completed', 'payment');
$contract->paid_at = now();
if ($contract->enrollment_type === 'playgroup') {
$contract->status_contract_id = $repositoryContract->getStatus('finished', 'contract');
$contract->finished_at = now();
} else {
$contract->status_contract_id = $repositoryContract->getStatus('pending_signature', 'contract');
}
}
$contract->updated_at = now();
$contract->save();
}
Log::info('[WebhookToku] Contrato actualizado tras activación PAC', [
'contract_id' => $contractId,
'contract_code' => $contract->code_contract,
'status_payment' => $hasPending ? 'partial' : 'completed',
'skipped_status_update' => $isAlreadyTerminal,
]);
}
$webhookLog->status_code = 'SUCCESS';
$webhookLog->reason = 'Suscripción PAC activada correctamente';
} catch (\Throwable $th) {
$webhookLog->status_code = 'ERROR';
$webhookLog->reason = 'Error al procesar payment_method.attached_products: ' . $th->getMessage();
Log::error('Error en payment_method.attached_products', [
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
}
$webhookLog->save();
break;
default:
Log::warning("Evento Toku no manejado", ['event' => $event]);
return ['handled' => false];
}
return ['handled' => true];
}
}