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/ContractService.php
<?php

namespace App\Services;

use App\Http\Resources\ContractListResource;
use App\Http\Resources\ContractResource;
use App\Models\Student;
use App\Models\ContractFormat;
use App\Repositories\ContractRepository;
use App\Repositories\ParentRepository;
use App\Repositories\PeriodRepository;
use App\Repositories\StudentRepository;
use App\Services\Signapis\SignapisService;
use Exception;
use App\Models\Contract;
use App\Models\ContractDetail;
use App\Models\Parents;
use App\Models\PaymentConceptDetail;
use App\Models\PeriodPricingConfig;
use Barryvdh\DomPDF\Facade\Pdf as FacadePdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

class ContractService
{
    protected $contractRepository;
    protected $debtCalculationService;

    public function __construct(ContractRepository $contractRepository, DebtCalculationService $debtCalculationService)
    {
        $this->contractRepository = $contractRepository;
        $this->debtCalculationService = $debtCalculationService;
    }

    public function list($periodId = null, $userCreated = null, $limit = null)
    {
        $registers = $this->contractRepository->getAll($periodId, $userCreated, $limit);
        if ($registers->count() < 1) {
            return [];
        }

        $periodRepository = new PeriodRepository();
        $period = $periodRepository->getActive();
        $yearPeriod = $period->period_year ?? "";

        $isParent = auth()->check() && auth()->user()->profile->code === 'parent';
        $today = Carbon::today();

        $contracts = $registers->map(function ($r) use ($isParent, $today) {
            $resource = (new ContractListResource($r))->resolve();
            $resource['accessible'] = true;

            if ($isParent && $r->statusContract->code !== 'finished') {
                $contractPeriod = $r->period;
                if (empty($contractPeriod) || !$contractPeriod->status) {
                    $resource['accessible'] = false;
                } elseif ($r->enrollment_type === 'regular') {
                    $hasActiveSubPeriod = false;
                    if (isset($contractPeriod->enrollmentPeriods)) {
                        foreach ($contractPeriod->enrollmentPeriods as $segment) {
                            if ($segment->status) {
                                $startDate = Carbon::parse($segment->start_date);
                                $endDate = Carbon::parse($segment->end_date);
                                if ($today->between($startDate, $endDate)) {
                                    $hasActiveSubPeriod = true;
                                    break;
                                }
                            }
                        }
                    }
                    if (!$hasActiveSubPeriod) {
                        $resource['accessible'] = false;
                    }
                }
            }

            return $resource;
        });

        if ($isParent) {
            $studentRepository = new StudentRepository();
            $students = $studentRepository->getAll();

            // CAE/CFE: alumnos con blocked_student. Prioridad sobre INACTIVE (un alumno
            // bloqueado se muestra solo en su grupo CAE/CFE, no además en inactivos).
            $blockedStudents = $students->whereNotNull('blocked_student')
                ->map(function ($student) {
                    return [
                        'id' => $student['id'],
                        'rut' => $student['rut'],
                        'full_name' => "{$student['first_name']} {$student['last_name']}",
                        'blocked_student' => $student['blocked_student'],
                    ];
                });

            // INACTIVE: alumnos con status=0 que NO están ya bloqueados por CAE/CFE.
            $inactiveStudents = $students
                ->where('status', 0)
                ->whereNull('blocked_student')
                ->map(function ($student) {
                    return [
                        'id' => $student['id'],
                        'rut' => $student['rut'],
                        'full_name' => "{$student['first_name']} {$student['last_name']}",
                        'blocked_student' => 'INACTIVE',
                    ];
                });

            $grouped = $blockedStudents->groupBy('blocked_student');
            if ($inactiveStudents->isNotEmpty()) {
                $grouped['INACTIVE'] = $inactiveStudents->values();
            }
            $blockedStudents = $grouped;

            $configuration = getConfiguration('general');
            $configAlert =  [
                'cae_title' => str_replace('#PERIOD_YEAR', $yearPeriod, $configuration['cae_title']),
                'cae_message' => $configuration['cae_message'],
                'cfe_title' => str_replace('#PERIOD_YEAR', $yearPeriod, $configuration['cfe_title']),
                'cfe_message' => $configuration['cfe_message'],
                'inactive_title' => str_replace('#PERIOD_YEAR', $yearPeriod, $configuration['inactive_title'] ?? 'Matrícula no disponible para #PERIOD_YEAR'),
                'inactive_message' => $configuration['inactive_message'] ?? 'Tu alumno se encuentra inactivo en el sistema. Para reactivarlo o resolver dudas, contacta a administración.',
            ];
        } else {
            $blockedStudents = [];
            $configAlert = [];
        }

        $data = [
            'contracts' => $contracts,
            'students' => $blockedStudents,
            'configuration' => $configAlert
        ];
        return $data;
    }

