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/app/Services/DebtCalculationService.php
<?php

namespace App\Services;

use App\Models\Contract;
use App\Models\ContractDetail;
use App\Models\Course;
use App\Models\EnrollmentPeriod;
use App\Models\PaymentConceptDetail;
use App\Models\PeriodPricingConfig;
use App\Models\PresencialPayment;
use App\Models\StatusContract;
use App\Models\Student;
use App\Models\StudentPeriod;
use App\Repositories\PaymentConceptDetailRepository;
use Illuminate\Support\Facades\Log;
use Exception;

class DebtCalculationService
{
    protected $paymentConceptDetailRepository;

    public function __construct()
    {
        $this->paymentConceptDetailRepository = new PaymentConceptDetailRepository();
    }

    /**
     * Calcula y crea las deudas de un estudiante para un contrato
     *
     * @param int $studentId
     * @param int $periodId
     * @param int $contractId
     * @return array Desglose de deudas creadas
     */
    public function calculateStudentDebts($studentId, $periodId, $contractId, $siblingOrderOverride = null, $paysIncorporationFee = true, $courseIdOverride = null)
    {
        $student = Student::with('course')->find($studentId);
        if (!$student) {
            throw new Exception("Estudiante ID {$studentId} no encontrado");
        }

        $contract = Contract::find($contractId);
        if (!$contract) {
            throw new Exception("Contrato ID {$contractId} no encontrado");
        }

        // Si se pasa un curso override (ej: desde recalculateContractDebts), usarlo
        if ($courseIdOverride) {
            $student->course_id = $courseIdOverride;
        }

        $courseId = $student->course_id;
        $parentId = $student->financial_parent_id;

        // Determinar orden de hermano (family=0 siempre obtiene precio base)
        if ($siblingOrderOverride !== null) {
            $siblingOrder = $siblingOrderOverride;
        } else {
            $pivotOverride = $this->getSiblingOrderOverride($studentId, $periodId);
            if ($pivotOverride !== null) {
                $siblingOrder = $pivotOverride;
            } else {
                $siblingOrder = $student->family ? $this->getSiblingOrder($parentId, $periodId, $studentId) : 1;
            }
        }

        // Obtener configuración de precios del período
        $pricingConfigs = PeriodPricingConfig::where('period_id', $periodId)
            ->where('status', true)
            ->get();

        $debtsCreated = [];

        if ($pricingConfigs->isEmpty()) {
            throw new Exception("No existe configuración de precios para el período seleccionado. Configure los valores antes de crear contratos.");
        }

        $activePriceType = $this->getActivePriceType($periodId);

        $debtsCreated = $this->calculateFromPricingConfig($pricingConfigs, $student, $contract, $periodId, $siblingOrder, $contract->enrollment_type, $paysIncorporationFee, $activePriceType);

        // Update contract total
        $this->updateContractTotal($contractId);

        return $debtsCreated;
    }

