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