    public function store($request)
    {
        $this->validatePeriod();

        // Determinar tipo de firma según document_type del apoderado financiero
        if (!empty($request['financial_parent_id']) && empty($request['status_signature'])) {
            $parent = \App\Models\Parents::find($request['financial_parent_id']);
            if ($parent && ($parent->document_type ?? 'RUT') === 'PASSPORT') {
                $enrollmentType = $request['enrollment_type'] ?? 'regular';
                if ($enrollmentType !== 'playgroup') {
                    $request['status_signature'] = 'pending_manual';
                }
            }
        }

        $register = $this->contractRepository->create((object)$request);
        if (!$register) {
            throw new Exception("Ha ocurrido un error al crear el contrato", 500);
        }

        return null;
    }

    public function update($id, $request)
    {
        $this->validatePeriod();

        $register = $this->show($id, true, true);
        if (!$register) {
            throw new Exception("Contrato no existe o fue eliminado", 404);
        }

        $update = $this->contractRepository->update($register, (object)$request);
        if (!$update) {
            throw new Exception("Ha ocurrido un error al actualizar el contrato", 500);
        }

        return true;
    }

    public function show($id, $allData = true, $allDataEdit = false)
    {
        $register = $this->contractRepository->getById($id);

        if (!$register) {
            throw new Exception("Contrato no existe o fue eliminado", 404);
        }

        if ($allDataEdit) {
            return $register;
        }

        return (object)(new ContractResource($register, $allData))->resolve();
    }

    public function showByCode($code, $allData = true, $allDataEdit = false)
    {
        $register = $this->contractRepository->getByCode($code);

        if (!$register) {
            throw new Exception("Contrato no existe o fue eliminado", 404);
        }

        $blocked = false;
        if (auth()->check() && auth()->user()->profile->code === 'parent') {
            $studentRepository = new StudentRepository();
            $students = $studentRepository->getAll();
            $hasBlocked = $students->whereNotNull('blocked_student')->count() > 0;
            $hasInactive = $students->where('status', 0)->whereNull('blocked_student')->count() > 0;
            $blocked = $hasBlocked || $hasInactive;
        }
        if ($allDataEdit) {
            return $register;
        }
        if ($blocked) {
            throw new Exception("No se puede acceder al contrato debido a un bloqueo de alumnos", 404);
        }

        // Guardar datos del contrato antes de la transformación del resource
        $enrollmentType = $register->enrollment_type;
        $contractPeriod = $register->period;
        $statusContractCode = $register->statusContract->code;

        $register = (object)(new ContractResource($register, $allData))->resolve();
        $register->blocked = $blocked;

        if (auth()->check() && auth()->user()->profile->code === 'parent' && $statusContractCode !== 'finished') {
            if (empty($contractPeriod) || !$contractPeriod->status) {
                throw new Exception("No se puede acceder al contrato, debido a que el periodo de matrícula no se encuentra disponible.", 404);
            }

            // Para contratos regulares, validar subperiodos activos del período del contrato
            if ($enrollmentType === 'regular') {
                $periodActive = false;
                $today = Carbon::today();
                foreach ($contractPeriod->enrollmentPeriods as $segment) {
                    if ($segment->status) {
                        $startDate = Carbon::parse($segment->start_date);
                        $endDate = Carbon::parse($segment->end_date);
                        if ($today->between($startDate, $endDate)) {
                            $periodActive = true;
                            break;
                        }
                    }
                }
                if (!$periodActive) {
                    throw new Exception("No se puede acceder al contrato, debido a que el periodo de matrícula no se encuentra disponible.", 404);
                }
            }
        }
        return $register;
    }

    public function signature($code, $allData = true, $allDataEdit = false)
    {
        $register = $this->contractRepository->getByCode($code);

        if (!$register) {
            throw new Exception("Contrato no existe o fue eliminado", 404);
        }

        $send = $this->sendPDF($register);
        return true;
    }

    public function delete($id)
    {
        $register = $this->show($id, true, true);
        $register->deleted = 1;
        $register->deleted_at = now();
        $register->user_deleted = auth()->id();

        if (!$register->save()) {
            throw new Exception("Ha ocurrido un error al eliminar el contrato", 500);
        }

        return true;
    }

    // ----------------- PRIVATE ----------------- //

    private function validatePeriod()
    {
        $periodRepository = new PeriodRepository();
        $periodActive = $periodRepository->getActive();
        if (empty($periodActive)) {
            throw new Exception("Actualmente no hay períodos de matrícula disponibles para generar contratos.");
        }

        if ($periodActive->finished) {
            throw new Exception("No es posible editar el contrato porque el período de matrícula ha finalizado.");
        }
        return true;
    }