    /**
     * Calcular deudas desde la configuración de precios del período
     */
    private function calculateFromPricingConfig($pricingConfigs, $student, $contract, $periodId, $siblingOrder, $enrollmentType = null, $paysIncorporationFee = true, $activePriceType = 'regular')
    {
        $debtsCreated = [];
        // Limpiar RUT: quitar DV (último dígito después del guión), luego quitar puntos y guiones
        $parentRut = $student->financialParent->rut ?? '';
        $parentRut = str_replace('.', '', $parentRut); // Quitar puntos primero
        if (strpos($parentRut, '-') !== false) {
            $parentRut = substr($parentRut, 0, strrpos($parentRut, '-')); // Quitar DV
        }
        $parentRut = strtolower($parentRut);
        $periodYear = $contract->period->period_year ?? date('Y');

        // Limpiar RUT del estudiante
        $studentRut = $student->rut ?? '';
        $studentRut = str_replace('.', '', $studentRut);
        if (strpos($studentRut, '-') !== false) {
            $studentRut = substr($studentRut, 0, strrpos($studentRut, '-'));
        }
        $studentRut = strtolower($studentRut);

        // Limitar orden de hermano a 5 para búsqueda (5 = quinto en adelante)
        $effectiveSibling = min($siblingOrder, 5);

        foreach ($pricingConfigs as $config) {
            // Si paysIncorporationFee = false, no generar CUOTA_INCORPORACION
            if (!$paysIncorporationFee && $config->config_type === 'incorporation') {
                continue;
            }

            // Verificar si esta configuración aplica al curso del estudiante
            if ($config->course_id && $config->course_id != $student->course_id) {
                continue;
            }

            // Verificar filtro de orden de hermano
            if ($config->sibling_order !== null && $config->sibling_order != $effectiveSibling) {
                continue;
            }

            // Cuota de incorporación: split solo si (período futuro Y alumno Pre kínder).
            // Resto: cuota única legacy. Anti-degradación: si _1/_2 ya pagada, preservar split.
            if ($config->config_type === 'incorporation') {
                $amountRegular = $config->price_regular;
                $amountExceptional = $config->price_extended;
                $amountCash = $config->price_anticipated;
                $totalAmount = $this->pickPriceFromConfig($config, $activePriceType);

                if ($totalAmount <= 0 && (!$amountExceptional || $amountExceptional <= 0)) {
                    continue;
                }

                // Detectar pagos previos del estudiante (los unpaid ya fueron borrados en recalculate)
                $paidIncorporation = ContractDetail::where('contract_id', $contract->id)
                    ->where('student_id', $student->id)
                    ->where('paid', true)
                    ->whereHas('paymentConceptDetail', fn($q) =>
                        $q->where('code', 'LIKE', 'CUOTA_INCORPORACION%')
                    )
                    ->with('paymentConceptDetail')
                    ->get();

                $paidLegacy = $paidIncorporation->contains(fn($d) =>
                    optional($d->paymentConceptDetail)->code === 'CUOTA_INCORPORACION'
                );

                if ($paidLegacy) {
                    // Ya pagó la cuota completa legacy, no crear nada
                    continue;
                }

                // Regla: split solo si período futuro Y alumno Pre kínder.
                // Anti-degradación: si _1/_2 ya pagada, preservar split aunque cambien las condiciones.
                $prekinderCourseId = Course::where('code', 'PK')->value('id');
                $isPrekinder = $prekinderCourseId && (int) $student->course_id === (int) $prekinderCourseId;
                $isFuturePeriod = $periodYear > (int) date('Y');
                $hasSplitPaid = $paidIncorporation->isNotEmpty();
                $useSplit = $hasSplitPaid || ($isFuturePeriod && $isPrekinder);

                if ($useSplit) {
                    // Splitear cada tipo de precio en 2 (1ra cuota = mitad redondeada, 2da cuota = total - 1ra)
                    $splitAmount = function ($value) {
                        if (!$value) return [0, 0];
                        $half = round($value / 2, 4);
                        return [$half, round($value - $half, 4)];
                    };

                    [$amount1, $amount2] = $splitAmount($totalAmount);
                    [$regular1, $regular2] = $splitAmount($amountRegular);
                    [$exceptional1, $exceptional2] = $splitAmount($amountExceptional);
                    [$cash1, $cash2] = $splitAmount($amountCash);

                    $quotas = [
                        ['number' => 1, 'amount' => $amount1, 'regular' => $regular1, 'exceptional' => $exceptional1, 'cash' => $cash1],
                        ['number' => 2, 'amount' => $amount2, 'regular' => $regular2, 'exceptional' => $exceptional2, 'cash' => $cash2],
                    ];

                    foreach ($quotas as $quota) {
                        $concept = $this->resolveIncorporationQuota($config, $quota['number']);
                        if (!$concept) {
                            Log::warning("No se pudo resolver concepto CUOTA_INCORPORACION_{$quota['number']}, saltando.");
                            continue;
                        }

                        $detailExists = ContractDetail::where('contract_id', $contract->id)
                            ->where('student_id', $student->id)
                            ->where('payment_concept_detail_id', $concept->id)
                            ->exists();

                        if ($detailExists) {
                            continue;
                        }

                        $detail = ContractDetail::create(array_merge([
                            'contract_id'                => $contract->id,
                            'student_id'                 => $student->id,
                            'payment_concept_detail_id'  => $concept->id,
                            'amount'                     => $quota['amount'],
                            'amount_regular'             => $quota['regular'],
                            'amount_exceptional'         => $quota['exceptional'],
                            'amount_cash'                => $quota['cash'],
                            'paid'                       => false,
                            'code_toku'                  => $concept->code_toku . '_' . $periodYear . '_' . $studentRut,
                            'enrollment_period_id'       => $periodId,
                        ], ContractDetail::buildStudentSnapshot($student)));

                        $debtsCreated[] = [
                            'concept_code'  => "CUOTA_INCORPORACION_{$quota['number']}",
                            'config_type'   => $config->config_type,
                            'amount'        => $quota['amount'],
                            'currency'      => $config->currency_type,
                            'detail_id'     => $detail->id,
                            'is_family'     => false,
                        ];
                    }
                } else {
                    // Cuota única legacy (period actual o pasado, sin split previo)
                    $concept = $this->resolveOrCreateLegacyIncorporationConcept($config);
                    if (!$concept) {
                        Log::warning("No se pudo resolver concepto CUOTA_INCORPORACION (legacy), saltando.");
                        continue;
                    }

                    $detailExists = ContractDetail::where('contract_id', $contract->id)
                        ->where('student_id', $student->id)
                        ->where('payment_concept_detail_id', $concept->id)
                        ->exists();

                    if (!$detailExists) {
                        $detail = ContractDetail::create(array_merge([
                            'contract_id'                => $contract->id,
                            'student_id'                 => $student->id,
                            'payment_concept_detail_id'  => $concept->id,
                            'amount'                     => $totalAmount,
                            'amount_regular'             => $amountRegular,
                            'amount_exceptional'         => $amountExceptional,
                            'amount_cash'                => $amountCash,
                            'paid'                       => false,
                            'code_toku'                  => $concept->code_toku . '_' . $periodYear . '_' . $studentRut,
                            'enrollment_period_id'       => $periodId,
                        ], ContractDetail::buildStudentSnapshot($student)));

                        $debtsCreated[] = [
                            'concept_code'  => 'CUOTA_INCORPORACION',
                            'config_type'   => $config->config_type,
                            'amount'        => $totalAmount,
                            'currency'      => $config->currency_type,
                            'detail_id'     => $detail->id,
                            'is_family'     => false,
                        ];
                    }
                }

                continue;
            }

            // Para pagos familiares, verificar si ya existe en CUALQUIER contrato activo del apoderado+período
            // (excluir contratos cancelados)
            if ($config->is_family_payment) {
                $parentId = $student->financial_parent_id;
                $canceledId = StatusContract::where('code', 'canceled')->value('id');
                $exists = ContractDetail::whereHas('contract', function ($q) use ($parentId, $periodId, $canceledId) {
                    $q->where('financial_parent_id', $parentId)
                        ->where('period_id', $periodId);
                    if ($canceledId) {
                        $q->where('status_contract_id', '!=', $canceledId);
                    }
                })
                    ->whereHas('paymentConceptDetail', function ($q) use ($config) {
                        $q->where('code', $config->concept_code);
                    })
                    ->exists();

                if ($exists) {
                    continue;
                }
            }

            // Resolver concepto: buscar o auto-crear desde la configuración de precios
            $concept = $this->resolveOrCreateConcept($config);
            if (!$concept) {
                Log::warning("No se pudo resolver concepto '{$config->concept_code}', saltando.");
                continue;
            }

            $studentDetailExists = ContractDetail::where('contract_id', $contract->id)
                ->where('student_id', $student->id)
                ->where('payment_concept_detail_id', $concept->id)
                ->exists();

            if ($studentDetailExists) {
                continue;
            }

            // Usar precios de period_pricing_config (sobrescribe valores por defecto)
            $amountRegular = $config->price_regular;
            $amountExceptional = $config->price_extended;
            $amountCash = $config->price_anticipated;
            $amount = $this->pickPriceFromConfig($config, $activePriceType);

            // Saltar si no tiene monto
            if ($amount <= 0 && (!$amountExceptional || $amountExceptional <= 0)) {
                continue;
            }

            // Crear detalle de contrato con precios de la configuración
            $detail = ContractDetail::create(array_merge([
                'contract_id'                => $contract->id,
                'student_id'                 => $student->id,
                'payment_concept_detail_id'  => $concept->id,
                'amount'                     => $amount,
                'amount_regular'             => $amountRegular,
                'amount_exceptional'         => $amountExceptional,
                'amount_cash'                => $amountCash,
                'paid'                       => false,
                'code_toku'                  => $concept->code_toku . '_' . $periodYear . '_' . ($config->is_family_payment ? $parentRut : $studentRut),
                'enrollment_period_id'       => $periodId,
            ], ContractDetail::buildStudentSnapshot($student)));

            $debtsCreated[] = [
                'concept_code'  => $config->concept_code,
                'config_type'   => $config->config_type,
                'amount'        => $amount,
                'currency'      => $config->currency_type,
                'detail_id'     => $detail->id,
                'is_family'     => $config->is_family_payment,
            ];
        }

        return $debtsCreated;
    }

