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(['ñ', 'ñ', 'Ñ', 'Ñ'], ['ñ', 'ñ', 'Ñ', 'Ñ'], $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"> </p><p style="text-align:justify"> </p>', $data);
$data = str_replace('#salto_p_dos', $studentIds->count() > 2 ? '' : '<p style="text-align:justify"> </p><p style="text-align:justify"> </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:"Arial MT",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:"Arial MT",sans-serif"><span style="font-size:9.0pt"><span style="font-family:"Arial",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:"Arial MT",sans-serif"><strong><span style="font-size:9.0pt"><span style="font-family:"Arial",sans-serif">Niveles</span></span></strong></span></span></td>
<td style="text-align:center"><span style="font-size:11pt"><span style="font-family:"Arial MT",sans-serif"><strong><span style="font-size:9.0pt"><span style="font-family:"Arial",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;
}
}