    public function getContractActive()
    {
        $periodRepository = new PeriodRepository();
        $periodActive = $periodRepository->getActive();
        if (empty($periodActive)) {
            return null;
        }
        $register = $this->contractRepository->getByPeriodId($periodActive->id);
        if (!$register) {
            return null;
        }
        return [
            'code' => $register->code_contract,
            'year' => $register->period->period_year,
            'status_contract' => $register->statusContract->code,
            'paid' => $register->statusPayment->code == 'completed',
            'paid_at' => !empty($register->paid_at) ? sortDateHourForHuman($register->paid_at) : sortDateHourForHuman($register->updated_at),
            'signed' => in_array($register->statusSignature->code, ['signed', 'manual_signed']),
            'total_amount' => $register->total_amount,
            'enrollment_type' => $register->enrollment_type,
        ];
    }


    public function sendPDF($register)
    {
        set_time_limit(300);
        if (is_numeric($register)) {
            $register = $this->contractRepository->getById($register);
        }
        if (!$register) {
            throw new Exception("El contrato no existe o fue eliminado", 404);
        }

        if ($register->statusPayment->code != 'completed') {
            throw new Exception("Se requiere tener el contrato pagado para realizar la firma del contrato.", 400);
        }

        if ($register->statusSignature->code == 'sent') {
            throw new Exception("Contrato de Matrícula ya ha sido enviado. Revise su bandeja del correo: " . $register->financial_parent_email, 400);
        }

        if ($register->statusSignature->code == 'signed') {
            throw new Exception("Contrato de Matrícula ya ha sido firmado.", 400);
        }

        if (in_array($register->statusSignature->code, ['pending_manual', 'manual_signed'])) {
            throw new Exception("Este contrato requiere firma manual. No es posible enviar para firma electrónica.", 400);
        }

        if (in_array($register->statusContract->code, ['canceled', 'finished'])) {
            throw new Exception("No es posbile enviar correo. Matrícula se encuentra {$register->description}", 400);
        }

        if (empty($register->financial_parent_email)) {
            throw new Exception("El correo electrónico del apoderado es requerido. Corrija e intente nuevamente.", 409);
        }

        $contractFormat = ContractFormat::where('period_id', $register->period_id)
            ->where('deleted', 0)
            ->first();

        if (!$contractFormat || empty($contractFormat->description)) {
            throw new Exception("No hay un template de contrato configurado para el período seleccionado", 400);
        }

        $register->contract_format_data = $this->replaceTags($contractFormat->description, $register);

        $data = [
            'title'      => 'Contrato de Matrícula',
            'form_data'  => $register,
            'draft_mode' => false
        ];

        // Genera el PDF
        $pdf = FacadePdf::loadView('pdfs.contract_pdf', $data)
            ->setOption('isPhpEnabled', true); // Permitir PHP en plantillas

        $filename = 'matricula_' . str_replace(
            '-',
            '_',
            $register->code_contract
        ) . '.pdf';
        // Guardar localmente en storage
        Storage::put($filename, $pdf->output());



        $filePath = storage_path("app/{$filename}");

        // Contar páginas para firma
        $pdfContent = Storage::get($filename);
        preg_match_all("/\/Type\s*\/Page[^s]/", $pdfContent, $matches);
        $totalPages = count($matches[0]) ?: 1;

        // Convertir a base64
        $pdfBase64 = base64_encode(Storage::get($filename));

        Storage::delete($filename);

        $email = $register->financial_parent_email;
        if (env('TYPE_MODE') != 'PRODUCTION') {
            $email = env('SEND_MAIL');
        }
        $payload = [
            "type" => "ELECTRONIC",
            "name" => $filename,
            "document" => $pdfBase64,
            "flow" => "INDIVIDUAL",
            "observation" => strUpperSinTildes("Firma de Matrícula " . $register->period->period_year),
            "signatories" => [
                [
                    "email" => $email,
                    "fullName" => normalizeOnlyText(($register->financial_parent_first_name . ' ' . $register->financial_parent_last_name . ' ' . $register->financial_parent_second_last_name)),
                    "phoneNumber" => normalizePhoneNumber($register->financial_parent_mobile),
                    // "identification" => "11111111-1",
                    // "mfa" => ["CIVIL_REGISTRY"],

                    //"mfa": [
                    // "BIOMETRIC",
                    // "CIVIL_REGISTRY"
                    // ],
                    "notification" => ["EMAIL"],
                    "positions" => [
                        [
                            "pageNumber" => (string)$totalPages,
                            "x" => "345", // 75 left // 475 right 275px center
                            "y" => "220", // 850 bottom , 75px, 500px center
                            "width" => "220",
                            "height" => "130"
                        ]
                    ]
                ]
            ],
            "metadata" => [
                "contractId" => $register->code_contract,
                "parentId" => "$register->financial_parent_id",
                "reminder" => true,
                "X-Origin-APi" => "FIRMAKI",

            ]
        ];
        $payloadLog = $payload;
        unset($payloadLog['document']);
        Log::info(['payload' => json_encode($payload)]);
        Log::info(['payloadLog' => json_encode($payloadLog)]);
        try {
            $signapis = new SignapisService();
            $data = $signapis->createDocument($payload);
            $register->status_signature_id = $this->contractRepository->getStatus('sent', 'signature');
            // $register->status_contract_id = $this->contractRepository->getStatus('finished', 'contract');
            $register->updated_at = now();
            $register->user_updated = auth()->check() ? auth()->id() : $register->user_updated;
            return $register->save();
        } catch (Exception $e) {
            throw new Exception($e->getMessage(), $e->getCode() ?: 500);
        }
    }