    /**
     * Recalcular todas las deudas de un contrato
     */
    public function recalculateContractDebts($contractId)
    {
        $contract = Contract::with('details.paymentConceptDetail')->find($contractId);
        if (!$contract) {
            throw new Exception("Contrato ID {$contractId} no encontrado");
        }

        // Obtener curso snapshot por estudiante desde detalles PAGADOS (son los originales intactos)
        $studentCourseSnapshot = [];
        foreach ($contract->details->where('paid', true) as $detail) {
            if ($detail->student_id && $detail->student_course_id) {
                $studentCourseSnapshot[$detail->student_id] = $detail->student_course_id;
            }
        }
        // Fallback: si un estudiante no tiene detalles pagados, usar cualquier detalle
        foreach ($contract->details as $detail) {
            if ($detail->student_id && $detail->student_course_id && !isset($studentCourseSnapshot[$detail->student_id])) {
                $studentCourseSnapshot[$detail->student_id] = $detail->student_course_id;
            }
        }

        $studentIds = $contract->details->pluck('student_id')->unique();

        // Detectar qué estudiantes tienen conceptos de incorporación ANTES de borrar
        $studentsWithIncorporation = $contract->details
            ->filter(fn($d) => $d->paymentConceptDetail && strpos($d->paymentConceptDetail->code ?? '', 'CUOTA_INCORPORACION') === 0)
            ->pluck('student_id')
            ->unique()
            ->toArray();

        $this->cancelPendingTokuPayments($contractId);

        // Eliminar solo los detalles no pagados
        ContractDetail::where('contract_id', $contractId)
            ->where('paid', false)
            ->delete();

        $allDebts = [];
        foreach ($studentIds as $studentId) {
            $courseIdOverride = $studentCourseSnapshot[$studentId] ?? null;
            $paysIncFee = in_array($studentId, $studentsWithIncorporation);
            $debts = $this->calculateStudentDebts($studentId, $contract->period_id, $contractId, null, $paysIncFee, $courseIdOverride);
            $allDebts[$studentId] = $debts;
        }

        $this->updateContractTotal($contractId);

        return $allDebts;
    }

