File: /var/www/matriculas_api_dev/app/Services/PresencialService.php
<?php
namespace App\Services;
use App\Models\Contract;
use App\Models\ContractDetail;
use App\Models\Course;
use App\Models\PaymentMethods;
use App\Models\Period;
use App\Models\PresencialPayment;
use App\Models\Student;
use App\Repositories\ContractRepository;
use App\Repositories\PeriodRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Barryvdh\DomPDF\Facade\Pdf as FacadePdf;
use Carbon\Carbon;
use Exception;
class PresencialService
{
protected $contractRepository;
protected $debtCalculationService;
public function __construct(
ContractRepository $contractRepository,
DebtCalculationService $debtCalculationService
) {
$this->contractRepository = $contractRepository;
$this->debtCalculationService = $debtCalculationService;
}
/**
* Obtener alumnos disponibles (sin contrato activo en el periodo) para un apoderado.
* Solo incluye alumnos habilitados en student_period para el periodo dado.
*/
public function getAvailableStudents($parentId, $periodId)
{
$allStudents = Student::where('financial_parent_id', $parentId)
->where('deleted', false)
->where('status', true)
->whereHas('periods', function ($q) use ($periodId) {
$q->where('period_id', $periodId);
})
->with(['course', 'courseLetter'])
->get();
// Filter out students who already have a contract detail in this period
$studentsWithContract = ContractDetail::whereHas('contract', function ($q) use ($parentId, $periodId) {
$q->where('financial_parent_id', $parentId)
->where('period_id', $periodId);
})
->pluck('student_id')
->unique()
->toArray();
return $allStudents->filter(function ($student) use ($studentsWithContract) {
return !in_array($student->id, $studentsWithContract);
})->values();
}
/**
* Preview de deudas para múltiples estudiantes
*/
public function previewDebts($studentIds, $periodId)
{
$previews = [];
foreach ($studentIds as $studentId) {
$previews[] = $this->debtCalculationService->previewStudentDebts($studentId, $periodId);
}
return $previews;
}
/**
* Crear contrato(s) presencial(es) con deudas auto-calculadas.
* Separa automáticamente alumnos PG y no-PG en contratos distintos.
*/
public function createContract($parentId, $studentIds, $periodId, $enrollmentType = 'regular')
{
DB::beginTransaction();
try {
Log::info("[Presencial] Iniciando creación de contrato", [
'parent_id' => $parentId,
'student_ids' => $studentIds,
'period_id' => $periodId,
]);
$parent = \App\Models\Parents::find($parentId);
if (!$parent) {
throw new Exception("Apoderado no encontrado");
}
// Separate students by PG vs non-PG
$students = Student::whereIn('id', $studentIds)->with('course')->get();
$pgStudents = $students->filter(fn($s) => $this->isPlayGroupCourse($s->course_id));
$nonPgStudents = $students->filter(fn($s) => !$this->isPlayGroupCourse($s->course_id));
Log::info("[Presencial] Alumnos separados", [
'pg_ids' => $pgStudents->pluck('id')->toArray(),
'regular_ids' => $nonPgStudents->pluck('id')->toArray(),
]);
$results = [];
// Create PG contract if there are PG students
if ($pgStudents->isNotEmpty()) {
$results[] = $this->createSingleContract(
$parent,
$pgStudents->pluck('id')->toArray(),
$periodId,
'playgroup'
);
}
// Create regular contract if there are non-PG students
if ($nonPgStudents->isNotEmpty()) {
$results[] = $this->createSingleContract(
$parent,
$nonPgStudents->pluck('id')->toArray(),
$periodId,
'regular'
);
}
DB::commit();
Log::info("[Presencial] Contrato(s) creado(s) exitosamente", [
'parent_id' => $parentId,
'contratos' => array_map(fn($r) => $r['contract_code'] ?? '', $results),
]);
return $results;
} catch (Exception $e) {
DB::rollBack();
Log::error('[Presencial] Error creando contrato', [
'parent_id' => $parentId,
'student_ids' => $studentIds,
'period_id' => $periodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Crear un solo contrato con sus deudas
*/
private function createSingleContract($parent, $studentIds, $periodId, $enrollmentType)
{
$isPlayGroup = $enrollmentType === 'playgroup';
$isPassport = ($parent->document_type ?? 'RUT') === 'PASSPORT';
$signatureStatus = $isPlayGroup ? 'not_required' : ($isPassport ? 'pending_manual' : 'pending');
// Generate unique contract code (sin sufijo -PG, correlativo estándar)
$periodYear = Period::where('id', $periodId)->value('period_year') ?? date('Y');
$contractCode = $this->generateContractCode($periodYear, $parent->id, $periodId);
Log::info("[Presencial] Creando contrato individual", [
'code' => $contractCode,
'parent_id' => $parent->id,
'parent_rut' => $parent->rut,
'enrollment_type' => $enrollmentType,
'signature_status' => $signatureStatus,
'student_ids' => $studentIds,
]);
$contract = $this->contractRepository->findOrcreate($contractCode, $parent, $periodId);
// Update contract attributes
$contract->enrollment_type = $enrollmentType;
$contract->status_contract_id = $this->contractRepository->getStatus('in_course', 'contract');
$contract->status_payment_id = $this->contractRepository->getStatus('pending', 'payment');
$contract->status_signature_id = $this->contractRepository->getStatus($signatureStatus, 'signature');
$contract->observation = $isPlayGroup
? 'Contrato Play Group creado presencialmente por agente.'
: 'Contrato creado presencialmente por agente.';
$contract->save();
// Calculate debts for each student
$allDebts = [];
foreach ($studentIds as $studentId) {
$debts = $this->debtCalculationService->calculateStudentDebts($studentId, $periodId, $contract->id);
$allDebts[$studentId] = $debts;
Log::info("[Presencial] Deudas calculadas para estudiante {$studentId}", [
'contract_id' => $contract->id,
'total_deudas' => count($debts),
]);
}
// Recalcular sibling ordering tras crear deudas de todos los alumnos
$hasFamilyStudents = Student::whereIn('id', $studentIds)->where('family', 1)->exists();
if ($hasFamilyStudents) {
Log::info("[Presencial] Recalculando hermanos para apoderado {$parent->id}");
$this->debtCalculationService->recalculateFamilySiblingDebts($parent->id, $periodId);
}
Log::info("[Presencial] Contrato {$contractCode} completado", [
'contract_id' => $contract->id,
'total' => $contract->fresh()->total_amount,
]);
return [
'success' => true,
'contract_id' => $contract->id,
'contract_code' => $contract->code_contract,
'enrollment_type' => $enrollmentType,
'debts' => $allDebts,
'total' => $contract->fresh()->total_amount,
];
}
/**
* Generate a unique contract code with global sequential correlative.
* Format: CTR-YYYY-NNNN where NNNN is the next available number for that year.
*/
private function generateContractCode($periodYear, $parentId = null, $periodId = null)
{
$prefix = sprintf('CTR-%s-', $periodYear);
// Find the highest existing correlative for this year (only exact format CTR-YYYY-NNNN)
$lastCode = Contract::where('code_contract', 'like', $prefix . '%')
->whereRaw("code_contract REGEXP ?", ['^CTR-' . $periodYear . '-[0-9]+$'])
->orderByRaw("CAST(SUBSTRING(code_contract, ?) AS UNSIGNED) DESC", [strlen($prefix) + 1])
->value('code_contract');
$nextNumber = 1;
if ($lastCode) {
$numericPart = substr($lastCode, strlen($prefix));
$parsed = intval($numericPart);
if ($parsed > 0) {
$nextNumber = $parsed + 1;
}
}
return sprintf('CTR-%s-%04d', $periodYear, $nextNumber);
}
/**
* Determine if a course is Play Group
*/
private function isPlayGroupCourse($courseId): bool
{
if (in_array($courseId, [14, 1])) {
return true;
}
$course = Course::find($courseId);
return $course && in_array($course->code, ['PG', 'JI']);
}
/**
* Procesar pago POS: marcar conceptos seleccionados como pagados
* Si $isSubscription = true (PAC/PAT), solo registra la seleccion sin marcar como pagado
*/
public function processPosPayment($contractId, $detailIds, $paymentMethodId, $referenceNumber = null, $notes = null, $attachmentFile = null, $isSubscription = false)
{
DB::beginTransaction();
try {
$flowType = $isSubscription ? 'PAC/PAT' : 'POS';
Log::info("[Presencial] Iniciando pago {$flowType}", [
'contract_id' => $contractId,
'detail_ids' => $detailIds,
'payment_method_id' => $paymentMethodId,
'is_subscription' => $isSubscription,
]);
$contract = Contract::find($contractId);
if (!$contract) {
throw new Exception("Contrato no encontrado");
}
$paymentMethodRecord = PaymentMethods::find($paymentMethodId);
if (!$paymentMethodRecord) {
throw new Exception("Metodo de pago no encontrado");
}
// Get selected unpaid details for this contract
$details = ContractDetail::where('contract_id', $contractId)
->whereIn('id', $detailIds)
->where('paid', false)
->with('paymentConceptDetail.currency_type')
->get();
if ($details->isEmpty()) {
throw new Exception("No hay conceptos pendientes para pagar");
}
Log::info("[Presencial] Conceptos a pagar ({$flowType})", [
'contract_code' => $contract->code_contract,
'details_encontrados' => $details->count(),
'detail_ids_encontrados' => $details->pluck('id')->toArray(),
]);
// Get UF rate for CLP conversion
$ufRate = getUfValue();
// Calculate total (in CLP) and build details array
$totalAmount = 0;
$paidDetails = [];
foreach ($details as $detail) {
$currency = $detail->paymentConceptDetail->currency_type->currency_symbol ?? '$';
$rawDesc = $detail->paymentConceptDetail->description ?? '';
// Ensure valid UTF-8 (DB may store Latin-1 chars like Ñ, Ó, °)
if (!mb_check_encoding($rawDesc, 'UTF-8')) {
$rawDesc = mb_convert_encoding($rawDesc, 'UTF-8', 'ISO-8859-1');
}
// Para colegiatura aplica cálculo proporcional según calendario de cuotas
$effectiveAmount = $contract->getEffectiveDetailAmount($detail);
$entry = [
'id' => $detail->id,
'description' => $rawDesc,
'amount' => $effectiveAmount,
'currency' => $currency,
];
// Convert UF to CLP for the total and include CLP equivalent
if ($currency === 'UF' && $ufRate > 0) {
$clpAmount = (int) round($effectiveAmount * $ufRate);
$entry['amount_clp'] = $clpAmount;
$totalAmount += $clpAmount;
} else {
$totalAmount += $effectiveAmount;
}
$paidDetails[] = $entry;
}
// Only mark as paid if NOT a subscription (PAC/PAT)
if (!$isSubscription) {
foreach ($details as $detail) {
$detail->paid = true;
$detail->paid_at = now();
$detail->save();
}
Log::info("[Presencial] Conceptos marcados como pagados", [
'contract_id' => $contractId,
'detail_ids' => $details->pluck('id')->toArray(),
'total_clp' => $totalAmount,
]);
}
// Handle file attachment
$attachmentPath = null;
if ($attachmentFile) {
$attachmentPath = $attachmentFile->store('presencial_attachments', 'public');
}
// PAC/PAT: create subscription + 10 monthly invoices in TOKU
$subscriptionLink = null;
$subscriptionId = null;
$tokuInvoiceIds = [];
if ($isSubscription) {
$contract->load('financialParent');
$parent = $contract->financialParent;
if (!$parent) {
throw new Exception("Apoderado financiero no encontrado");
}
// Use contract-level data
$fpRut = $contract->financial_parent_rut ?? $parent->rut;
$fpEmail = $contract->financial_parent_email ?? $parent->email;
$fpName = trim(
($contract->financial_parent_first_name ?? $parent->first_name ?? '') . ' ' .
($contract->financial_parent_last_name ?? $parent->last_name ?? '')
);
$fpPhone = normalizePhoneNumber($contract->financial_parent_mobile ?? $parent->mobile);
// Resolve government_id: use generic RUT for passport parents
$governmentId = str_replace(['.', '-'], '', $fpRut ?? '');
$tokuService = app(ApiTokuService::class);
$config = getConfiguration('toku');
$isPassport = ($parent->document_type ?? 'RUT') === 'PASSPORT';
if ($isPassport) {
$genericRut = $config['generic_rut'] ?? null;
if (empty($genericRut)) {
throw new Exception("Para procesar pagos de apoderados con pasaporte debe configurar un RUT genérico en la configuración de Toku");
}
$governmentId = str_replace(['.', '-'], '', $genericRut);
}
// Ensure customer exists in TOKU
if (empty($parent->code_toku)) {
Log::info("[TokuPAC] Creando customer en Toku", [
'parent_id' => $parent->id,
'rut' => $fpRut,
'email' => $fpEmail,
'is_passport' => $isPassport,
]);
$customerResponse = $tokuService->createCustomer([
'government_id' => $governmentId,
'external_id' => $fpRut,
'mail' => $fpEmail,
'name' => $fpName,
'phone_number' => $fpPhone,
'send_mail' => false,
]);
$parent->code_toku = $customerResponse['id'] ?? null;
$parent->save();
Log::info("[TokuPAC] Customer creado: {$parent->code_toku}");
}
if (empty($parent->code_toku)) {
throw new Exception("No se pudo obtener el customer ID de TOKU");
}
$schedule = $contract->getInstallmentSchedule();
$periodYear = $schedule['period_year'];
$startMonth = $schedule['start_month'];
$numInstallments = $schedule['num_installments'];
$halfFirstInstallment = $schedule['half_first_installment'];
$contractDate = Carbon::parse($contract->date_contract);
Log::info("[TokuPAC] Cálculo de cuotas", [
'contract_id' => $contractId,
'contract_code' => $contract->code_contract,
'date_contract' => $contract->date_contract,
'period_year' => $periodYear,
'contract_year' => $contractDate->year,
'contract_month' => $contractDate->month,
'contract_day' => $contractDate->day,
'start_month' => $startMonth,
'num_installments' => $numInstallments,
'half_first_installment' => $halfFirstInstallment,
]);
// Obtener subscripciones existentes del customer (una sola llamada)
$existingSubscriptions = [];
try {
$existingSubscriptions = $tokuService->getSubscriptionsByCustomer($parent->code_toku);
} catch (\Exception $e) {
Log::info("[TokuPAC] Sin subscripciones existentes para customer {$parent->code_toku}: " . $e->getMessage());
}
// Helper: resolver RUT limpio para product_id
$cleanRut = function ($rut) {
$clean = str_replace('.', '', $rut ?? '');
if (strpos($clean, '-') !== false) {
$clean = substr($clean, 0, strrpos($clean, '-'));
}
return strtolower($clean);
};
// Cache de subscriptions resueltas por product_id (RUT)
$resolvedSubscriptions = [];
$resolveSubscription = function ($rutForProduct) use (
$tokuService, $parent, $contractId, $existingSubscriptions, &$resolvedSubscriptions
) {
if (isset($resolvedSubscriptions[$rutForProduct])) {
return $resolvedSubscriptions[$rutForProduct];
}
$existing = collect($existingSubscriptions)->firstWhere('product_id', $rutForProduct);
if ($existing) {
$subId = $existing['id'];
$wasExisting = true;
} else {
$response = $tokuService->createSubscription([
'product_id' => $rutForProduct,
'customer' => $parent->code_toku,
'metadata' => [
'contract_id' => $contractId,
'description' => 'Subscription PAC/PAT alumno ' . $rutForProduct,
'type' => 'pac_pat',
],
]);
$subId = $response['id'] ?? null;
$wasExisting = false;
}
Log::info("[TokuPAC] Subscription resuelta", [
'subscription_id' => $subId,
'product_id' => $rutForProduct,
'customer' => $parent->code_toku,
'fue_existente' => $wasExisting,
]);
$resolvedSubscriptions[$rutForProduct] = $subId;
return $subId;
};
// Crear invoices (10 cuotas) por cada detail, cada uno bajo la subscription de su alumno
foreach ($details as $detail) {
$rawDesc = $detail->paymentConceptDetail->description ?? 'Colegiatura';
if (!mb_check_encoding($rawDesc, 'UTF-8')) {
$rawDesc = mb_convert_encoding($rawDesc, 'UTF-8', 'ISO-8859-1');
}
// Send original currency to TOKU (UF → CLF, CLP stays CLP)
$currencySymbol = $detail->paymentConceptDetail->currency_type->currency_symbol ?? '$';
$tokuCurrency = ($currencySymbol === 'UF') ? 'CLF' : 'CLP';
$detailAmount = ($tokuCurrency === 'CLF')
? round((float) $detail->amount, 4)
: (int) $detail->amount;
$conceptCode = $detail->paymentConceptDetail->code_toku ?? 'col';
$isFamilyConcept = $detail->paymentConceptDetail->family_payment ?? false;
$rutClean = $isFamilyConcept
? $cleanRut($fpRut)
: $cleanRut($detail->student->rut ?? '');
$detailSubscriptionId = $resolveSubscription($rutClean);
// external_id determinístico: {concepto}_{año}_{rut}. Toku acepta upsert por
// este valor (POST con mismo external_id devuelve la invoice existente). El
// webhook matchea por id_invoice (toku_debt_ids) — ver findDetailForWebhook.
$externalId = $conceptCode . '_' . $periodYear . '_' . $rutClean;
$detail->code_toku = $externalId;
$detail->save();
Log::info("[TokuPAC] Creando {$numInstallments} cuotas para detail {$detail->id}", [
'concept' => $conceptCode,
'external_id' => $externalId,
'amount' => $detailAmount,
'currency' => $tokuCurrency,
'installment_amount' => $tokuCurrency === 'CLF' ? round($detailAmount / 10, 4) : (int) round($detailAmount / 10),
'half_first' => $halfFirstInstallment,
]);
// Crear invoices mensuales: cuota = monto / 10 (siempre base 10 meses)
// Si date_contract es después del 15, primera cuota al 50%
$detailInvoiceIds = [];
if ($tokuCurrency === 'CLF') {
$detailInstallment = round($detailAmount / 10, 4);
} else {
$detailInstallment = (int) round($detailAmount / 10);
}
// Cuotas desde $startMonth hasta diciembre del año del periodo
for ($i = 1; $i <= $numInstallments; $i++) {
if ($i === 1 && $halfFirstInstallment) {
// Primera cuota al 50% por ingreso después del día 15
$amount = ($tokuCurrency === 'CLF')
? round($detailInstallment / 2, 4)
: (int) round($detailInstallment / 2);
} else {
$amount = $detailInstallment;
}
// Due date: cuota incorporación 2 tiene vencimiento especial
$detailConceptCode = $detail->paymentConceptDetail->code ?? '';
$dueDate = ($detailConceptCode === 'CUOTA_INCORPORACION_2')
? $this->getSecondIncorporationDueDate($contract)
: Carbon::create($periodYear, $startMonth + $i - 1, 5)->format('Y-m-d');
Log::info("[TokuPAC] Creando cuota {$i}/{$numInstallments}", [
'detail_id' => $detail->id,
'concept' => $conceptCode,
'concept_code' => $detailConceptCode,
'amount' => $amount,
'installment_base' => $detailInstallment,
'is_half' => ($i === 1 && $halfFirstInstallment),
'due_date' => $dueDate,
'currency' => $tokuCurrency,
'subscription_id' => $detailSubscriptionId,
'external_id' => $externalId . '_cuota_' . $i,
]);
$invoiceResponse = $tokuService->createInvoice([
'subscription' => $detailSubscriptionId,
'customer' => $parent->code_toku,
'invoice_external_id' => $externalId . '_cuota_' . $i,
'amount' => $amount,
'due_date' => $dueDate,
'currency_code' => $tokuCurrency,
]);
$invoiceId = $invoiceResponse['id'] ?? null;
$tokuInvoiceIds[] = $invoiceId;
$detailInvoiceIds[] = $invoiceId;
}
// Store Toku IDs on the contract detail
$detail->toku_subscription_id = $detailSubscriptionId;
$detail->toku_debt_ids = array_filter($detailInvoiceIds);
$detail->save();
}
// Build recurring portal URL with all subscription IDs
$allSubIds = array_values($resolvedSubscriptions);
$subscriptionLink = $config['url_pac'] ?? '';
$subscriptionLink = str_replace('#PARENT_CODE', $parent->code_toku, $subscriptionLink);
$subscriptionLink = str_replace('#SUBSCRIPTION_IDS', json_encode($allSubIds), $subscriptionLink);
Log::info("[TokuPAC] Flujo PAC/PAT completado", [
'contract_id' => $contractId,
'subscription_ids' => $allSubIds,
'total_invoices' => count($tokuInvoiceIds),
'total_details' => $details->count(),
'num_installments' => $numInstallments,
'period_year' => $periodYear,
'start_month' => $startMonth,
]);
}
// Create presencial payment record
$receiptData = ['detail_ids' => $detailIds, 'details' => $paidDetails, 'uf_rate' => $ufRate];
if ($isSubscription) {
$receiptData['toku_subscription_ids'] = array_values($resolvedSubscriptions);
$receiptData['toku_invoice_ids'] = array_filter($tokuInvoiceIds);
$receiptData['installments'] = $numInstallments;
}
$payment = PresencialPayment::create([
'contract_id' => $contractId,
'payment_method_id' => $paymentMethodId,
'amount' => $totalAmount,
'reference_number' => $referenceNumber,
'attachment_path' => $attachmentPath,
'receipt_data' => $receiptData,
'notes' => $notes,
'registered_by' => auth()->id(),
'reconciliation_status' => $isSubscription ? 'pending' : 'reconciled',
'subscription_status' => $isSubscription ? 'pending_subscription' : null,
'subscription_link' => $subscriptionLink,
]);
// Update contract payment status (only if not subscription)
// Count mandatory unpaid: requires_payment=true OR colegiatura
// Optional concepts don't affect contract completion
$mandatoryUnpaid = ContractDetail::where('contract_id', $contractId)
->where('paid', false)
->whereHas('paymentConceptDetail', function ($q) {
$q->where('requires_payment', true)
->orWhere('description', 'LIKE', 'COLEGIATURA%');
})
->count();
// No regresar estado si el contrato ya está finalizado o cancelado
$contractCode = $contract->statusContract->code ?? '';
$isAlreadyTerminal = in_array($contractCode, ['finished', 'canceled']);
if (!$isSubscription && !$isAlreadyTerminal) {
if ($mandatoryUnpaid > 0) {
$contract->status_payment_id = $this->contractRepository->getStatus('partial', 'payment');
} else {
$contract->status_payment_id = $this->contractRepository->getStatus('completed', 'payment');
$contract->paid_at = now();
// PG → finalizar directamente (firma no requerida)
if ($contract->enrollment_type === 'playgroup') {
$contract->status_contract_id = $this->contractRepository->getStatus('finished', 'contract');
$contract->finished_at = now();
} else {
$contract->status_contract_id = $this->contractRepository->getStatus('pending_signature', 'contract');
}
}
$contract->save();
}
DB::commit();
$contractStatus = $isSubscription ? 'subscription_registered' : ($mandatoryUnpaid > 0 ? 'partial' : 'completed');
Log::info("[Presencial] Pago {$flowType} completado", [
'payment_id' => $payment->id,
'contract_id' => $contractId,
'contract_code' => $contract->code_contract,
'total_clp' => $totalAmount,
'details_pagados' => count($paidDetails),
'contract_status' => $contractStatus,
]);
// Enviar correo al apoderado informando pago registrado manualmente.
// Solo en pagos reales (no subscriptions PAC/PAT, ya que ahí no se marca como pagado).
if (!$isSubscription) {
$this->sendPaymentRegisteredMail($contract, $details, $paymentMethodRecord, $totalAmount, $referenceNumber, $mandatoryUnpaid);
}
return [
'payment_id' => $payment->id,
'total_paid' => $totalAmount,
'details_paid' => count($paidDetails),
'details' => $paidDetails,
'payment_method_name' => $paymentMethodRecord->payment_method,
'contract_status' => $contractStatus,
'is_subscription' => $isSubscription,
'subscription_link' => $payment->subscription_link,
];
} catch (Exception $e) {
DB::rollBack();
Log::error("[Presencial] Error procesando pago {$flowType}", [
'contract_id' => $contractId,
'detail_ids' => $detailIds,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Reverse a single detail from a POS payment.
* Marks the detail as unpaid, adjusts or deletes the payment record.
* Only for regular (non-subscription, non-colegiatura) concepts.
*/
public function reverseDetail($detailId)
{
DB::beginTransaction();
try {
Log::info("[Presencial] Iniciando reversión de detalle", ['detail_id' => $detailId]);
// Find the detail
$detail = ContractDetail::with('paymentConceptDetail')->find($detailId);
if (!$detail) {
throw new Exception("Concepto no encontrado");
}
if (!$detail->paid) {
throw new Exception("El concepto no esta pagado");
}
// Block colegiatura reversal
$desc = $detail->paymentConceptDetail->description ?? '';
if (strtoupper(substr($desc, 0, 11)) === 'COLEGIATURA') {
throw new Exception("No se puede revertir pagos de colegiatura desde aqui");
}
$contractId = $detail->contract_id;
$contract = Contract::find($contractId);
if (!$contract) {
throw new Exception("Contrato no encontrado");
}
// Find the payment record that contains this detail_id
$payments = PresencialPayment::where('contract_id', $contractId)
->whereNull('subscription_status')
->get();
$payment = null;
foreach ($payments as $p) {
$receiptDetailIds = $p->receipt_data['detail_ids'] ?? [];
if (in_array($detailId, $receiptDetailIds)) {
$payment = $p;
break;
}
}
// Mark the single detail as unpaid
$detail->paid = false;
$detail->paid_at = null;
$detail->save();
Log::info("[Presencial] Detalle marcado como no pagado", [
'detail_id' => $detailId,
'contract_id' => $contractId,
'contract_code' => $contract->code_contract,
'concepto' => $desc,
'payment_id' => $payment->id ?? null,
]);
// Adjust or delete the payment record
if ($payment) {
$receiptData = $payment->receipt_data;
$oldDetailIds = $receiptData['detail_ids'] ?? [];
$oldDetails = $receiptData['details'] ?? [];
// Remove this detail from the arrays
$newDetailIds = array_values(array_filter($oldDetailIds, fn($id) => $id != $detailId));
$newDetails = array_values(array_filter($oldDetails, fn($d) => ($d['id'] ?? null) != $detailId));
if (empty($newDetailIds)) {
// No more details in this payment — delete it
if ($payment->attachment_path) {
Storage::disk('public')->delete($payment->attachment_path);
}
$payment->delete();
} else {
// Update the payment record with remaining details
$newAmount = array_sum(array_column($newDetails, 'amount'));
$payment->amount = $newAmount;
$payment->receipt_data = [
'detail_ids' => $newDetailIds,
'details' => $newDetails,
];
$payment->save();
}
}
// Recalculate contract payment status
// Only mandatory items (requires_payment=true or colegiatura) affect status
$allPaid = ContractDetail::where('contract_id', $contractId)->where('paid', true)->count();
$mandatoryUnpaid = ContractDetail::where('contract_id', $contractId)
->where('paid', false)
->whereHas('paymentConceptDetail', function ($q) {
$q->where('requires_payment', true)
->orWhere('description', 'LIKE', 'COLEGIATURA%');
})
->count();
if ($allPaid === 0) {
$contract->status_payment_id = $this->contractRepository->getStatus('pending', 'payment');
$contract->paid_at = null;
} elseif ($mandatoryUnpaid > 0) {
$contract->status_payment_id = $this->contractRepository->getStatus('partial', 'payment');
$contract->paid_at = null;
} else {
$contract->status_payment_id = $this->contractRepository->getStatus('completed', 'payment');
$contract->paid_at = now();
}
$contract->save();
DB::commit();
$newStatus = $allPaid === 0 ? 'pending' : ($mandatoryUnpaid > 0 ? 'partial' : 'completed');
Log::info("[Presencial] Reversión completada", [
'detail_id' => $detailId,
'contract_id' => $contractId,
'contract_code' => $contract->code_contract,
'nuevo_estado_pago' => $newStatus,
]);
return [
'reversed_detail_id' => $detailId,
'contract_status' => $newStatus,
];
} catch (Exception $e) {
DB::rollBack();
Log::error('[Presencial] Error revirtiendo detalle', [
'detail_id' => $detailId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Crear invoices en TOKU para conceptos seleccionados y retornar link del portal.
* Guarda los invoice IDs en un PresencialPayment para seguimiento.
*/
public function createTokuPayment($contractId, $detailIds)
{
DB::beginTransaction();
try {
$contract = Contract::with(['financialParent', 'period'])->find($contractId);
if (!$contract) {
throw new Exception("Contrato no encontrado");
}
$parent = $contract->financialParent;
if (!$parent) {
throw new Exception("Apoderado financiero no encontrado");
}
// Get selected unpaid details
$details = ContractDetail::where('contract_id', $contractId)
->whereIn('id', $detailIds)
->where('paid', false)
->with(['paymentConceptDetail', 'student'])
->get();
if ($details->isEmpty()) {
throw new Exception("No hay conceptos pendientes para pagar");
}
// Use contract-level financial parent data (updated by agent/parent in real time).
// The Parents model record is only synced at contract finalization.
$fpRut = $contract->financial_parent_rut ?? $parent->rut;
$fpEmail = $contract->financial_parent_email ?? $parent->email;
$fpName = trim(
($contract->financial_parent_first_name ?? $parent->first_name ?? '') . ' ' .
($contract->financial_parent_last_name ?? $parent->last_name ?? '')
);
$fpPhone = normalizePhoneNumber($contract->financial_parent_mobile ?? $parent->mobile);
// Resolve government_id: use generic RUT for passport parents
$governmentId = str_replace(['.', '-'], '', $fpRut ?? '');
$tokuService = app(ApiTokuService::class);
$config = getConfiguration('toku');
$isPassport = ($parent->document_type ?? 'RUT') === 'PASSPORT';
if ($isPassport) {
$genericRut = $config['generic_rut'] ?? null;
if (empty($genericRut)) {
throw new Exception("Para procesar pagos de apoderados con pasaporte debe configurar un RUT genérico en la configuración de Toku");
}
$governmentId = str_replace(['.', '-'], '', $genericRut);
}
// Ensure parent has a TOKU customer ID
if (empty($parent->code_toku)) {
$customerResponse = $tokuService->createCustomer([
'government_id' => $governmentId,
'external_id' => $fpRut,
'mail' => $fpEmail,
'name' => $fpName,
'phone_number' => $fpPhone,
'send_mail' => false,
]);
$parent->code_toku = $customerResponse['id'] ?? null;
$parent->save();
}
if (empty($parent->code_toku)) {
throw new Exception("No se pudo obtener el customer ID de TOKU");
}
// Get UF rate for CLP conversion (same as processPosPayment)
$ufRate = getUfValue();
// Obtener subscripciones existentes del customer en Toku (una sola llamada)
$existingSubscriptions = [];
try {
$existingSubscriptions = $tokuService->getSubscriptionsByCustomer($parent->code_toku);
} catch (\Exception $e) {
Log::info("[TokuPayment] Sin subscripciones existentes para customer {$parent->code_toku}: " . $e->getMessage());
}
// Helper: resolver RUT limpio para product_id
$cleanRut = function ($rut) {
$clean = str_replace('.', '', $rut ?? '');
if (strpos($clean, '-') !== false) {
$clean = substr($clean, 0, strrpos($clean, '-'));
}
return strtolower($clean);
};
// Cache de subscriptions resueltas por product_id (RUT)
$resolvedSubscriptions = [];
$resolveSubscription = function ($rutForProduct) use (
$tokuService, $parent, $contractId, $existingSubscriptions, &$resolvedSubscriptions
) {
if (isset($resolvedSubscriptions[$rutForProduct])) {
return $resolvedSubscriptions[$rutForProduct];
}
$existing = collect($existingSubscriptions)->firstWhere('product_id', $rutForProduct);
if ($existing) {
$subId = $existing['id'];
$wasExisting = true;
} else {
$response = $tokuService->createSubscription([
'product_id' => $rutForProduct,
'customer' => $parent->code_toku,
'metadata' => [
'contract_id' => $contractId,
'description' => 'Subscription alumno ' . $rutForProduct,
],
]);
$subId = $response['id'] ?? null;
$wasExisting = false;
}
Log::info("[TokuPayment] Subscription resuelta", [
'subscription_id' => $subId,
'product_id' => $rutForProduct,
'customer' => $parent->code_toku,
'fue_existente' => $wasExisting,
]);
$resolvedSubscriptions[$rutForProduct] = $subId;
return $subId;
};
// Crear invoices en TOKU para cada detail
$tokuInvoiceIds = [];
$totalAmount = 0;
$paidDetails = [];
$periodYear = $contract->period->period_year ?? date('Y');
Log::info("[TokuPayment] Iniciando creación de invoices", [
'total_details' => $details->count(),
'detail_ids' => $details->pluck('id')->toArray(),
]);
foreach ($details as $index => $detail) {
$rawDesc = $detail->paymentConceptDetail->description ?? 'Concepto';
if (!mb_check_encoding($rawDesc, 'UTF-8')) {
$rawDesc = mb_convert_encoding($rawDesc, 'UTF-8', 'ISO-8859-1');
}
// Send original currency to TOKU (UF or CLP)
$currencySymbol = $detail->paymentConceptDetail->currency_type->currency_symbol ?? '$';
$tokuCurrency = ($currencySymbol === 'UF') ? 'CLF' : 'CLP';
// Para colegiatura aplica cálculo proporcional según calendario de cuotas
$effectiveAmount = $contract->getEffectiveDetailAmount($detail);
$tokuAmount = ($tokuCurrency === 'CLF')
? round($effectiveAmount, 4)
: (int) $effectiveAmount;
$conceptCode = $detail->paymentConceptDetail->code_toku ?? '';
// Resolver RUT y subscription correcta según si es concepto familiar o de alumno
$isFamilyConcept = $detail->paymentConceptDetail->family_payment ?? false;
$rutClean = $isFamilyConcept
? $cleanRut($fpRut)
: $cleanRut($detail->student->rut ?? '');
$detailSubscriptionId = $resolveSubscription($rutClean);
// external_id determinístico: {concepto}_{año}_{rut}. Toku acepta upsert por
// este valor. El webhook matchea por id_invoice (toku_debt_ids) — ver findDetailForWebhook.
$externalId = $conceptCode . '_' . $periodYear . '_' . $rutClean;
$detail->code_toku = $externalId;
$detail->save();
// Due date: cuota incorporación 2 tiene vencimiento especial
$detailConceptCode = $detail->paymentConceptDetail->code ?? '';
$dueDate = ($detailConceptCode === 'CUOTA_INCORPORACION_2')
? $this->getSecondIncorporationDueDate($contract)
: now()->addDays(7)->format('Y-m-d');
Log::info("[TokuPayment] Creando invoice {$index}/{$details->count()}", [
'detail_id' => $detail->id,
'concept' => $conceptCode,
'concept_code' => $detailConceptCode,
'external_id' => $externalId,
'amount' => $tokuAmount,
'currency' => $tokuCurrency,
'due_date' => $dueDate,
'subscription_id' => $detailSubscriptionId,
'product_id' => $rutClean,
]);
// Crear invoice (deuda) vinculada a la subscription del alumno
$invoiceResponse = $tokuService->createInvoice([
'subscription' => $detailSubscriptionId,
'customer' => $parent->code_toku,
'invoice_external_id' => $externalId,
'amount' => $tokuAmount,
'due_date' => $dueDate,
'currency_code' => $tokuCurrency,
]);
$invoiceId = $invoiceResponse['id'] ?? null;
$tokuInvoiceIds[] = $invoiceId;
Log::info("[TokuPayment] Invoice creado", [
'detail_id' => $detail->id,
'invoice_id' => $invoiceId,
'response_keys' => array_keys($invoiceResponse ?? []),
]);
// Store Toku IDs on the contract detail
$detail->toku_subscription_id = $detailSubscriptionId;
$detail->toku_debt_ids = array_filter([$invoiceId]);
$detail->save();
$entry = [
'id' => $detail->id,
'product_id' => $rutClean,
'description' => $rawDesc,
'amount' => $effectiveAmount,
'currency' => $currencySymbol,
'toku_currency' => $tokuCurrency,
];
// Convert UF to CLP for the total (same as processPosPayment)
if ($currencySymbol === 'UF' && $ufRate > 0) {
$clpAmount = (int) round($effectiveAmount * $ufRate);
$entry['amount_clp'] = $clpAmount;
$totalAmount += $clpAmount;
} else {
$totalAmount += $effectiveAmount;
}
$paidDetails[] = $entry;
}
// Build onetime portal URL with invoice IDs
$filteredInvoiceIds = array_values(array_filter($tokuInvoiceIds));
$portalUrl = $config['url_onetime'] ?? '';
$portalUrl = str_replace('#PARENT_CODE', $parent->code_toku, $portalUrl);
$portalUrl = str_replace('#INVOICE_IDS', json_encode($filteredInvoiceIds), $portalUrl);
// Create tracking record
$payment = PresencialPayment::create([
'contract_id' => $contractId,
'payment_method_id' => null,
'amount' => $totalAmount,
'receipt_data' => [
'detail_ids' => $detailIds,
'details' => $paidDetails,
'uf_rate' => $ufRate,
'toku_subscription_ids' => array_values($resolvedSubscriptions),
'toku_invoice_ids' => array_filter($tokuInvoiceIds),
],
'registered_by' => auth()->id(),
'reconciliation_status' => 'pending',
'subscription_status' => 'pending_toku',
'subscription_link' => $portalUrl,
]);
DB::commit();
return [
'payment_id' => $payment->id,
'portal_url' => $portalUrl,
'toku_invoice_ids' => array_filter($tokuInvoiceIds),
'total_amount' => $totalAmount,
'details' => $paidDetails,
];
} catch (Exception $e) {
DB::rollBack();
Log::error('Error creando pago TOKU: ' . $e->getMessage());
throw $e;
}
}
/**
* Cancelar pago TOKU: void invoices en TOKU y limpiar registro local.
*/
public function cancelTokuPayment($paymentId)
{
DB::beginTransaction();
try {
$payment = PresencialPayment::find($paymentId);
if (!$payment) {
throw new Exception("Registro de pago no encontrado");
}
Log::info("[TokuCancel] Iniciando cancelación", [
'payment_id' => $paymentId,
'contract_id' => $payment->contract_id,
'subscription_status' => $payment->subscription_status,
]);
if ($payment->subscription_status !== 'pending_toku') {
throw new Exception("El pago TOKU no esta pendiente");
}
// Void invoices (deudas) en TOKU — la subscription (alumno) se mantiene
$tokuInvoiceIds = $payment->receipt_data['toku_invoice_ids'] ?? [];
Log::info("[TokuCancel] Invoices a anular", [
'total' => count($tokuInvoiceIds),
'invoice_ids' => $tokuInvoiceIds,
]);
try {
$tokuService = app(ApiTokuService::class);
foreach ($tokuInvoiceIds as $index => $invoiceId) {
if ($invoiceId) {
Log::info("[TokuCancel] Anulando invoice " . ($index + 1) . "/" . count($tokuInvoiceIds), [
'invoice_id' => $invoiceId,
]);
$tokuService->voidInvoice($invoiceId, true);
Log::info("[TokuCancel] Invoice anulado OK: {$invoiceId}");
}
}
} catch (Exception $e) {
Log::warning("[TokuCancel] Error al anular invoices en TOKU (continuando): " . $e->getMessage());
}
// Limpiar toku_debt_ids y code_toku de los contract_details afectados
$detailIds = $payment->receipt_data['detail_ids'] ?? [];
Log::info("[TokuCancel] Limpiando contract_details", [
'detail_ids' => $detailIds,
]);
if (!empty($detailIds)) {
ContractDetail::whereIn('id', $detailIds)->update([
'toku_debt_ids' => null,
'code_toku' => null,
]);
}
// Mark as cancelled and clean up
$payment->subscription_status = 'cancelled';
$payment->subscription_link = null;
$payment->save();
DB::commit();
Log::info("[TokuCancel] Cancelación completada", [
'payment_id' => $paymentId,
'voided_invoices' => count($tokuInvoiceIds),
'cleaned_details' => count($detailIds),
]);
return [
'payment_id' => $payment->id,
'voided_invoices' => count($tokuInvoiceIds),
];
} catch (Exception $e) {
DB::rollBack();
Log::error('Error cancelando pago TOKU: ' . $e->getMessage());
throw $e;
}
}
/**
* Registrar pago presencial
*/
public function registerPayment($contractId, $data)
{
$contract = Contract::find($contractId);
if (!$contract) {
throw new Exception("Contrato no encontrado");
}
$payment = PresencialPayment::create([
'contract_id' => $contractId,
'payment_method' => $data['payment_method'],
'amount' => $data['amount'],
'reference_number' => $data['reference_number'] ?? null,
'receipt_data' => $data['receipt_data'] ?? null,
'notes' => $data['notes'] ?? null,
'registered_by' => auth()->id(),
'reconciliation_status' => 'pending',
]);
return $payment;
}
/**
* Obtener pagos presenciales de un contrato
*/
public function getPayments($contractId)
{
$payments = PresencialPayment::where('contract_id', $contractId)
->with(['registeredByUser', 'paymentMethodRecord'])
->orderBy('created_at', 'desc')
->get();
// Enrich receipt_data that lacks currency or amount_clp for UF concepts
$currentUfRate = null;
foreach ($payments as $payment) {
$receiptData = $payment->receipt_data;
if (!is_array($receiptData) || empty($receiptData['details'])) continue;
$needsEnrich = false;
foreach ($receiptData['details'] as $detail) {
if (!isset($detail['currency']) || (($detail['currency'] ?? '') === 'UF' && !isset($detail['amount_clp']))) {
$needsEnrich = true;
break;
}
}
if (!$needsEnrich) continue;
// Use stored UF rate from payment time; fallback to current rate for legacy records
$ufRate = $receiptData['uf_rate'] ?? null;
if (!$ufRate || $ufRate <= 0) {
if ($currentUfRate === null) {
$currentUfRate = getUfValue();
}
$ufRate = $currentUfRate;
}
$detailIds = array_column($receiptData['details'], 'id');
$contractDetails = ContractDetail::whereIn('id', $detailIds)
->with('paymentConceptDetail.currency_type')
->get()
->keyBy('id');
$enrichedDetails = [];
$recalculatedTotal = 0;
foreach ($receiptData['details'] as $detail) {
// Add currency if missing
if (!isset($detail['currency'])) {
$cd = $contractDetails->get($detail['id']);
$pcd = $cd ? $cd->paymentConceptDetail : null;
$ct = $pcd ? $pcd->currency_type : null;
$detail['currency'] = $ct ? ($ct->currency_symbol ?? '$') : '$';
}
// Add amount_clp for UF concepts if missing
if (($detail['currency'] ?? '') === 'UF' && !isset($detail['amount_clp']) && $ufRate > 0) {
$detail['amount_clp'] = (int) round((float) $detail['amount'] * $ufRate);
}
// Recalculate total based on enriched details
if (($detail['currency'] ?? '') === 'UF') {
$recalculatedTotal += $detail['amount_clp'] ?? 0;
} else {
$recalculatedTotal += $detail['amount'];
}
$enrichedDetails[] = $detail;
}
$receiptData['details'] = $enrichedDetails;
$payment->receipt_data = $receiptData;
// Update payment amount if it was calculated incorrectly (difference > $1 to account for rounding)
if ($recalculatedTotal > 0 && abs($payment->amount - $recalculatedTotal) > 1) {
$payment->amount = $recalculatedTotal;
}
}
return $payments;
}
/**
* Generate a PDF receipt for a presencial payment.
*/
public function generateReceiptPdf($paymentId)
{
$payment = PresencialPayment::with(['contract', 'registeredByUser', 'paymentMethodRecord'])
->find($paymentId);
if (!$payment) {
throw new Exception("Pago no encontrado", 404);
}
$contract = $payment->contract;
// Parent info
$parentName = trim(
($contract->financial_parent_first_name ?? '') . ' ' .
($contract->financial_parent_last_name ?? '')
);
$parentRut = $contract->financial_parent_rut ?? '-';
// Payment method — fallback to TOKU payment instrument info
$tokuInfo = $payment->receipt_data['toku_payment_info'] ?? null;
if ($payment->paymentMethodRecord) {
$paymentMethodName = $payment->paymentMethodRecord->payment_method;
} elseif ($tokuInfo) {
$brand = $tokuInfo['card_brand'] ?? '';
$type = ($tokuInfo['card_type'] ?? '') === 'credit' ? 'Crédito' : (($tokuInfo['card_type'] ?? '') === 'debit' ? 'Débito' : '');
$last = $tokuInfo['last_digits'] ?? '';
$paymentMethodName = trim("$brand $type" . ($last ? " ****$last" : '')) ?: 'TOKU';
} else {
$paymentMethodName = $payment->subscription_status === 'pending_toku' ? 'TOKU (pendiente)' : '-';
}
// Reference — fallback to TOKU authorization code
$referenceNumber = $payment->reference_number ?? ($tokuInfo['authorization_code'] ?? null);
// Registered by
$registeredBy = $payment->registeredByUser
? ($payment->registeredByUser->name ?? $payment->registeredByUser->email ?? '-')
: '-';
// Receipt details with enrichment for legacy data
$receiptData = $payment->receipt_data;
$details = $receiptData['details'] ?? [];
// Enrich details: add missing currency and/or amount_clp for UF concepts
// Use stored UF rate from payment time; fallback to current rate for legacy records
$ufRate = $receiptData['uf_rate'] ?? null;
if (!$ufRate || $ufRate <= 0) {
$ufRate = getUfValue();
}
$needsEnrich = false;
foreach ($details as $detail) {
if (!isset($detail['currency']) || (($detail['currency'] ?? '') === 'UF' && !isset($detail['amount_clp']))) {
$needsEnrich = true;
break;
}
}
if ($needsEnrich) {
$detailIds = array_column($details, 'id');
$contractDetails = ContractDetail::whereIn('id', $detailIds)
->with('paymentConceptDetail.currency_type')
->get()
->keyBy('id');
$enrichedDetails = [];
foreach ($details as $detail) {
// Add currency if missing
if (!isset($detail['currency'])) {
$cd = $contractDetails->get($detail['id']);
$pcd = $cd ? $cd->paymentConceptDetail : null;
$ct = $pcd ? $pcd->currency_type : null;
$detail['currency'] = $ct ? ($ct->currency_symbol ?? '$') : '$';
}
// Add amount_clp for UF concepts if missing
if (($detail['currency'] ?? '') === 'UF' && !isset($detail['amount_clp']) && $ufRate > 0) {
$detail['amount_clp'] = (int) round((float) $detail['amount'] * $ufRate);
}
$enrichedDetails[] = $detail;
}
$details = $enrichedDetails;
}
// Enrich details with student name from contract_details
$detailIds = array_column($details, 'id');
$studentNames = ContractDetail::whereIn('id', $detailIds)
->with('student')
->get()
->keyBy('id');
foreach ($details as &$detail) {
if (!isset($detail['student_name'])) {
$cd = $studentNames->get($detail['id']);
$detail['student_name'] = ($cd && $cd->student)
? trim($cd->student->first_name . ' ' . $cd->student->last_name)
: null;
}
}
unset($detail);
// Aggregate totals by currency for UF subtitle and recalculate CLP total
$ufTotal = 0;
$clpTotal = 0;
$calculatedTotal = 0;
foreach ($details as $d) {
if (($d['currency'] ?? '') === 'UF') {
$ufTotal += (float) $d['amount'];
$calculatedTotal += isset($d['amount_clp']) ? $d['amount_clp'] : 0;
} else {
$clpTotal += (float) $d['amount'];
$calculatedTotal += (float) $d['amount'];
}
}
// Fix legacy records where amount was stored in UF instead of CLP
$displayAmount = ($calculatedTotal > 0 && abs($payment->amount - $calculatedTotal) > 1)
? $calculatedTotal
: $payment->amount;
// Payment status label
$statusLabel = null;
$statusColor = null;
if ($payment->subscription_status) {
$statusMap = [
'paid' => ['PAGADO', 'green'],
'active' => ['ACTIVA', 'green'],
'pending_subscription' => ['POR ACTIVAR', 'amber'],
'pending_toku' => ['PROCESANDO', 'blue'],
'cancelled' => ['CANCELADA', 'red'],
];
$mapped = $statusMap[$payment->subscription_status] ?? ['PENDIENTE', 'amber'];
$statusLabel = $mapped[0];
$statusColor = $mapped[1];
}
$data = [
'payment' => $payment,
'contract' => $contract,
'details' => $details,
'parentName' => $parentName ?: '-',
'parentRut' => $parentRut,
'paymentMethodName' => $paymentMethodName,
'registeredBy' => $registeredBy,
'ufTotal' => $ufTotal,
'clpTotal' => $clpTotal,
'displayAmount' => $displayAmount,
'statusLabel' => $statusLabel,
'statusColor' => $statusColor,
];
$pdf = FacadePdf::loadView('pdfs.payment_receipt', $data);
$filename = 'comprobante_pago_' . str_pad($payment->id, 6, '0', STR_PAD_LEFT) . '.pdf';
return $pdf->stream($filename);
}
/**
* Reporte de conciliación de pagos presenciales
*/
public function getReconciliationReport($filters = [])
{
$query = PresencialPayment::with(['contract', 'registeredByUser']);
if (!empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
if (!empty($filters['status'])) {
$query->where('reconciliation_status', $filters['status']);
}
return $query->orderBy('created_at', 'desc')->paginate(50);
}
/**
* Notifica al apoderado financiero que se registró un pago manualmente.
* Si quedan conceptos pendientes o falta firma, invita a retomar el proceso.
* Errores de envío se loguean pero no propagan (el pago ya está registrado).
*/
protected function sendPaymentRegisteredMail($contract, $details, $paymentMethodRecord, $totalAmount, $referenceNumber, $mandatoryUnpaid)
{
try {
$email = $contract->financial_parent_email;
if (empty($email)) {
Log::warning('[Presencial] No se envía correo: apoderado sin email', [
'contract_id' => $contract->id,
]);
return;
}
$name = trim(
($contract->financial_parent_first_name ?? '') . ' ' .
($contract->financial_parent_last_name ?? '')
);
// Alumnos únicos implicados en los details pagados
$studentNames = $details->map(function ($d) {
return trim(($d->student_first_name ?? '') . ' ' . ($d->student_last_name ?? ''));
})->filter()->unique()->values()->implode(', ');
// Conceptos pagados
$conceptNames = $details->map(function ($d) {
$desc = $d->paymentConceptDetail->description ?? '';
if (!mb_check_encoding($desc, 'UTF-8')) {
$desc = mb_convert_encoding($desc, 'UTF-8', 'ISO-8859-1');
}
return $desc;
})->filter()->unique()->values()->implode(', ');
// Mensaje de acción pendiente
$contract->load('statusSignature', 'statusContract');
$signatureCode = $contract->statusSignature->code ?? null;
$contractCode = $contract->statusContract->code ?? null;
$pendingMessage = null;
if ($mandatoryUnpaid > 0) {
$pendingMessage = 'Aún existen conceptos pendientes por regularizar en esta matrícula. Te invitamos a ingresar al portal para completar el proceso.';
} elseif (in_array($signatureCode, ['pending', 'sent', 'pending_manual'])) {
$pendingMessage = 'El pago fue registrado correctamente. Para finalizar tu matrícula, aún debes <strong>firmar el contrato</strong>. Ingresa al portal para completar este paso.';
} elseif ($contractCode && !in_array($contractCode, ['finished', 'canceled'])) {
$pendingMessage = 'El pago fue registrado correctamente. Ingresa al portal para revisar si quedan pasos pendientes en tu proceso de matrícula.';
}
$portalUrl = rtrim(env('FRONTEND_URL', ''), '/') . '/dashboard/matriculas/' . $contract->code_contract;
$mailService = new MailService();
$mailService->paymentRegistered((object)[
'email' => $email,
'name' => $name,
'contract_code' => $contract->code_contract,
'students' => $studentNames,
'concepts' => $conceptNames,
'amount' => '$' . number_format($totalAmount, 0, ',', '.'),
'payment_method' => $paymentMethodRecord->payment_method ?? null,
'paid_at' => now()->format('d-m-Y H:i'),
'reference_number' => $referenceNumber,
'pending_message' => $pendingMessage,
'portal_url' => $pendingMessage ? $portalUrl : null,
]);
Log::info('[Presencial] Correo de pago registrado enviado', [
'contract_id' => $contract->id,
'email' => $email,
]);
} catch (Exception $e) {
Log::error('[Presencial] Error enviando correo de pago registrado', [
'contract_id' => $contract->id ?? null,
'error' => $e->getMessage(),
]);
}
}
/**
* Obtener fecha de vencimiento para la 2da cuota de incorporación.
* Actualmente: 31 de octubre del año siguiente al created_at del contrato.
*/
private function getSecondIncorporationDueDate(Contract $contract)
{
$baseYear = Carbon::parse($contract->created_at)->year;
return Carbon::create($baseYear + 1, 10, 31)->format('Y-m-d');
}
}