    public function generatePDF64($contractId)
    {

        $dataArray = explode('_', $contractId);
        $contractCode = $dataArray[0] ?? null;
        $parentRut = $dataArray[1] ?? null;

        if (empty($contractCode) || empty($parentRut)) {
            throw new Exception("El código del contrato es inválido", 404);
        }

        $parentRepository = new ParentRepository();
        // Try formatted RUT first, then raw value (for passport parents)
        $parentData = $parentRepository->getByRut(formatterRut($parentRut));
        if (!$parentData) {
            $parentData = $parentRepository->getByRut(strtoupper($parentRut));
        }
        if (!$parentData) {
            throw new Exception("El contrato no existe o fue eliminado", 404);
        }

        $register = $this->contractRepository->getByCode($contractCode);
        if (empty($register)) {
            throw new Exception("El contrato no existe o fue eliminado", 404);
        }

        if (!$register->file_data) {
            throw new Exception("No se pudo encontrar el contrato firmado.", 404);
        }

        return $register;
    }
    public function generatePdf($contractId,  $request, $type)
    {
        set_time_limit(300);
        if ($type == 'code') {
            $dataArray = explode('_', $contractId);
            $contractCode = $dataArray[0] ?? null;
            $parentRut = $dataArray[1] ?? null;

            if (empty($contractCode) || empty($parentRut)) {
                throw new Exception("El código del contrato es inválido", 404);
            }

            $parentRepository = new ParentRepository();
            // Try formatted RUT first, then raw value (for passport parents)
            $parentData = $parentRepository->getByRut(formatterRut($parentRut));
            if (!$parentData) {
                $parentData = $parentRepository->getByRut(strtoupper($parentRut));
            }
            if (!$parentData) {
                throw new Exception("El contrato no existe o fue eliminado", 404);
            }

            $register = $this->contractRepository->getByCode($contractCode);
        } else {
            $register = $this->contractRepository->getById($contractId);
        }

        if (empty($register)) {
            throw new Exception("El contrato no existe o fue eliminado", 404);
        }

        // if ($register->status_contract_id < 2) {
        //     throw new Exception("La matrícula aún se encuentra en curso o no ha sido procesada", 400);
        // }

        $contractFormat = ContractFormat::where('period_id', $register->period_id)
            ->where('deleted', 0)
            ->first();

        if (!$contractFormat || empty($contractFormat->description)) {
            throw new Exception("No hay un template de contrato configurado para el período seleccionado", 400);
        }

        $register->contract_format_data = $this->replaceTags($contractFormat->description, $register);

        // Draft mode: for manual signature, use ?draft=1 query param to show watermark
        // Parent preview sends ?draft=1, admin download does not
        $isManualSignature = in_array($register->statusSignature->code ?? '', ['pending_manual', 'manual_signed']);
        $draftMode = $isManualSignature ? (bool) $request->query('draft', false) : true;

        $data = [
            'title'      => 'Contrato de Matrícula',
            'form_data'  => $register,
            'draft_mode' => $draftMode
        ];


        // Genera el PDF
        $pdf = FacadePdf::loadView('pdfs.contract_pdf', $data)
            ->setOption('isPhpEnabled', true); // Permitir PHP en plantillas

        if (isset($request->download)) {
            return $pdf->download('matricula_' . $register->code_contract . '.pdf');
        }

        return $pdf->stream('matricula_' . $register->code_contract . '.pdf');
    }