    /**
     * Obtener override de sibling_order desde student_period (si existe).
     */
    private function getSiblingOrderOverride($studentId, $periodId)
    {
        $pivot = StudentPeriod::where('student_id', $studentId)
            ->where('period_id', $periodId)
            ->first();

        return $pivot && $pivot->sibling_order_override !== null
            ? (int) $pivot->sibling_order_override
            : null;
    }

    /**
     * Obtener el orden de hermano basado en courses.ordering DESC.
     * El alumno en el curso más alto (ordering mayor) obtiene sibling_order=1 (precio completo).
     * Solo cuenta hermanos (family=1) del mismo apoderado+período, excluye PG/JI y cancelados.
     */
    public function getSiblingOrder($parentId, $periodId, $studentId = null)
    {
        $canceledStatusId = StatusContract::where('code', 'canceled')->value('id');
        $playGroupCourseIds = Course::whereIn('code', ['PG', 'JI'])->pluck('id')->toArray();

        // Obtener IDs únicos de alumnos family=1 con contrato activo en el período
        $existingStudentIds = ContractDetail::whereHas('contract', function ($q) use ($parentId, $periodId, $canceledStatusId) {
            $q->where('financial_parent_id', $parentId)
                ->where('period_id', $periodId);
            if ($canceledStatusId) {
                $q->where('status_contract_id', '!=', $canceledStatusId);
            }
        })
            ->whereHas('student', function ($q) use ($playGroupCourseIds) {
                $q->where('family', 1);
                if (!empty($playGroupCourseIds)) {
                    $q->whereNotIn('course_id', $playGroupCourseIds);
                }
            })
            ->pluck('student_id')
            ->unique();

        // Incluir al alumno actual si no está ya en la lista
        if ($studentId && !$existingStudentIds->contains($studentId)) {
            $existingStudentIds->push($studentId);
        }

        // Ordenar por courses.ordering DESC (JOIN a nivel BD) → el curso más alto = sibling_order 1
        $students = Student::whereIn('students.id', $existingStudentIds)
            ->join('courses', 'students.course_id', '=', 'courses.id')
            ->orderByDesc('courses.ordering')
            ->select('students.*')
            ->get();

        // Retornar posición del alumno actual (1-based)
        if ($studentId) {
            $position = $students->search(fn($s) => $s->id == $studentId);
            return $position !== false ? $position + 1 : $students->count();
        }

        return $students->count() + 1;
    }

    /**
     * Recalcular las deudas de TODOS los hermanos de un apoderado en un período.
     * Solo aplica a contratos no-playgroup, sin pagos, y no cancelados.
     * Pre-computa los sibling orders ANTES de borrar detalles para evitar estado parcial.
     */
    public function recalculateFamilySiblingDebts($parentId, $periodId)
    {
        $canceledStatusId = StatusContract::where('code', 'canceled')->value('id');

        $contracts = Contract::where('financial_parent_id', $parentId)
            ->where('period_id', $periodId)
            ->where('enrollment_type', '!=', 'playgroup')
            ->when($canceledStatusId, fn($q) => $q->where('status_contract_id', '!=', $canceledStatusId))
            ->get();

        if ($contracts->isEmpty()) {
            return [];
        }

        // Pre-computar sibling orders con TODOS los alumnos de TODOS los contratos
        // ANTES de borrar ningún detalle
        $allStudentIds = ContractDetail::whereIn('contract_id', $contracts->pluck('id'))
            ->pluck('student_id')
            ->unique();

        // Si hay más de 1 hermano family=1 (excl. PG/JI), los hermanos reales están en el sistema
        // → nullear overrides en student_period para que se use el cálculo dinámico
        $playGroupCourseIds = Course::whereIn('code', ['PG', 'JI'])->pluck('id')->toArray();
        $familyCount = Student::whereIn('id', $allStudentIds)
            ->where('family', 1)
            ->when(!empty($playGroupCourseIds), fn($q) => $q->whereNotIn('course_id', $playGroupCourseIds))
            ->count();

        if ($familyCount > 1) {
            StudentPeriod::whereIn('student_id', $allStudentIds)
                ->where('period_id', $periodId)
                ->whereNotNull('sibling_order_override')
                ->update(['sibling_order_override' => null]);
        }

        $siblingOrders = $this->computeSiblingOrders($allStudentIds, $periodId);

        $recalculated = [];

        foreach ($contracts as $contract) {
            // Solo recalcular si no tiene ningún detalle pagado
            $hasPaidDetails = ContractDetail::where('contract_id', $contract->id)
                ->where('paid', true)
                ->exists();

            if ($hasPaidDetails) {
                continue;
            }

            $result = $this->recalculateContractDebtsWithOrders($contract->id, $siblingOrders);
            $recalculated[] = [
                'contract_id' => $contract->id,
                'contract_code' => $contract->code_contract,
                'debts' => $result,
            ];
        }

        return $recalculated;
    }

