HEX
Server: Apache/2.4.58 (Ubuntu)
System: Linux Bradford-Sitios 6.14.0-1017-azure #17~24.04.1-Ubuntu SMP Mon Dec 1 20:10:50 UTC 2025 x86_64
User: www-data (33)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: /var/www/matriculas_api_dev/app/Services/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');
    }
}