    /**
     * Reemplaza las etiquetas del contrato por los valores reales.
     */
    private function replaceTags($template, $register_data)
    {
        // Normalizar entidades HTML de ñ/Ñ para que los tags con ñ sean reemplazados correctamente
        $template = str_replace(['&ntilde;', '&#241;', '&Ntilde;', '&#209;'], ['ñ', 'ñ', 'Ñ', 'Ñ'], $template);

        // Detectar si el apoderado financiero tiene pasaporte (no formatear como RUT)
        $fpDocType = $register_data->financialParent->document_type ?? 'RUT';
        $fpIsPassport = $fpDocType === 'PASSPORT';
        $fpRutDisplay = $fpIsPassport
            ? ($register_data->financial_parent_rut ?? '')
            : (formatterRut($register_data->financial_parent_rut) ?? '');

        // Detectar si el apoderado académico tiene pasaporte
        $acParent = $register_data->academicParent ?? null;
        $acDocType = $acParent->document_type ?? 'RUT';
        $acIsPassport = $acDocType === 'PASSPORT';
        $acRutDisplay = $acIsPassport
            ? ($register_data->academic_parent_rut ?? '')
            : (formatterRut($register_data->academic_parent_rut) ?? '');

        $dia_matricula = isset($register_data->date_contract) ? date('d', strtotime($register_data->date_contract)) : date('d');
        $mes_matricula = isset($register_data->date_contract) ? date('n', strtotime($register_data->date_contract)) : date('n');
        $año_matricula = isset($register_data->date_contract) ? date('Y', strtotime($register_data->date_contract)) : date('Y');

        $replacements = [

            //  Datos generales del contrato
            '#dia_matricula' => $dia_matricula,
            '#mes_matricula' => getMonthText($mes_matricula),
            '#año_matricula' => $año_matricula,
            '#codigo_matricula' => $register_data->code_contract ?? '',
            '#fecha_matricula' => isset($register_data->date_contract)
                ? ordenarFechaHumanoSlash($register_data->date_contract)
                : ordenarFechaHumanoSlash(date('d-m-Y')),
            '#usuario_responsable_matricula' => StrCapital($register_data->createdBy->name ?? ''),
            '#observacion_matricula' => $register_data->observation ?? '',

            // Apoderado financiero
            '#rut_apoderado_financiero' => $fpRutDisplay,
            '#profesion_apoderado_financiero' => $register_data->financial_parent_profession ?? '',
            '#nombre_completo_apoderado_financiero' => implode(' ', array_filter([
                $register_data->financial_parent_first_name ?? '',
                $register_data->financial_parent_second_name ?? '',
                $register_data->financial_parent_last_name ?? '',
                $register_data->financial_parent_second_last_name ?? '',
            ])),
            '#nombres_apoderado_financiero' => trim("{$register_data->financial_parent_first_name} {$register_data->financial_parent_second_name}"),
            '#primer_nombre_apoderado_financiero' => $register_data->financial_parent_first_name ?? '',
            '#segundo_nombre_apoderado_financiero' => $register_data->financial_parent_second_name ?? '',
            '#primer_apellido_apoderado_financiero' => $register_data->financial_parent_last_name ?? '',
            '#segundo_apellido_apoderado_financiero' => $register_data->financial_parent_second_last_name ?? '',
            '#pais_nacimiento_apoderado_financiero' => !empty($register_data->financial_parent_country_data)
                ? StrCapital($register_data->financial_parent_country_data->country ?? '')
                : '',
            '#nacionalidad_apoderado_financiero' => $register_data->financial_parent_country_data->nationality ?? 'Chilena',
            '#fecha_nacimiento_apoderado_financiero' => ordenarFechaHumanoSlash($register_data->financial_parent_birth_date) ?? '',
            '#parentesco_apoderado_financiero' => StrCapital($register_data->financial_parent_relationship_data->relationship ?? ''),
            '#correo_electronico_apoderado_financiero' => $register_data->financial_parent_email ?? '',
            '#email_apoderado_financiero' => $register_data->financial_parent_email ?? '',
            '#celular_apoderado_financiero' => $register_data->financial_parent_mobile ?? '',
            '#genero_apoderado_financiero' => !empty($register_data->financial_parent_gender_data)
                ? StrCapital($register_data->financial_parent_gender_data->gender ?? '')
                : '',
            '#region_apoderado_financiero' => !empty($register_data->financial_parent_region_data)
                ? StrCapital($register_data->financial_parent_region_data->region ?? '')
                : '',
            '#comuna_apoderado_financiero' => !empty($register_data->financial_parent_commune_data)
                ? StrCapital($register_data->financial_parent_commune_data->commune ?? '')
                : '',
            '#direccion_apoderado_financiero' => $register_data->financial_parent_address ?? '',

            //  Apoderado académico
            '#rut_apoderado_academico' => $acRutDisplay,
            '#nombre_completo_apoderado_academico' => trim("{$register_data->academic_parent_first_name} {$register_data->academic_parent_second_name} {$register_data->academic_parent_last_name} {$register_data->academic_parent_second_last_name}"),
            '#nombres_apoderado_academico' => trim("{$register_data->academic_parent_first_name} {$register_data->academic_parent_second_name}"),
            '#primer_nombre_apoderado_academico' => $register_data->academic_parent_first_name ?? '',
            '#segundo_nombre_apoderado_academico' => $register_data->academic_parent_second_name ?? '',
            '#primer_apellido_apoderado_academico' => $register_data->academic_parent_last_name ?? '',
            '#segundo_apellido_apoderado_academico' => $register_data->academic_parent_second_last_name ?? '',
            '#pais_nacimiento_apoderado_academico' => !empty($register_data->academic_parent_country_data)
                ? StrCapital($register_data->academic_parent_country_data->country ?? '')
                : '',
            '#nacionalidad_apoderado_academico' => $register_data->academic_parent_country_data->nationality ?? 'Chilena',
            '#fecha_nacimiento_apoderado_academico' => ordenarFechaHumanoSlash($register_data->academic_parent_birth_date) ?? '',
            '#parentesco_apoderado_academico' => StrCapital($register_data->academic_parent_relationship_data->relationship ?? ''),
            '#correo_electronico_apoderado_academico' => $register_data->academic_parent_email ?? '',
            '#email_apoderado_academico' => $register_data->academic_parent_email ?? '',
            '#celular_apoderado_academico' => $register_data->academic_parent_mobile ?? '',
            '#genero_apoderado_academico' => !empty($register_data->academic_parent_gender_data)
                ? StrCapital($register_data->academic_parent_gender_data->gender ?? '')
                : '',
            '#region_apoderado_academico' => !empty($register_data->academic_parent_region_data)
                ? StrCapital($register_data->academic_parent_region_data->region ?? '')
                : '',
            '#comuna_apoderado_academico' => !empty($register_data->academic_parent_commune_data)
                ? StrCapital($register_data->academic_parent_commune_data->commune ?? '')
                : '',
            '#direccion_apoderado_academico' => $register_data->academic_parent_address ?? '',
        ];
        $data = str_replace(array_keys($replacements), array_values($replacements), $template);
        $studentIds = $register_data->details->pluck('student_id')->unique()->values();
        $students = Student::whereIn('id', $studentIds)->get();
        $studentsHtml = formattedStudents($students ?? []);
        $data = str_replace('#listado_estudiantes', $studentsHtml, $data);
        $data = str_replace('#salto_p_uno', $studentIds->count() > 2 ? '#salto_pagina' : '<p style="text-align:justify">&nbsp;</p><p style="text-align:justify">&nbsp;</p>', $data);
        $data = str_replace('#salto_p_dos', $studentIds->count() > 2 ? '' : '<p style="text-align:justify">&nbsp;</p><p style="text-align:justify">&nbsp;</p>', $data);
        $data = str_replace('#firma_final', signatureSection(), $data);

        // Obtener precios de colegiatura desde period_pricing_configs (fuente de verdad por período)
        $periodId = $register_data->period_id;
        $tuitionConfigs = PeriodPricingConfig::where('period_id', $periodId)
            ->where('config_type', 'tuition')
            ->where('status', true)
            ->where(function ($q) {
                $q->where('sibling_order', 1)->orWhereNull('sibling_order');
            })
            ->orderBy('concept_code')
            ->get()
            ->unique('concept_code');

        $conceptsPaymentsRegularHtml = $this->buildColegiaturaTable($tuitionConfigs, 'regular');
        $conceptsPaymentsExceptionalHtml = $this->buildColegiaturaTable($tuitionConfigs, 'exceptional');

        $data = str_replace('#colegiatura_regular', $conceptsPaymentsRegularHtml, $data);
        $data = str_replace('#colegiatura_excepcional', $conceptsPaymentsExceptionalHtml, $data);


        return $data;
    }