    /**
     * Pre-computar sibling orders para un conjunto de alumnos.
     * Ordena por course.ordering DESC: curso más alto = sibling_order 1.
     * Solo cuenta alumnos family=1 y excluye PG/JI.
     * Alumnos family=0 obtienen sibling_order 1 (precio base).
     */
    private function computeSiblingOrders($studentIds, $periodId = null)
    {
        $playGroupCourseIds = Course::whereIn('code', ['PG', 'JI'])->pluck('id')->toArray();

        // Obtener alumnos family=1 (no PG/JI), ordenados por courses.ordering DESC (JOIN a nivel BD)
        $familyStudents = Student::whereIn('students.id', $studentIds)
            ->where('students.family', 1)
            ->when(!empty($playGroupCourseIds), fn($q) => $q->whereNotIn('students.course_id', $playGroupCourseIds))
            ->join('courses', 'students.course_id', '=', 'courses.id')
            ->orderByDesc('courses.ordering')
            ->select('students.*')
            ->get();

        // Cargar overrides desde student_period si tenemos periodId
        $overrides = [];
        if ($periodId) {
            $overrides = StudentPeriod::whereIn('student_id', $familyStudents->pluck('id'))
                ->where('period_id', $periodId)
                ->whereNotNull('sibling_order_override')
                ->pluck('sibling_order_override', 'student_id')
                ->toArray();
        }

        $orders = [];
        foreach ($familyStudents as $index => $student) {
            if (isset($overrides[$student->id])) {
                $orders[$student->id] = (int) $overrides[$student->id];
            } else {
                $orders[$student->id] = $index + 1;
            }
        }

        // Alumnos family=0 o PG/JI → sibling_order 1
        $allStudents = Student::whereIn('id', $studentIds)->get();
        foreach ($allStudents as $student) {
            if (!isset($orders[$student->id])) {
                $orders[$student->id] = 1;
            }
        }

        return $orders;
    }

    /**
     * Recalcular deudas de un contrato usando sibling orders pre-computados.
     */
    private function recalculateContractDebtsWithOrders($contractId, $precomputedOrders)
    {
        $contract = Contract::with('details.paymentConceptDetail')->find($contractId);
        if (!$contract) {
            throw new Exception("Contrato ID {$contractId} no encontrado");
        }

        // Obtener curso snapshot por estudiante desde detalles PAGADOS (son los originales intactos)
        $studentCourseSnapshot = [];
        foreach ($contract->details->where('paid', true) as $detail) {
            if ($detail->student_id && $detail->student_course_id) {
                $studentCourseSnapshot[$detail->student_id] = $detail->student_course_id;
            }
        }
        // Fallback: si un estudiante no tiene detalles pagados, usar cualquier detalle
        foreach ($contract->details as $detail) {
            if ($detail->student_id && $detail->student_course_id && !isset($studentCourseSnapshot[$detail->student_id])) {
                $studentCourseSnapshot[$detail->student_id] = $detail->student_course_id;
            }
        }

        $studentIds = $contract->details->pluck('student_id')->unique();

        // Detectar qué estudiantes tienen conceptos de incorporación ANTES de borrar
        // Si un estudiante no tiene ninguno, es porque no paga cuota de incorporación
        $studentsWithIncorporation = $contract->details
            ->filter(fn($d) => $d->paymentConceptDetail && strpos($d->paymentConceptDetail->code ?? '', 'CUOTA_INCORPORACION') === 0)
            ->pluck('student_id')
            ->unique()
            ->toArray();

        $this->cancelPendingTokuPayments($contractId);

        // Eliminar solo los detalles no pagados
        ContractDetail::where('contract_id', $contractId)
            ->where('paid', false)
            ->delete();

        $allDebts = [];
        foreach ($studentIds as $studentId) {
            $siblingOrder = $precomputedOrders[$studentId] ?? 1;
            $courseIdOverride = $studentCourseSnapshot[$studentId] ?? null;
            $paysIncFee = in_array($studentId, $studentsWithIncorporation);
            $debts = $this->calculateStudentDebts($studentId, $contract->period_id, $contractId, $siblingOrder, $paysIncFee, $courseIdOverride);
            $allDebts[$studentId] = $debts;
        }

        $this->updateContractTotal($contractId);

        return $allDebts;
    }

