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/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];
    }
}