    /**
     * Genera tabla HTML de colegiaturas desde period_pricing_configs.
     */
    private function buildColegiaturaTable($tuitionConfigs, $typePrice = 'regular')
    {
        $rows = '';
        foreach ($tuitionConfigs as $config) {
            $price = $typePrice === 'exceptional' ? $config->price_extended : $config->price_regular;
            if (!$price || $price <= 0) continue;

            // Obtener descripción desde payment_concept_details por concept_code
            $conceptDetail = PaymentConceptDetail::where('code', $config->concept_code)->first();
            $description = $conceptDetail ? $conceptDetail->description : str_replace('_', ' ', $config->concept_code);
            $currencySymbol = $config->currency_type ?? 'UF';
            $formattedPrice = $currencySymbol === 'UF'
                ? $currencySymbol . ' ' . number_format($price, 2, ',', '.')
                : '$ ' . number_format($price, 0, ',', '.');

            $rows .= '<tr>
                <td style="text-align:center"><span style="font-size:11pt"><span style="font-family:&quot;Arial MT&quot;,sans-serif"><span style="font-size:9.0pt">' . $description . '</span></span></span></td>
                <td style="text-align:center"><span style="font-size:11pt"><span style="font-family:&quot;Arial MT&quot;,sans-serif"><span style="font-size:9.0pt"><span style="font-family:&quot;Arial&quot;,sans-serif">' . $formattedPrice . '</span></span></span></span></td>
            </tr>';
        }

        return '<table border="1" cellpadding="1" cellspacing="1" style="width:500px">
            <tbody>
                <tr>
                    <td style="text-align:center"><span style="font-size:11pt"><span style="font-family:&quot;Arial MT&quot;,sans-serif"><strong><span style="font-size:9.0pt"><span style="font-family:&quot;Arial&quot;,sans-serif">Niveles</span></span></strong></span></span></td>
                    <td style="text-align:center"><span style="font-size:11pt"><span style="font-family:&quot;Arial MT&quot;,sans-serif"><strong><span style="font-size:9.0pt"><span style="font-family:&quot;Arial&quot;,sans-serif">Colegiatura Anual</span></span></strong></span></span></td>
                </tr>
                ' . $rows . '
            </tbody>
        </table>';
    }