    /**
     * Cancelar pagos Toku en curso de un contrato antes de recalcular detalles.
     * Replica la misma lógica de PresencialService::cancelTokuPayment():
     * void invoices en Toku, limpiar toku_debt_ids/code_toku de details, marcar cancelled.
     * Debe llamarse ANTES de borrar contract_details.
     */
    private function cancelPendingTokuPayments($contractId)
    {
        $pendingPayments = PresencialPayment::where('contract_id', $contractId)
            ->whereIn('subscription_status', ['pending_toku', 'pending_subscription'])
            ->get();

        if ($pendingPayments->isEmpty()) {
            return;
        }

        Log::info("[RecalculateTokuCancel] Cancelando pagos Toku en curso para contrato {$contractId}", [
            'payment_ids' => $pendingPayments->pluck('id')->toArray(),
        ]);

        try {
            $tokuService = app(ApiTokuService::class);
        } catch (Exception $e) {
            Log::error("[RecalculateTokuCancel] No se pudo instanciar ApiTokuService: " . $e->getMessage());
            $tokuService = null;
        }

        foreach ($pendingPayments as $payment) {
            $receiptData = $payment->receipt_data ?? [];
            $tokuInvoiceIds = $receiptData['toku_invoice_ids'] ?? [];
            $detailIds = $receiptData['detail_ids'] ?? [];

            Log::info("[RecalculateTokuCancel] Procesando presencial_payment #{$payment->id}", [
                'status' => $payment->subscription_status,
                'invoices' => count($tokuInvoiceIds),
                'detail_ids' => $detailIds,
            ]);

            // 1. Void invoices (deudas) en Toku — misma lógica que cancelTokuPayment()
            if ($tokuService) {
                foreach ($tokuInvoiceIds as $invoiceId) {
                    if (!$invoiceId) continue;
                    try {
                        $tokuService->voidInvoice($invoiceId, true);
                        Log::info("[RecalculateTokuCancel] Invoice anulado: {$invoiceId}");
                    } catch (Exception $e) {
                        Log::warning("[RecalculateTokuCancel] Error anulando invoice {$invoiceId}: " . $e->getMessage());
                    }
                }
            }

            // 2. Limpiar toku_debt_ids y code_toku de los contract_details afectados
            if (!empty($detailIds)) {
                ContractDetail::whereIn('id', $detailIds)->update([
                    'toku_debt_ids' => null,
                    'code_toku'     => null,
                ]);
            }

            // 3. Marcar el presencial_payment como cancelado
            $payment->subscription_status = 'cancelled';
            $payment->subscription_link = null;
            $payment->save();

            Log::info("[RecalculateTokuCancel] Pago #{$payment->id} cancelado correctamente");
        }
    }

    /**
     * Actualizar el monto total del contrato basado en sus detalles
     */
    private function updateContractTotal($contractId)
    {
        $total = ContractDetail::where('contract_id', $contractId)
            ->sum('amount');

        Contract::where('id', $contractId)
            ->update(['total_amount' => $total]);
    }

    /**
     * Obtener vista previa de deudas de un estudiante (sin crear registros)
     * Usado por el wizard presencial para mostrar qué deudas se crearían
     */
    public function previewStudentDebts($studentId, $periodId)
    {
        $student = Student::with(['course', 'financialParent'])->find($studentId);
        if (!$student) {
            throw new Exception("Estudiante ID {$studentId} no encontrado");
        }

        $parentId = $student->financial_parent_id;

        // Si el estudiante no es familiar (family=0), usar sibling_order=1 (precio base, sin descuento)
        $pivotOverride = $this->getSiblingOrderOverride($studentId, $periodId);
        if ($pivotOverride !== null) {
            $siblingOrder = $pivotOverride;
        } else {
            $siblingOrder = $student->family ? $this->getSiblingOrder($parentId, $periodId, $studentId) : 1;
        }

        // Verificar configuración de precios del período
        $pricingConfigs = PeriodPricingConfig::where('period_id', $periodId)
            ->where('status', true)
            ->get();

        // Obtener códigos de conceptos familiares que ya existen en otro contrato del apoderado+período
        $existingFamilyCodes = $this->getExistingFamilyConcepts($parentId, $periodId);

        $preview = [];

        $effectiveSibling = min($siblingOrder, 5);

        if ($pricingConfigs->isEmpty()) {
            throw new Exception("No existe configuración de precios para el período seleccionado.");
        }

        $activePriceType = $this->getActivePriceType($periodId);

        $period = \App\Models\Period::find($periodId);
        $previewPeriodYear = (int) ($period->period_year ?? date('Y'));

        $prekinderCourseId = Course::where('code', 'PK')->value('id');
        $isPrekinder = $prekinderCourseId && (int) $student->course_id === (int) $prekinderCourseId;

        foreach ($pricingConfigs as $config) {
            if ($config->course_id && $config->course_id != $student->course_id) {
                continue;
            }
            if ($config->sibling_order !== null && $config->sibling_order != $effectiveSibling) {
                continue;
            }

            // Cuota de incorporación: split solo si período futuro Y alumno Pre kínder.
            if ($config->config_type === 'incorporation') {
                $amountRegular = $config->price_regular;
                $amountExceptional = $config->price_extended;
                $amountCash = $config->price_anticipated;
                $totalAmount = $this->pickPriceFromConfig($config, $activePriceType);

                if ($totalAmount <= 0 && (!$amountExceptional || $amountExceptional <= 0)) {
                    continue;
                }

                $useSplit = $previewPeriodYear > (int) date('Y') && $isPrekinder;

                if ($useSplit) {
                    $splitAmount = function ($value) {
                        if (!$value) return [0, 0];
                        $half = round($value / 2, 4);
                        return [$half, round($value - $half, 4)];
                    };

                    [$amount1, $amount2] = $splitAmount($totalAmount);
                    [$regular1, $regular2] = $splitAmount($amountRegular);
                    [$exceptional1, $exceptional2] = $splitAmount($amountExceptional);
                    [$cash1, $cash2] = $splitAmount($amountCash);

                    $quotas = [
                        ['number' => 1, 'amount' => $amount1, 'regular' => $regular1, 'exceptional' => $exceptional1, 'cash' => $cash1],
                        ['number' => 2, 'amount' => $amount2, 'regular' => $regular2, 'exceptional' => $exceptional2, 'cash' => $cash2],
                    ];

                    foreach ($quotas as $quota) {
                        $concept = $this->resolveIncorporationQuota($config, $quota['number']);

                        $preview[] = [
                            'concept_code'       => "CUOTA_INCORPORACION_{$quota['number']}",
                            'description'        => $concept ? $concept->description : "Cuota de Incorporacion {$quota['number']}",
                            'config_type'        => $config->config_type,
                            'amount'             => $quota['amount'],
                            'amount_regular'     => $quota['regular'],
                            'amount_exceptional' => $quota['exceptional'],
                            'amount_cash'        => $quota['cash'],
                            'currency'           => $config->currency_type,
                            'is_family'          => false,
                            'is_optional'        => $quota['number'] === 2,
                            'sibling_order'      => $siblingOrder,
                        ];
                    }
                } else {
                    // Cuota única legacy
                    $concept = $this->resolveOrCreateLegacyIncorporationConcept($config);

                    $preview[] = [
                        'concept_code'       => 'CUOTA_INCORPORACION',
                        'description'        => $concept ? $concept->description : 'Cuota de Incorporacion',
                        'config_type'        => $config->config_type,
                        'amount'             => $totalAmount,
                        'amount_regular'     => $amountRegular,
                        'amount_exceptional' => $amountExceptional,
                        'amount_cash'        => $amountCash,
                        'currency'           => $config->currency_type,
                        'is_family'          => false,
                        'is_optional'        => false,
                        'sibling_order'      => $siblingOrder,
                    ];
                }

                continue;
            }

            // Saltar conceptos familiares que ya existen en otro contrato del apoderado+período
            if ($config->is_family_payment && in_array($config->concept_code, $existingFamilyCodes)) {
                continue;
            }

            $concept = $this->resolveOrCreateConcept($config);
            $amount = $this->pickPriceFromConfig($config, $activePriceType);
            if ($amount <= 0 && (!$config->price_extended || $config->price_extended <= 0)) {
                continue;
            }

            $preview[] = [
                'concept_code'      => $config->concept_code,
                'description'       => $concept ? $concept->description : $config->concept_code,
                'config_type'       => $config->config_type,
                'amount'            => $amount,
                'amount_regular'    => $config->price_regular,
                'amount_exceptional' => $config->price_extended,
                'amount_cash'       => $config->price_anticipated,
                'currency'          => $config->currency_type,
                'is_family'         => $config->is_family_payment,
                'is_optional'       => $config->is_optional,
                'sibling_order'     => $siblingOrder,
            ];
        }

        return [
            'student' => [
                'id'         => $student->id,
                'rut'        => $student->rut,
                'name'       => $student->first_name . ' ' . $student->last_name,
                'course'     => $student->course ? $student->course->course : null,
                'course_id'  => $student->course_id,
            ],
            'sibling_order' => $siblingOrder,
            'debts'         => $preview,
        ];
    }