    public function updateParent($code, $request)
    {
        $this->validatePeriod();

        $register = $this->showByCode($code, true, true);
        if (!$register) {
            throw new Exception("Matrícula no existe o fue eliminada", 404);
        }
        if ($register->statusContract->code == 'finished') {
            throw new Exception("No se pueden modificar las matrículas tras la generación del contrato", 404);
        }


        if ($register->statusSignature->code == 'sent') {
            throw new Exception("No es posible modificar matrícula. El contrato ya ha sido enviado.", 400);
        }

        if (in_array($register->statusSignature->code, ['signed', 'manual_signed'])) {
            throw new Exception("No es posible modificar matrícula. El contrato ya ha sido firmado.", 400);
        }

        if (in_array($register->statusContract->code, ['canceled', 'finished'])) {
            throw new Exception("No es posible modificar registros. La Matrícula se encuentra {$register->description}.", 400);
        }

        $update = $this->contractRepository->updateParent($register, (object)$request);
        if (!$update) {
            throw new Exception("Ha ocurrido un error al actualizar el contrato", 500);
        }

        return true;
    }

    /**
     * Subir PDF firmado manualmente (para apoderados con pasaporte)
     */
    public function uploadSignedPdf($code, $file)
    {
        $register = $this->contractRepository->getByCode($code);
        if (!$register) {
            throw new Exception("El contrato no existe o fue eliminado.", 404);
        }

        if ($register->statusSignature->code !== 'pending_manual') {
            throw new Exception("Este contrato no requiere firma manual.", 400);
        }

        if ($register->statusPayment->code !== 'completed') {
            throw new Exception("El contrato debe estar pagado antes de subir la firma.", 400);
        }

        $fileContent = base64_encode(file_get_contents($file->getRealPath()));
        $fileName = 'MANUAL_SIGNED_' . $code . '_' . time() . '.pdf';

        $register->file_data = $fileContent;
        $register->file_code = $fileName;
        $register->status_signature_id = $this->contractRepository->getStatus('manual_signed', 'signature');
        $register->status_contract_id = $this->contractRepository->getStatus('finished', 'contract');
        $register->signed_at = now();
        $register->user_finished = auth()->id();
        $register->save();

        return $register;
    }

    /**
     * Obtener alumnos con colegiatura pendiente del apoderado autenticado.
     * Se usa para mostrar el modal de matrícula incompleta en el dashboard.
     *
     * Retorna lista deduplicada (por student_id) ordenada alfabéticamente
     * y un flag has_incomplete.
     */
    public function getIncompleteEnrollmentsForParent()
    {
        $user = auth()->user();
        if (!$user || ($user->profile->code ?? null) !== 'parent') {
            return [
                'has_incomplete' => false,
                'students' => [],
            ];
        }

        $parentId = $user->parent->id ?? 0;
        if ($parentId < 1) {
            return [
                'has_incomplete' => false,
                'students' => [],
            ];
        }

        $contracts = $this->contractRepository->getIncompleteEnrollmentsByParent($parentId);

        $studentsMap = [];
        foreach ($contracts as $contract) {
            foreach ($contract->details as $detail) {
                $studentId = $detail->student_id;
                if (!$studentId || isset($studentsMap[$studentId])) {
                    continue;
                }

                $firstName  = $detail->student_first_name  ?? $detail->student->first_name  ?? '';
                $lastName   = $detail->student_last_name   ?? $detail->student->last_name   ?? '';
                $secondName = $detail->student_second_name ?? $detail->student->second_name ?? '';
                $secondLast = $detail->student_second_last_name ?? $detail->student->second_last_name ?? '';
                $rut        = $detail->student_rut ?? $detail->student->rut ?? null;

                $fullName = trim(implode(' ', array_filter([
                    $firstName,
                    $secondName,
                    $lastName,
                    $secondLast,
                ])));

                $studentsMap[$studentId] = [
                    'student_id' => $studentId,
                    'full_name'  => $fullName !== '' ? $fullName : "Alumno #{$studentId}",
                    'rut'        => $rut,
                ];
            }
        }

        $students = array_values($studentsMap);
        usort($students, fn($a, $b) => strcasecmp($a['full_name'], $b['full_name']));

        return [
            'has_incomplete' => count($students) > 0,
            'students'       => $students,
        ];
    }

    /**
     * Cambiar el apoderado financiero de un contrato.
     * Solo permitido si no hay pagos realizados ni suscripciones Toku activas.
     */
    public function changeFinancialParent($code, $newParentId)
    {
        $contract = $this->contractRepository->getByCode($code);
        if (!$contract) {
            throw new Exception("Contrato no encontrado", 404);
        }

        // Validar estados
        if (in_array($contract->statusContract->code, ['finished', 'canceled'])) {
            throw new Exception("No se puede cambiar el apoderado de un contrato finalizado o cancelado", 400);
        }

        if (in_array($contract->statusSignature->code, ['sent', 'signed', 'manual_signed'])) {
            throw new Exception("No se puede cambiar el apoderado de un contrato firmado o en proceso de firma", 400);
        }

        // Validar que sea un parent diferente
        if ($contract->financial_parent_id == $newParentId) {
            throw new Exception("El apoderado seleccionado ya es el apoderado financiero de este contrato", 400);
        }

        // Validar sin pagos realizados
        $hasPaidDetails = ContractDetail::where('contract_id', $contract->id)
            ->where('paid', true)
            ->exists();
        if ($hasPaidDetails) {
            throw new Exception("No se puede cambiar el apoderado: el contrato tiene pagos realizados", 400);
        }

        // Validar sin suscripciones Toku activas
        $hasTokuSubscription = ContractDetail::where('contract_id', $contract->id)
            ->whereNotNull('toku_subscription_id')
            ->exists();
        if ($hasTokuSubscription) {
            throw new Exception("No se puede cambiar el apoderado: hay suscripciones Toku activas", 400);
        }

        $newParent = Parents::find($newParentId);
        if (!$newParent) {
            throw new Exception("El apoderado seleccionado no existe", 404);
        }

        $oldParentId = $contract->financial_parent_id;
        $periodId = $contract->period_id;

        DB::transaction(function () use ($contract, $newParent, $oldParentId, $periodId) {
            // 1. Actualizar FK y snapshot en el contrato
            $contract->financial_parent_id                = $newParent->id;
            $contract->financial_parent_rut               = $newParent->rut;
            $contract->financial_parent_first_name        = $newParent->first_name;
            $contract->financial_parent_second_name       = $newParent->second_name;
            $contract->financial_parent_last_name         = $newParent->last_name;
            $contract->financial_parent_second_last_name  = $newParent->second_last_name;
            $contract->financial_parent_birth_date        = $newParent->birth_date;
            $contract->financial_parent_email             = $newParent->email;
            $contract->financial_parent_mobile            = $newParent->mobile;
            $contract->financial_parent_relationship_id   = $newParent->relationship_id;
            $contract->financial_parent_gender_id         = $newParent->gender_id;
            $contract->financial_parent_country_id        = $newParent->country_id;
            $contract->financial_parent_region_id         = $newParent->region_id;
            $contract->financial_parent_commune_id        = $newParent->commune_id;
            $contract->financial_parent_address           = $newParent->address;
            $contract->financial_parent_profession        = $newParent->profession;
            $contract->updated_at = now();
            $contract->user_updated = auth()->check() ? auth()->id() : $contract->user_updated;
            $contract->save();

            // 2. Obtener student IDs ANTES de cualquier borrado de details
            $studentIds = ContractDetail::where('contract_id', $contract->id)
                ->pluck('student_id')
                ->unique()
                ->toArray();

            // 3. Actualizar financial_parent_id de los estudiantes del contrato
            if (!empty($studentIds)) {
                Student::whereIn('id', $studentIds)
                    ->where('financial_parent_id', $oldParentId)
                    ->update(['financial_parent_id' => $newParent->id]);
            }

            // 4. Recalcular deudas para el nuevo parent
            //    recalculateFamilySiblingDebts internamente borra los details unpaid y los recrea
            //    con el nuevo parent (code_toku, family payments, sibling order)
            $this->debtCalculationService->recalculateFamilySiblingDebts($newParent->id, $periodId);

            // 5. Recalcular deudas del viejo parent (sus otros contratos pueden cambiar de orden)
            if ($oldParentId) {
                $this->debtCalculationService->recalculateFamilySiblingDebts($oldParentId, $periodId);
            }
        });

        return true;
    }
}