    /**
     * Resolver concepto de pago: buscar en payment_concept_details o auto-crear desde la config de precios.
     * period_pricing_configs es la fuente de verdad; payment_concept_details es solo el catálogo de referencia.
     */
    private function resolveOrCreateConcept(PeriodPricingConfig $config)
    {
        $concept = $this->paymentConceptDetailRepository->getByCode($config->concept_code);
        if ($concept) {
            return $concept;
        }

        // Mapeo config_type → payment_concept_id
        $conceptIdMap = [
            'incorporation'  => 1, // MATRICULA
            'tuition'        => 2, // COLEGIATURA
            'enrollment_fee' => 1, // MATRICULA
            'third_party'    => 3, // SEGUROS Y OTROS
        ];

        // Mapeo currency_type → currency_type_id
        $currencyIdMap = [
            'CLP' => 1,
            'UF'  => 2,
        ];

        // Mapeo config_type → code_toku base
        $codeTokuMap = [
            'incorporation'  => 'cuota-de-incorporacion',
            'tuition'        => 'colegiatura-anual',
            'enrollment_fee' => 'matricula',
        ];

        $paymentConceptId = $conceptIdMap[$config->config_type] ?? 3;
        $currencyTypeId = $currencyIdMap[$config->currency_type] ?? 1;
        $codeToku = $codeTokuMap[$config->config_type]
            ?? strtolower(str_replace('_', '-', $config->concept_code));

        // Descripción legible desde concept_code: ENROLLMENT_PG → ENROLLMENT PG
        $description = str_replace('_', ' ', $config->concept_code);

        $concept = PaymentConceptDetail::create([
            'code'                => $config->concept_code,
            'description'         => $description,
            'code_toku'           => $codeToku,
            'payment_concept_id'  => $paymentConceptId,
            'currency_type_id'    => $currencyTypeId,
            'price_regular'       => $config->price_regular,
            'price_exceptional'   => $config->price_extended,
            'required_element'    => !$config->is_optional,
            'requires_payment'    => in_array($config->config_type, ['enrollment_fee', 'incorporation']),
            'requires_assignment' => false,
            'family_payment'      => $config->is_family_payment ?? false,
            'status'              => true,
            'created_at'          => now(),
        ]);

        Log::info("Auto-creado payment_concept_detail '{$config->concept_code}' desde period_pricing_config ID {$config->id}");

        return $concept;
    }

    /**
     * Resolver concepto de cuota de incorporación split (1 o 2).
     * Busca en catálogo o auto-crea si no existe.
     */
    private function resolveIncorporationQuota(PeriodPricingConfig $config, int $quotaNumber)
    {
        $code = "CUOTA_INCORPORACION_{$quotaNumber}";
        $concept = $this->paymentConceptDetailRepository->getByCode($code);
        if ($concept) {
            return $concept;
        }

        $currencyTypeId = $config->currency_type === 'UF' ? 2 : 1;

        $concept = PaymentConceptDetail::create([
            'code'                => $code,
            'description'         => $quotaNumber === 1 ? '1ra Cuota de Incorporacion' : '2da Cuota de Incorporacion',
            'code_toku'           => "cuota-de-incorporacion-{$quotaNumber}",
            'payment_concept_id'  => 1,
            'currency_type_id'    => $currencyTypeId,
            'price_regular'       => 0,
            'price_exceptional'   => 0,
            'required_element'    => $quotaNumber === 1,
            'requires_payment'    => $quotaNumber === 1,
            'requires_assignment' => false,
            'family_payment'      => false,
            'status'              => true,
        ]);

        Log::info("Auto-creado payment_concept_detail '{$code}' para split de incorporación");

        return $concept;
    }

    /**
     * Resolver concepto de cuota de incorporación legacy (cuota única).
     * Busca en catálogo o auto-crea si no existe.
     */
    private function resolveOrCreateLegacyIncorporationConcept(PeriodPricingConfig $config)
    {
        $concept = $this->paymentConceptDetailRepository->getByCode('CUOTA_INCORPORACION');
        if ($concept) {
            return $concept;
        }

        $currencyTypeId = $config->currency_type === 'UF' ? 2 : 1;

        $concept = PaymentConceptDetail::create([
            'code'                => 'CUOTA_INCORPORACION',
            'description'         => 'Cuota de Incorporacion',
            'code_toku'           => 'cuota-de-incorporacion',
            'payment_concept_id'  => 1,
            'currency_type_id'    => $currencyTypeId,
            'price_regular'       => 0,
            'price_exceptional'   => 0,
            'required_element'    => true,
            'requires_payment'    => true,
            'requires_assignment' => false,
            'family_payment'      => false,
            'status'              => true,
        ]);

        Log::info("Auto-creado payment_concept_detail 'CUOTA_INCORPORACION' (legacy)");

        return $concept;
    }

    /**
     * Obtener códigos de conceptos familiares que ya existen en algún contrato del apoderado+período
     */
    private function getExistingFamilyConcepts($parentId, $periodId)
    {
        $canceledStatusId = StatusContract::where('code', 'canceled')->value('id');

        return ContractDetail::whereHas('contract', function ($q) use ($parentId, $periodId, $canceledStatusId) {
            $q->where('financial_parent_id', $parentId)
                ->where('period_id', $periodId);
            if ($canceledStatusId) {
                $q->where('status_contract_id', '!=', $canceledStatusId);
            }
        })
            ->whereHas('paymentConceptDetail', function ($q) {
                $q->where('family_payment', true);
            })
            ->with('paymentConceptDetail')
            ->get()
            ->pluck('paymentConceptDetail.code')
            ->unique()
            ->toArray();
    }

    /**
     * Resolver tipo de precio activo según fecha actual y enrollment_periods.
     *
     * Mapeo:
     *   - "Matrícula Regular" → 'anticipated' (con descuento)
     *   - "Matrícula Excepcional" → 'extended' (con recargo)
     *   - "CAE" → 'regular'
     *   - Default (fuera de rango) → 'regular'
     *
     * @return string 'regular' | 'anticipated' | 'extended'
     */
    public function getActivePriceType($periodId, $today = null)
    {
        $today = $today ?? now()->toDateString();

        $activePeriods = EnrollmentPeriod::where('period_id', $periodId)
            ->where('status', 1)
            ->where('deleted', 0)
            ->whereDate('start_date', '<=', $today)
            ->whereDate('end_date', '>=', $today)
            ->get();

        foreach ($activePeriods as $p) {
            $desc = strtolower(strtr($p->description ?? '', [
                'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u',
                'Á' => 'a', 'É' => 'e', 'Í' => 'i', 'Ó' => 'o', 'Ú' => 'u',
            ]));

            if (str_contains($desc, 'cae') || str_contains($desc, 'condicion academica')) {
                return 'regular';
            }
            if (str_contains($desc, 'excepcional')) {
                return 'extended';
            }
            if (str_contains($desc, 'regular')) {
                return 'anticipated';
            }
        }

        return 'regular';
    }

    /**
     * Seleccionar precio del config según el tipo de precio activo, con fallback a regular.
     */
    private function pickPriceFromConfig($config, $priceType)
    {
        switch ($priceType) {
            case 'anticipated':
                $value = $config->price_anticipated;
                break;
            case 'extended':
                $value = $config->price_extended;
                break;
            case 'regular':
            default:
                $value = $config->price_regular;
                break;
        }

        if ($value === null || $value <= 0) {
            $value = $config->price_regular ?? $config->price_extended ?? 0;
        }

        return $value;
    }
}