Tul xxx Tul
User / IP
:
216.73.216.146
Host / Server
:
45.84.207.204 / aircan.me
System
:
Linux lt-bnk-web1726.main-hosting.eu 5.14.0-611.36.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Mar 3 11:23:52 EST 2026 x86_64
Command
|
Upload
|
Create
Mass Deface
|
Jumping
|
Symlink
|
Reverse Shell
Ping
|
Port Scan
|
DNS Lookup
|
Whois
|
Header
|
cURL
:
/
home
/
u931257429
/
domains
/
aircan.me
/
public_html
/
siscapslaurel
/
controllers
/
Viewing: FacturasController.php
<?php use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; class FacturasController { private function ensureCsrf(): string { if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf']; } private function safeStrLen(string $value): int { return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); } private function safeSubstr(string $value, int $start, int $length): string { return function_exists('mb_substr') ? mb_substr($value, $start, $length) : substr($value, $start, $length); } private function normalizeDateInput(?string $value): string { $value = trim((string)$value); if ($value === '') { return ''; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { return $value; } if (preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $value)) { $dt = DateTime::createFromFormat('d/m/Y', $value); if ($dt instanceof DateTime) { return $dt->format('Y-m-d'); } } $ts = strtotime($value); return $ts !== false ? date('Y-m-d', $ts) : $value; } private function canRenderPdf(): bool { return class_exists('Dompdf\Dompdf'); } private function sanitizeHtmlForPdf(string $html): string { return $html; } private function checkCsrfPost(): bool { $token = $_POST['csrf'] ?? ''; return $token && isset($_SESSION['csrf']) && hash_equals($_SESSION['csrf'], $token); } private function getNextInvoiceSequence(PDO $pdo, string $issueDate): array { $ts = strtotime($issueDate); if ($ts === false) { $ts = time(); } $ym = date('Ym', $ts); $prefix = 'INV-' . $ym . '-'; $stmt = $pdo->prepare("SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(invoice_number, '-', -1) AS UNSIGNED)), 0) FROM invoices WHERE invoice_number LIKE :prefix"); $stmt->execute([ ':prefix' => $prefix . '%', ]); return [ 'prefix' => $prefix, 'seq' => (int)$stmt->fetchColumn(), ]; } private function createInvoiceFromBatch(PDO $pdo, array $data, array $lines, array &$invoiceSequence): int { $invoiceSequence['seq']++; $invoiceNumber = $invoiceSequence['prefix'] . str_pad((string)$invoiceSequence['seq'], 5, '0', STR_PAD_LEFT); $pdo->beginTransaction(); try { $invoiceCols = $pdo->query("SHOW COLUMNS FROM invoices")->fetchAll(PDO::FETCH_COLUMN, 0); $supportsReadingId = in_array('reading_id', $invoiceCols, true); $ins = $pdo->prepare( $supportsReadingId ? "INSERT INTO invoices (invoice_number, customer_id, reading_id, period_start, period_end, issue_date, due_date, consumption_m3, tariff_id, category, subtotal, tax, total, status, notes) VALUES (:invoice_number, :customer_id, :reading_id, :period_start, :period_end, :issue_date, :due_date, :consumption_m3, :tariff_id, :category, :subtotal, :tax, :total, :status, :notes)" : "INSERT INTO invoices (invoice_number, customer_id, period_start, period_end, issue_date, due_date, consumption_m3, tariff_id, category, subtotal, tax, total, status, notes) VALUES (:invoice_number, :customer_id, :period_start, :period_end, :issue_date, :due_date, :consumption_m3, :tariff_id, :category, :subtotal, :tax, :total, :status, :notes)" ); $params = [ ':invoice_number' => $invoiceNumber, ':customer_id' => (int)$data['customer_id'], ':period_start' => $data['period_start'] ?? null, ':period_end' => $data['period_end'] ?? null, ':issue_date' => $data['issue_date'], ':due_date' => $data['due_date'], ':consumption_m3' => (float)$data['consumption_m3'], ':tariff_id' => $data['tariff_id'] ?? null, ':category' => $data['category'] ?? 'Servicio', ':subtotal' => (float)$data['subtotal'], ':tax' => (float)($data['tax'] ?? 0.0), ':total' => (float)$data['total'], ':status' => $data['status'] ?? 'Pendiente', ':notes' => $data['notes'] ?? null, ]; if ($supportsReadingId) { $params[':reading_id'] = isset($data['reading_id']) ? (int)$data['reading_id'] : null; } $ins->execute($params); $invoiceId = (int)$pdo->lastInsertId(); $lineIns = $pdo->prepare("INSERT INTO invoice_lines (invoice_id, description, quantity, unit_price, type) VALUES (:invoice_id, :description, :quantity, :unit_price, :type)"); foreach ($lines as $ln) { $lineIns->execute([ ':invoice_id' => $invoiceId, ':description' => $ln['description'] ?? 'Consumo', ':quantity' => (float)$ln['quantity'], ':unit_price' => (float)$ln['unit_price'], ':type' => $ln['type'] ?? 'Consumo', ]); } $pdo->commit(); return $invoiceId; } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } } public function index(): void { $csrf = $this->ensureCsrf(); $systemData = SystemData::get(); $cutAlertDays = isset($systemData['cut_alert_days']) ? max(0, (int)$systemData['cut_alert_days']) : 0; $lateFeeGraceDays = isset($systemData['late_fee_grace_days']) ? max(0, (int)$systemData['late_fee_grace_days']) : 0; $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'sector' => trim((string)($_GET['sector'] ?? '')), 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = max(1, min(100, (int)($_GET['perPage'] ?? 15))); $result = Invoice::getAll($filters, $page, $perPage); $items = $result['items']; $total = $result['total']; $lastPage = $result['lastPage']; $customers = Customer::getActive(); $statuses = ['Pendiente','Pagado','Parcial','Vencida','Mora','Anulada']; $categoryRows = InvoiceCategory::getAll(false); $categories = array_values(array_filter(array_map(static fn($r) => (string)($r['value'] ?? ''), $categoryRows), static fn($v) => $v !== '')); $categoryLabels = InvoiceCategory::labelMap(true); require __DIR__ . '/../views/facturas/index.php'; } public function generate(): void { $csrf = $this->ensureCsrf(); require __DIR__ . '/../views/facturas/generate.php'; } public function create(): void { $csrf = $this->ensureCsrf(); $categories = InvoiceCategory::getAll(false); $statuses = ['Pendiente','Pagado','Parcial','Vencida','Mora','Anulada']; $invoice = [ 'customer_id' => '', 'issue_date' => date('Y-m-d'), 'due_date' => Invoice::calculateDueDate(date('Y-m-d')), 'status' => 'Pendiente', 'category' => 'Servicio', 'notes' => '', 'line' => [ 'description' => '', 'quantity' => 1, 'unit_price' => 0.00, ], ]; $prefillCustomer = null; $selectedCustomerId = isset($_GET['customer_id']) ? (int)$_GET['customer_id'] : 0; if ($selectedCustomerId > 0) { $customerRow = Customer::findById($selectedCustomerId); if ($customerRow) { $invoice['customer_id'] = (int)$customerRow['id']; $prefillCustomer = [ 'id' => (int)$customerRow['id'], 'name' => (string)($customerRow['name'] ?? ''), 'code' => (string)($customerRow['customer_code'] ?? ''), ]; } } require __DIR__ . '/../views/facturas/create.php'; } public function store(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.create'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $customer_id = (int)($_POST['customer_id'] ?? 0); $category = trim($_POST['category'] ?? ''); $description = trim($_POST['description'] ?? ''); $quantity = (float)($_POST['quantity'] ?? 1); $unit_price = (float)($_POST['unit_price'] ?? 0); $issue_date = $_POST['issue_date'] ?? date('Y-m-d'); $due_date = trim((string)($_POST['due_date'] ?? '')); if ($due_date === '') { $due_date = Invoice::calculateDueDate($issue_date); } $status = trim($_POST['status'] ?? 'Pendiente'); $notes = trim($_POST['notes'] ?? ''); $tax = (float)($_POST['tax'] ?? 0); if (!InvoiceCategory::existsValue($category, false)) { $_SESSION['flash_error'] = 'Categoría inválida.'; redirect('facturas.create'); } if ($customer_id <= 0) { $_SESSION['flash_error'] = 'Debes seleccionar un cliente.'; redirect('facturas.create'); } $customer = Customer::findById($customer_id); if (!$customer) { $_SESSION['flash_error'] = 'El cliente seleccionado no existe.'; redirect('facturas.create'); } if ($description === '') { $_SESSION['flash_error'] = 'La descripción es obligatoria.'; redirect('facturas.create'); } if ($quantity <= 0 || $unit_price <= 0) { $_SESSION['flash_error'] = 'La cantidad y el precio unitario deben ser mayores a 0.'; redirect('facturas.create'); } if ($tax < 0) { $_SESSION['flash_error'] = 'Los impuestos no pueden ser negativos.'; redirect('facturas.create'); } $allowedStatuses = ['Pendiente','Pagado','Parcial','Vencida','Mora','Anulada']; if (!in_array($status, $allowedStatuses, true)) { $status = 'Pendiente'; } if ($issue_date && $due_date && strtotime($due_date) < strtotime($issue_date)) { $_SESSION['flash_error'] = 'La fecha de vencimiento no puede ser anterior a la fecha de emisión.'; redirect('facturas.create'); } $subtotal = $quantity * $unit_price; $total = $subtotal + $tax; try { $invoiceId = Invoice::createManual([ 'customer_id' => $customer_id, 'issue_date' => $issue_date, 'due_date' => $due_date, 'status' => $status, 'notes' => $notes !== '' ? $notes : null, 'category' => $category, 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, ], [ [ 'description' => $description, 'quantity' => $quantity, 'unit_price' => $unit_price, 'type' => $category, ], ]); Invoice::refreshLateFeeForInvoice((int)$invoiceId); $_SESSION['flash_success'] = 'Factura creada correctamente.'; header('Location: ' . BASE_URL . '?route=facturas.show&id=' . $invoiceId); exit; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo crear la factura: ' . $e->getMessage(); redirect('facturas.create'); } } public function generateFromReadings(): void { ini_set('memory_limit', '2048M'); set_time_limit(0); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { header('Location: ' . BASE_URL . '?route=facturas.generate'); return; } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $period_start = $_POST['period_start'] ?? ''; $period_end = $_POST['period_end'] ?? ''; $period_start = $this->normalizeDateInput($period_start); $period_end = $this->normalizeDateInput($period_end); if (!$period_start || !$period_end) { $_SESSION['flash_error'] = 'Debe seleccionar periodo inicio y fin.'; redirect('facturas.generate'); } $pdo = (new Database())->getConnection(); try { $defaultIssueDate = $period_start; $defaultPeriodEnd = $period_end; $invoiceCols = $pdo->query("SHOW COLUMNS FROM invoices")->fetchAll(PDO::FETCH_COLUMN, 0); $supportsReadingId = in_array('reading_id', $invoiceCols, true); // Buscar lecturas pendientes de facturar (por lectura, sin agrupar por cliente) $sql = "SELECT r.id, r.customer_id, r.period_start, r.period_end, r.consumption_m3, r.previous_reading, r.reading_value FROM readings r JOIN customers c ON c.id = r.customer_id WHERE c.status IN ('Activo', 'Suspendido') AND EXISTS (SELECT 1 FROM contracts ct WHERE ct.customer_id = c.id) AND r.period_start IS NOT NULL AND r.period_end IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM invoices i WHERE i.status != 'Anulada' AND ( (" . ($supportsReadingId ? "i.reading_id = r.id" : "1 = 0") . ") OR ( i.customer_id = r.customer_id AND i.period_start = r.period_start AND i.period_end = r.period_end ) ) ) ORDER BY r.period_end ASC, r.id ASC"; $stmt = $pdo->prepare($sql); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if (!$rows) { $_SESSION['flash_error'] = 'No se encontraron lecturas pendientes de facturar.'; redirect('facturas.generate'); } $invoiceSequences = []; $created = 0; $skipped = 0; $errors = 0; $errorDetails = []; foreach ($rows as $row) { $rid = (int)($row['id'] ?? 0); $cid = (int)($row['customer_id'] ?? 0); if ($cid <= 0) { $skipped++; continue; } $rps = trim((string)($row['period_start'] ?? '')); $rpe = trim((string)($row['period_end'] ?? '')); if ($rps === '' || $rpe === '') { $skipped++; continue; } $cons = (int)round((float)($row['consumption_m3'] ?? 0)); if ($cons <= 0) { $readingValue = (int)($row['reading_value'] ?? 0); $previousReading = (int)($row['previous_reading'] ?? 0); $cons = max(0, $readingValue - $previousReading); } if ($cons <= 0) { $skipped++; continue; } $readingIssueDate = $rps ?: $defaultIssueDate; $readingDueDate = Invoice::calculateDueDateForPeriod($rpe ?: $defaultPeriodEnd, $readingIssueDate); $tariffDate = $rpe ?: $defaultPeriodEnd; $tariffId = Invoice::getActiveTariffIdForDate($tariffDate); if (!$tariffId) { $errors++; $errorDetails[] = "Lectura {$rid} (cliente {$cid}): no existe una tarifa activa para la fecha {$tariffDate}."; continue; } $custTariffId = Customer::getTariffId($cid); $tariffToUse = $tariffId; if ($custTariffId) { if (!Tariff::isUsableForDate((int)$custTariffId, $tariffDate)) { $errors++; $errorDetails[] = "Lectura {$rid} (cliente {$cid}, tarifa {$custTariffId}): la tarifa asignada no es válida para el periodo."; continue; } $tariffToUse = (int)$custTariffId; } if (!$tariffToUse || !Tariff::isUsableForDate((int)$tariffToUse, $tariffDate)) { $errors++; $errorDetails[] = "Lectura {$rid} (cliente {$cid}, tarifa {$tariffToUse}): no existe una tarifa válida para el periodo."; continue; } if (class_exists('TariffApproval') && method_exists('TariffApproval', 'hasActiveApproval')) { if (!TariffApproval::hasActiveApproval((int)$tariffToUse, $tariffDate)) { $errors++; $errorDetails[] = "Cliente {$cid} (tarifa {$tariffToUse}): la tarifa no tiene una aprobación vigente para el periodo seleccionado."; continue; } } try { $calcYear = (int)date('Y', strtotime($tariffDate)); $calc = Invoice::calculateAmount($cons, $tariffToUse, $calcYear); } catch (Throwable $e) { $errors++; $errorDetails[] = "Cliente {$cid} (tarifa {$tariffToUse}, consumo {$cons}): " . $e->getMessage(); continue; } // Usar el periodo real de la lectura para la factura $data = [ 'customer_id' => $cid, 'reading_id' => $rid, 'period_start' => $rps, 'period_end' => $rpe, 'issue_date' => $readingIssueDate, 'due_date' => $readingDueDate, 'consumption_m3' => $cons, 'tariff_id' => $tariffToUse, 'subtotal' => $calc['subtotal'], 'tax' => $calc['tax'], 'total' => $calc['total'], 'status' => 'Pendiente', 'notes' => null, ]; try { $dupSql = "SELECT id FROM invoices WHERE status != 'Anulada' AND ("; if ($supportsReadingId) { $dupSql .= "reading_id = :reading_id OR "; } $dupSql .= "(customer_id = :customer_id AND period_start = :period_start AND period_end = :period_end)) ORDER BY id DESC LIMIT 1"; $dupStmt = $pdo->prepare($dupSql); $dupParams = [ ':customer_id' => $cid, ':period_start' => $rps, ':period_end' => $rpe, ]; if ($supportsReadingId) { $dupParams[':reading_id'] = $rid; } $dupStmt->execute($dupParams); if ($dupStmt->fetchColumn()) { $skipped++; continue; } $seqKey = date('Ym', strtotime($readingIssueDate)); if (!isset($invoiceSequences[$seqKey])) { $invoiceSequences[$seqKey] = $this->getNextInvoiceSequence($pdo, $readingIssueDate); } $invoiceId = $this->createInvoiceFromBatch($pdo, $data, $calc['lines'], $invoiceSequences[$seqKey]); Invoice::refreshLateFeeForInvoice($invoiceId, $pdo); $created++; } catch (Throwable $e) { $errors++; $errorDetails[] = "Lectura {$rid} (cliente {$cid}, tarifa {$tariffToUse}, consumo {$cons}) [guardar factura]: " . $e->getMessage(); } } $_SESSION['flash_success'] = "Generación completada: creadas {$created}, omitidas {$skipped}, con error {$errors}."; if ($errors > 0 && !empty($errorDetails)) { $max = 3; $shown = array_slice($errorDetails, 0, $max); $more = count($errorDetails) > $max ? (' | ... y ' . (count($errorDetails) - $max) . ' más') : ''; $_SESSION['flash_error'] = 'Detalle de error: ' . implode(' | ', $shown) . $more; } redirect('facturas.index'); } catch (Throwable $e) { $_SESSION['flash_error'] = 'Error al generar facturas: ' . $e->getMessage(); redirect('facturas.generate'); } } public function show(): void { $csrf = $this->ensureCsrf(); $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } $customerIdForWarn = (int)($invoice['customer_id'] ?? 0); $openInvoicesCount = $customerIdForWarn > 0 ? Invoice::countOpenInvoicesByCustomer($customerIdForWarn) : 0; $openBalance = $customerIdForWarn > 0 ? Invoice::openBalanceByCustomer($customerIdForWarn) : 0.0; $systemData = SystemData::get(); $cutAlertDays = isset($systemData['cut_alert_days']) ? max(0, (int)$systemData['cut_alert_days']) : 0; $rawCat = (string)($invoice['category'] ?? ''); $invoice['category_label'] = InvoiceCategory::labelFor($rawCat); require __DIR__ . '/../views/facturas/show.php'; } public function downloadPdf(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } $html = $this->renderInvoiceHtml($invoice); if ($this->canRenderPdf()) { try { $html = $this->sanitizeHtmlForPdf($html); while (ob_get_level() > 0) { ob_end_clean(); } if (headers_sent($file, $line)) { http_response_code(500); echo 'No se pudo iniciar la descarga del PDF porque ya se enviaron datos de salida.'; return; } $dompdf = new Dompdf\Dompdf(); $dompdf->loadHtml($html); $dompdf->setPaper('A4'); $dompdf->render(); $dompdf->stream('Factura-' . ($invoice['invoice_number'] ?? $invoice['id']) . '.pdf', ['Attachment' => true]); exit; } catch (Throwable $e) { error_log('downloadPdf Dompdf error invoice_id=' . $id . ' msg=' . $e->getMessage()); } } header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function printInvoice(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('Content-Type: text/html; charset=UTF-8'); $html = $this->renderInvoiceHtml($invoice); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function printTicket(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('Content-Type: text/html; charset=UTF-8'); $paperHeight = null; $html = $this->renderInvoiceTicketHtml($invoice, $paperHeight); $extraCss = '<style> @media screen { html { background: #525659; } body { background: #fff; box-shadow: 0 4px 16px rgba(0,0,0,0.35); border-radius: 4px; margin: 30px auto !important; } } @media print { @page { size: 58mm auto; margin: 0; } body { box-shadow: none; border-radius: 0; margin: 0 auto !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } } </style>'; $html = preg_replace('/<\/head>/i', $extraCss . '</head>', $html, 1) ?: $html; echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function downloadTicket(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } $paperHeight = null; $html = $this->renderInvoiceTicketHtml($invoice, $paperHeight); $extraCss = '<style> @media screen { html, body { width: 100% !important; } body { font-size: 12px !important; line-height: 1.3 !important; max-width: none !important; } .small { font-size: 10px !important; } .center .b { font-size: 13px !important; } .label { min-width: 55% !important; } } @media print { @page{ size: auto; margin: 6mm 6mm; } html, body { width: 100% !important; } body { font-size: 12px !important; line-height: 1.3 !important; max-width: none !important; } .small { font-size: 10px !important; } .center .b { font-size: 13px !important; } .label { min-width: 55% !important; } } </style>'; $html = preg_replace('/<\/head>/i', $extraCss . '</head>', $html, 1) ?: $html; if ($this->canRenderPdf()) { try { $html = $this->sanitizeHtmlForPdf($html); while (ob_get_level() > 0) { ob_end_clean(); } if (headers_sent($file, $line)) { http_response_code(500); echo 'No se pudo iniciar la descarga del ticket porque ya se enviaron datos de salida.'; return; } header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); $width = 162; $minHeight = 170; $maxHeight = 2000; $height = (int)($paperHeight ?: 520); $height = max($minHeight, min($maxHeight, $height)); $renderWithHeight = static function (string $html, int $width, int $height): array { $d = new \Dompdf\Dompdf(); $d->loadHtml($html); $d->setPaper([0, 0, $width, $height], 'portrait'); $d->render(); $pages = (int)$d->getCanvas()->get_page_count(); return [$d, $pages]; }; $dompdf = null; $attemptsUp = 0; while (true) { list($dompdf, $pages) = $renderWithHeight($html, $width, $height); if ($pages <= 1 || $attemptsUp >= 4 || $height >= $maxHeight) { break; } $height = min($maxHeight, (int)ceil($height * 1.35)); $attemptsUp++; } if (isset($pages) && (int)$pages <= 1) { $low = $minHeight; $high = $height; for ($i = 0; $i < 10 && $low < $high; $i++) { $mid = (int)floor(($low + $high) / 2); list($tmpDompdf, $midPages) = $renderWithHeight($html, $width, $mid); if ($midPages <= 1) { $high = $mid; } else { $low = $mid + 1; } } $finalHeight = (int)max($minHeight, min($maxHeight, $high + 2)); list($dompdf, $tmpPages) = $renderWithHeight($html, $width, $finalHeight); } $dompdf->stream('Ticket-Factura-' . ($invoice['invoice_number'] ?? $invoice['id']) . '.pdf', ['Attachment' => true]); exit; } catch (Throwable $e) { error_log('downloadTicket Dompdf error invoice_id=' . $id . ' msg=' . $e->getMessage()); } } header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function markAsPaid(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } Invoice::refreshLateFeeForInvoice($id); $pdo = (new Database())->getConnection(); $stmt = $pdo->prepare('SELECT total FROM invoices WHERE id = :id LIMIT 1'); $stmt->execute([':id' => $id]); $total = (float)($stmt->fetchColumn() ?: 0); $stmt = $pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM payments WHERE invoice_id = :id'); $stmt->execute([':id' => $id]); $paid = (float)$stmt->fetchColumn(); $status = $paid >= $total && $total > 0 ? 'Pagado' : ($paid > 0 ? 'Parcial' : 'Pendiente'); Invoice::updateStatus($id, $status); $_SESSION['flash_success'] = 'Estado actualizado a ' . $status . '.'; redirect('facturas.index'); } public function edit(): void { $csrf = $this->ensureCsrf(); $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $categories = InvoiceCategory::getAll(false); $isManual = empty($invoice['period_start']) && empty($invoice['period_end']) && empty($invoice['tariff_id']); $statuses = ['Pendiente','Pagado','Parcial','Vencida','Mora','Anulada']; require __DIR__ . '/../views/facturas/edit.php'; } public function update(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } $invoice = Invoice::findById($id); if (!$invoice) { $_SESSION['flash_error'] = 'Factura no encontrada.'; redirect('facturas.index'); } $issue_date = trim($_POST['issue_date'] ?? ''); $due_date = trim($_POST['due_date'] ?? ''); if ($due_date === '' && $issue_date !== '') { $due_date = Invoice::calculateDueDate($issue_date); } $status = trim($_POST['status'] ?? ''); $notes = trim($_POST['notes'] ?? ''); $allowedStatuses = ['Pendiente','Pagado','Parcial','Vencida','Mora','Anulada']; if (!in_array($status, $allowedStatuses, true)) { $status = 'Pendiente'; } $category = (string)($invoice['category'] ?? 'Servicio'); $isManual = empty($invoice['period_start']) && empty($invoice['period_end']) && empty($invoice['tariff_id']); if ($isManual) { $postedCategory = trim((string)($_POST['category'] ?? '')); if ($postedCategory === '' || !InvoiceCategory::existsValue($postedCategory, false)) { $_SESSION['flash_error'] = 'Categoría inválida para la factura.'; header('Location: ' . BASE_URL . '?route=facturas.edit&id=' . $id); exit; } $category = $postedCategory; } try { $pdo = (new Database())->getConnection(); $stmt = $pdo->prepare("UPDATE invoices SET issue_date = :issue_date, due_date = :due_date, status = :status, notes = :notes, category = :category WHERE id = :id"); $stmt->execute([ ':issue_date' => $issue_date ?: null, ':due_date' => $due_date ?: null, ':status' => $status, ':notes' => ($notes !== '') ? $notes : null, ':category' => $category, ':id' => $id, ]); Invoice::refreshLateFeeForInvoice($id, $pdo); $_SESSION['flash_success'] = 'Factura actualizada correctamente.'; header('Location: ' . BASE_URL . '?route=facturas.show&id=' . $id); exit; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo actualizar la factura: ' . $e->getMessage(); header('Location: ' . BASE_URL . '?route=facturas.edit&id=' . $id); exit; } } public function delete(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } try { $ok = Invoice::delete($id); if ($ok) { $_SESSION['flash_success'] = 'Factura eliminada correctamente.'; } else { $_SESSION['flash_error'] = 'No se pudo eliminar la factura.'; } } catch (Throwable $e) { $_SESSION['flash_error'] = 'Error al eliminar la factura: ' . $e->getMessage(); } redirect('facturas.index'); } public function export(): void { requireAuth(['ADMIN','CAJERO']); $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'sector' => trim((string)($_GET['sector'] ?? '')), 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $rows = $this->fetchAllInvoices($filters); header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="facturas.csv"'); echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel $out = fopen('php://output', 'w'); fputcsv($out, ['Nº', 'Cliente', 'Sector', 'Código', 'Periodo', 'Consumo (m3)', 'Subtotal', 'Impuesto', 'Total', 'Estado', 'Emitida', 'Vence']); foreach ($rows as $r) { fputcsv($out, [ $r['invoice_number'], $r['customer_name'] ?? '', $r['customer_sector'] ?? '', $r['customer_code'] ?? '', format_period($r['period_start'] ?? '', $r['period_end'] ?? ''), number_format((float)(int)($r['consumption_m3'] ?? 0), 0, '.', ''), number_format((float)($r['subtotal'] ?? 0), 2, '.', ''), number_format((float)($r['tax'] ?? 0), 2, '.', ''), number_format((float)($r['total'] ?? 0), 2, '.', ''), $r['status'] ?? '', $r['issue_date'] ?? '', $r['due_date'] ?? '', ]); } fclose($out); exit; } public function exportExcel(): void { requireAuth(['ADMIN','CAJERO']); $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'sector' => trim((string)($_GET['sector'] ?? '')), 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $rows = $this->fetchAllInvoices($filters); $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } if (!class_exists(Spreadsheet::class)) { http_response_code(500); echo 'PhpSpreadsheet no está disponible.'; return; } $sheetTitle = 'Facturas'; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle($sheetTitle); $row = 1; $sheet->mergeCells("A{$row}:M{$row}"); $sheet->setCellValue("A{$row}", 'SISCAPS - Reporte de Facturas'); $sheet->getStyle("A{$row}")->getFont()->setBold(true)->setSize(15); $sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $row++; $sheet->mergeCells("A{$row}:M{$row}"); $sheet->setCellValue("A{$row}", 'Generado: ' . date('d/m/Y')); $row++; $sheet->mergeCells("A{$row}:M{$row}"); $sheet->setCellValue("A{$row}", 'Filtros: ' . $this->describeInvoiceFilters($filters)); $row += 2; $headerRow = $row; $headers = [ 'Nº', 'Cliente', 'Sector', 'Código', 'Categoría', 'Estado', 'Periodo', 'Consumo (m³)', 'Subtotal', 'Impuesto', 'Total', 'Emitida', 'Vence', ]; $sheet->fromArray($headers, null, "A{$headerRow}"); $sheet->getStyle("A{$headerRow}:M{$headerRow}")->applyFromArray([ 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], 'fill' => [ 'fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '173e62'], ], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); $row = $headerRow + 1; foreach ($rows as $invoice) { $sheet->fromArray([ $invoice['invoice_number'] ?? '', $invoice['customer_name'] ?? '', $invoice['customer_sector'] ?? '', $invoice['customer_code'] ?? Customer::findById((int)($invoice['customer_id'] ?? 0))['customer_code'] ?? '', $this->formatInvoiceCategory($invoice['category'] ?? ''), $invoice['status'] ?? '', format_period($invoice['period_start'] ?? '', $invoice['period_end'] ?? ''), (int)($invoice['consumption_m3'] ?? 0), (float)($invoice['subtotal'] ?? 0), (float)($invoice['tax'] ?? 0), (float)($invoice['total'] ?? 0), format_date($invoice['issue_date'] ?? ''), format_date($invoice['due_date'] ?? ''), ], null, "A{$row}"); $row++; } if ($row > $headerRow + 1) { $sheet->getStyle("A" . ($headerRow + 1) . ":M" . ($row - 1))->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_HAIR]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getStyle("H" . ($headerRow + 1) . ":H" . ($row - 1)) ->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER); $sheet->getStyle("I" . ($headerRow + 1) . ":K" . ($row - 1)) ->getNumberFormat()->setFormatCode('#,##0.00'); } foreach (range('A', 'M') as $col) { $sheet->getColumnDimension($col)->setAutoSize(true); } $sheet->freezePane('A' . ($headerRow + 1)); $filename = 'Facturas_' . date('Ymd_His') . '.xlsx'; while (ob_get_level() > 0) { ob_end_clean(); } if (headers_sent($file, $line)) { http_response_code(500); echo "No se pudo iniciar la descarga de Excel porque ya se enviaron datos de salida."; return; } header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Cache-Control: max-age=0'); $writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); $writer->save('php://output'); exit; } public function exportPdf(): void { requireAuth(['ADMIN','CAJERO']); ini_set('memory_limit', '2048M'); set_time_limit(300); $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'sector' => trim((string)($_GET['sector'] ?? '')), 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $rows = $this->fetchAllInvoices($filters); $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } $html = $this->renderInvoicesReportHtml($rows, $filters); if (class_exists('Dompdf\Dompdf')) { $html = $this->sanitizeHtmlForPdf($html); while (ob_get_level() > 0) { ob_end_clean(); } if (headers_sent($file, $line)) { http_response_code(500); echo "No se pudo iniciar la descarga del reporte PDF porque ya se enviaron datos de salida."; return; } $dompdf = new Dompdf\Dompdf(); $dompdf->loadHtml($html); $dompdf->setPaper('A4', 'landscape'); $dompdf->render(); $dompdf->stream('Facturas_' . date('Ymd_His') . '.pdf', ['Attachment' => true]); exit; } header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } private function fetchAllInvoices(array $filters): array { $res = Invoice::getAll($filters, 1, 100000); return $res['items'] ?? []; } private function describeInvoiceFilters(array $filters): string { $parts = []; if (!empty($filters['q'])) { $parts[] = 'Búsqueda: "' . $filters['q'] . '"'; } if (!empty($filters['customer_id'])) { $customer = Customer::findById((int)$filters['customer_id']); $parts[] = 'Cliente: ' . ($customer['name'] ?? ('ID ' . $filters['customer_id'])); } if (!empty($filters['sector'])) { $parts[] = 'Sector: ' . $filters['sector']; } if (!empty($filters['status'])) { $parts[] = 'Estado: ' . $filters['status']; } if (!empty($filters['category'])) { $parts[] = 'Categoría: ' . $this->formatInvoiceCategory($filters['category']); } if (!empty($filters['period_start']) || !empty($filters['period_end'])) { $parts[] = 'Periodo: ' . ($filters['period_start'] ?: 'inicio') . ' a ' . ($filters['period_end'] ?: 'hoy'); } if (!empty($filters['issue_from']) || !empty($filters['issue_to'])) { $parts[] = 'Emitida: ' . ($filters['issue_from'] ?: 'inicio') . ' a ' . ($filters['issue_to'] ?: 'hoy'); } return $parts ? implode(' | ', $parts) : 'Sin filtros'; } private function renderInvoicesReportHtml(array $rows, array $filters): string { $generatedAt = date('d/m/Y H:i'); $filtersDesc = $this->describeInvoiceFilters($filters); $systemData = SystemData::get(); $committeeName = trim((string)($systemData['committee_name'] ?? '')); if ($committeeName === '') { $committeeName = 'Comité de Agua Potable y Saneamiento'; } $providerReg = trim((string)($systemData['provider_registration_number'] ?? '')); $municipalCertificate = trim((string)($systemData['municipal_certificate'] ?? '')); $ruc = trim((string)($systemData['ruc_number'] ?? '')); $municipality = trim((string)($systemData['municipality'] ?? '')); $department = trim((string)($systemData['department'] ?? '')); $physicalAddress = trim((string)($systemData['physical_address'] ?? '')); $phone = trim((string)($systemData['phone'] ?? '')); if ($physicalAddress !== '') { $maxAddr = 110; if ($this->safeStrLen($physicalAddress) > $maxAddr) { $physicalAddress = rtrim($this->safeSubstr($physicalAddress, 0, $maxAddr)) . '...'; } } $locationLine = trim(implode(' - ', array_filter([$municipality, $department]))); $regRucLine = trim(implode(' | ', array_filter([ $providerReg !== '' ? ('Reg. prestador: ' . $providerReg) : '', $municipalCertificate !== '' ? ('Cert. municipal: ' . $municipalCertificate) : '', $ruc !== '' ? ('RUC: ' . $ruc) : '', ]))); $contactLine = trim(implode(' | ', array_filter([ $physicalAddress !== '' ? ('Dir.: ' . $physicalAddress) : '', $phone !== '' ? ('Tel.: ' . $phone) : '', ]))); $logoDataUri = null; $logoRel = trim((string)($systemData['logo_path'] ?? '')); if ($logoRel !== '') { $candidatePaths = [ dirname(__DIR__) . '/' . ltrim($logoRel, '/'), dirname(__DIR__) . '/public/' . ltrim($logoRel, '/'), ]; foreach ($candidatePaths as $logoAbs) { if (is_file($logoAbs)) { $ext = strtolower(pathinfo($logoAbs, PATHINFO_EXTENSION)); $mimeMap = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', ]; $mime = $mimeMap[$ext] ?? 'application/octet-stream'; $bin = @file_get_contents($logoAbs); if ($bin !== false) { $logoDataUri = 'data:' . $mime . ';base64,' . base64_encode($bin); break; } } } } $graceDays = 0; if (class_exists('Invoice') && method_exists('Invoice', 'getLateFeeGraceDays')) { $graceDays = (int)Invoice::getLateFeeGraceDays(); } $todayTs = strtotime(date('Y-m-d') . ' 00:00:00'); $customerCodes = []; ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Reporte de Facturas</title> <style> * { box-sizing: border-box; } body { font-family: Arial, Helvetica, sans-serif; font-size: 10px; color:#222; margin: 12px; } .membrete { border-bottom: 1px solid #ddd; padding-bottom: 6px; margin-bottom: 8px; } .membrete-table { width: 100%; border-collapse: collapse; table-layout: auto; } .membrete-logo { width: 64px; vertical-align: top; padding-right: 8px; } .membrete-logo img { width: 64px; height: 64px; object-fit: contain; } .membrete-info { vertical-align: top; } .membrete-right { width: 260px; vertical-align: top; text-align: right; } .membrete-title { font-size: 12px; font-weight: bold; margin: 0; line-height: 1.2; } .membrete-line { font-size: 9px; margin: 0; line-height: 1.2; color: #333; } .membrete-meta { font-size: 9px; color: #555; word-break: break-word; } table { width:100%; border-collapse: collapse; table-layout: fixed; } th, td { border:1px solid #dee2e6; padding:5px 6px; text-align:left; font-size: 9px; vertical-align: top; word-wrap: break-word; } th { background:#f8f9fa; } .right { text-align:right; } </style> </head> <body> <div class="membrete"> <table class="membrete-table"> <tr> <td class="membrete-logo"> <?php if (!empty($logoDataUri)): ?> <img src="<?= htmlspecialchars($logoDataUri) ?>" alt="Logo"> <?php endif; ?> </td> <td class="membrete-info"> <p class="membrete-title"><?= htmlspecialchars($committeeName) ?></p> <?php if ($regRucLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($regRucLine) ?></p> <?php endif; ?> <?php if ($locationLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($locationLine) ?></p> <?php endif; ?> <?php if ($contactLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($contactLine) ?></p> <?php endif; ?> </td> <td class="membrete-right"> <p class="membrete-title">Facturas</p> <div class="membrete-meta"><strong>Total:</strong> <?= count($rows) ?></div> <div class="membrete-meta"><strong>Generado:</strong> <?= htmlspecialchars($generatedAt) ?></div> <div class="membrete-meta"><strong>Filtro:</strong> <?= htmlspecialchars($filtersDesc) ?></div> </td> </tr> </table> </div> <table> <thead> <tr> <th>Nº</th> <th>Cliente</th> <th>Sector</th> <th>Código</th> <th>Categoría</th> <th>Estado</th> <th>Periodo</th> <th class="right">Consumo (m³)</th> <th class="right">Subtotal</th> <th class="right">Impuesto</th> <th class="right">Total</th> <th>Emitida</th> <th>Vence</th> </tr> </thead> <tbody> <?php if (!empty($rows)): foreach ($rows as $invoice): ?> <tr> <td><?= htmlspecialchars($invoice['invoice_number'] ?? '') ?></td> <td><?= htmlspecialchars($invoice['customer_name'] ?? '') ?></td> <td><?= htmlspecialchars($invoice['customer_sector'] ?? '') ?></td> <?php $cid = (int)($invoice['customer_id'] ?? 0); if (!array_key_exists($cid, $customerCodes)) { $cRow = $cid > 0 ? Customer::findById($cid) : null; $customerCodes[$cid] = $cRow ? (string)($cRow['customer_code'] ?? '') : ''; } ?> <td><?= htmlspecialchars($invoice['customer_code'] ?? $customerCodes[$cid] ?? '') ?></td> <td><?= htmlspecialchars($this->formatInvoiceCategory($invoice['category'] ?? '')) ?></td> <?php $statusDisplay = (string)($invoice['status_display'] ?? ''); if ($statusDisplay === '' && class_exists('Invoice') && method_exists('Invoice', 'calculateDisplayStatus')) { try { $statusDisplay = (string)Invoice::calculateDisplayStatus($invoice); } catch (Throwable $e) { $statusDisplay = ''; } } if ($statusDisplay === '') { $statusDisplay = (string)($invoice['status'] ?? ''); } $statusExtra = ''; $dueRaw = trim((string)($invoice['due_date'] ?? '')); $dueTs = ($dueRaw !== '') ? strtotime($dueRaw . ' 00:00:00') : false; if ($todayTs !== false && $dueTs !== false) { if ($statusDisplay === 'Mora') { $moraStartTs = $graceDays > 0 ? strtotime('+' . $graceDays . ' days', $dueTs) : false; $base = $moraStartTs !== false ? $moraStartTs : $dueTs; $days = (int)floor(($todayTs - $base) / 86400); $statusExtra = $days > 0 ? (' (' . $days . ' días)') : ''; } elseif ($statusDisplay === 'Vencida') { $days = (int)floor(($todayTs - $dueTs) / 86400); $statusExtra = $days > 0 ? (' (' . $days . ' días)') : ''; } } $displayStatusLabel = trim($statusDisplay . $statusExtra); ?> <td><?= htmlspecialchars($displayStatusLabel) ?></td> <td><?= htmlspecialchars(format_period($invoice['period_start'] ?? '', $invoice['period_end'] ?? '')) ?></td> <td class="right"><?= format_num((int)($invoice['consumption_m3'] ?? 0), 0) ?></td> <td class="right"><?= format_currency($invoice['subtotal'] ?? 0) ?></td> <td class="right"><?= format_currency($invoice['tax'] ?? 0) ?></td> <td class="right"><?= format_currency($invoice['total'] ?? 0) ?></td> <td><?= htmlspecialchars(format_date($invoice['issue_date'] ?? '')) ?></td> <td><?= htmlspecialchars(format_date($invoice['due_date'] ?? '')) ?></td> </tr> <?php endforeach; else: ?> <tr><td colspan="13" style="text-align:center;">No existen facturas para los filtros aplicados.</td></tr> <?php endif; ?> </tbody> </table> </body> </html> <?php return (string)ob_get_clean(); } private function formatInvoiceCategory(?string $raw): string { return InvoiceCategory::labelFor($raw); } public function categories(): void { $csrf = $this->ensureCsrf(); $categories = InvoiceCategory::getAll(false); require __DIR__ . '/../views/facturas/categories.php'; } public function categoryStore(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.categories'); } if (!$this->checkCsrfPost()) { http_response_code(400); echo 'CSRF inválido'; return; } $label = trim((string)($_POST['label'] ?? '')); if ($label === '') { $_SESSION['flash_error'] = 'Datos inválidos'; redirect('facturas.categories'); } $labelNoAccents = strtr($label, [ 'Ã' => 'A', 'É' => 'E', 'Ã' => 'I', 'Ó' => 'O', 'Ú' => 'U', 'Ñ' => 'N', 'á' => 'a', 'é' => 'e', 'Ã' => 'i', 'ó' => 'o', 'ú' => 'u', 'ñ' => 'n', 'Ü' => 'U', 'ü' => 'u', ]); $parts = preg_split('/[^A-Za-z0-9]+/', $labelNoAccents, -1, PREG_SPLIT_NO_EMPTY) ?: []; $valueBase = ''; foreach ($parts as $p) { $valueBase .= ucfirst(strtolower($p)); } if ($valueBase === '') { $valueBase = 'Categoria'; } if (strlen($valueBase) > 80) { $valueBase = substr($valueBase, 0, 80); } $value = $valueBase; $k = 2; while (InvoiceCategory::existsValue($value, true)) { $suffix = (string)$k; $maxBaseLen = 80 - strlen($suffix); $value = substr($valueBase, 0, $maxBaseLen) . $suffix; $k++; if ($k > 200) { break; } } try { InvoiceCategory::createCategory($value, $label); $_SESSION['flash_success'] = 'Categoría creada.'; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo crear la categorÃa: ' . $e->getMessage(); } redirect('facturas.categories'); } public function categoryUpdate(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.categories'); } if (!$this->checkCsrfPost()) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); $label = trim((string)($_POST['label'] ?? '')); if ($id <= 0 || $label === '') { $_SESSION['flash_error'] = 'Datos inválidos'; redirect('facturas.categories'); } try { $row = InvoiceCategory::findById($id); $value = trim((string)($row['value'] ?? '')); if ($value === '') { throw new RuntimeException('Categoría no encontrada'); } InvoiceCategory::updateCategory($id, $value, $label, 1); $_SESSION['flash_success'] = 'Categoría actualizada.'; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo actualizar: ' . $e->getMessage(); } redirect('facturas.categories'); } public function categoryDelete(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.categories'); } if (!$this->checkCsrfPost()) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.categories'); } try { InvoiceCategory::deactivate($id); $_SESSION['flash_success'] = 'Categoría eliminada.'; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo eliminar: ' . $e->getMessage(); } redirect('facturas.categories'); } private function renderInvoiceHtml(array $inv): string { $lines = $inv['lines'] ?? []; $openInvoicesCount = 0; $openBalance = 0.0; try { $cid = (int)($inv['customer_id'] ?? 0); $openInvoicesCount = Invoice::countOpenInvoicesByCustomer($cid); $openBalance = Invoice::openBalanceByCustomer($cid); } catch (Throwable $e) { /* noop for PDF safety */ } $systemData = SystemData::get(); $committeeName = trim((string)($systemData['committee_name'] ?? '')); if ($committeeName === '') { $committeeName = 'Comité de Agua Potable y Saneamiento'; } $providerReg = trim((string)($systemData['provider_registration_number'] ?? '')); $municipalCertificate = trim((string)($systemData['municipal_certificate'] ?? '')); $ruc = trim((string)($systemData['ruc_number'] ?? '')); $municipality = trim((string)($systemData['municipality'] ?? '')); $department = trim((string)($systemData['department'] ?? '')); $physicalAddress = trim((string)($systemData['physical_address'] ?? '')); $phone = trim((string)($systemData['phone'] ?? '')); if ($physicalAddress !== '') { $maxAddr = 110; if (mb_strlen($physicalAddress) > $maxAddr) { $physicalAddress = rtrim(mb_substr($physicalAddress, 0, $maxAddr)) . '...'; } } $locationLine = trim(implode(' - ', array_filter([$municipality, $department]))); $regRucLine = trim(implode(' | ', array_filter([ $providerReg !== '' ? ('Reg. prestador: ' . $providerReg) : '', $municipalCertificate !== '' ? ('Cert. municipal: ' . $municipalCertificate) : '', $ruc !== '' ? ('RUC: ' . $ruc) : '', ]))); $contactLine = trim(implode(' | ', array_filter([ $physicalAddress !== '' ? ('Dir.: ' . $physicalAddress) : '', $phone !== '' ? ('Tel.: ' . $phone) : '', ]))); $logoDataUri = null; $logoRel = trim((string)($systemData['logo_path'] ?? '')); if ($logoRel !== '') { $logoAbs = dirname(__DIR__) . '/public/' . ltrim($logoRel, '/'); if (is_file($logoAbs)) { $ext = strtolower(pathinfo($logoAbs, PATHINFO_EXTENSION)); $mimeMap = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', ]; $mime = $mimeMap[$ext] ?? 'application/octet-stream'; $bin = @file_get_contents($logoAbs); if ($bin !== false) { $logoDataUri = 'data:' . $mime . ';base64,' . base64_encode($bin); } } } $displayStatus = (string)($inv['status_display'] ?? ''); if ($displayStatus === '' && class_exists('Invoice') && method_exists('Invoice', 'calculateDisplayStatus')) { $displayStatus = (string)Invoice::calculateDisplayStatus($inv); } if ($displayStatus === '') { $displayStatus = (string)($inv['status'] ?? 'Pendiente'); } $statusExtra = ''; $dueRaw = trim((string)($inv['due_date'] ?? '')); $todayTs = strtotime(date('Y-m-d') . ' 00:00:00'); $dueTs = $dueRaw !== '' ? strtotime($dueRaw . ' 00:00:00') : false; $grace = 0; if (class_exists('Invoice') && method_exists('Invoice', 'getLateFeeGraceDays')) { $grace = (int)Invoice::getLateFeeGraceDays(); } if ($todayTs !== false && $dueTs !== false) { if ($displayStatus === 'Mora') { $moraStartTs = $grace > 0 ? strtotime('+' . $grace . ' days', $dueTs) : false; $base = $moraStartTs !== false ? $moraStartTs : $dueTs; $days = (int)floor(($todayTs - $base) / 86400); $statusExtra = $days > 0 ? (' (' . $days . ' días)') : ''; } elseif ($displayStatus === 'Vencida') { $days = (int)floor(($todayTs - $dueTs) / 86400); $statusExtra = $days > 0 ? (' (' . $days . ' días)') : ''; } } $displayStatusLabel = trim($displayStatus . $statusExtra); ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Factura <?= htmlspecialchars($inv['invoice_number'] ?? ('#'.$inv['id'])) ?></title> <style> * { box-sizing: border-box; } body { font-family: Arial, sans-serif; font-size: 12px; color:#222; margin: 12px; } .membrete { border-bottom: 1px solid #ddd; padding-bottom: 6px; margin-bottom: 10px; } .membrete-table { width: 100%; border-collapse: collapse; table-layout: auto; } .membrete-logo { width: 64px; vertical-align: top; padding-right: 8px; } .membrete-logo img { width: 64px; height: 64px; object-fit: contain; } .membrete-info { vertical-align: top; } .membrete-right { width: 220px; vertical-align: top; text-align: right; } .membrete-title { font-size: 12px; font-weight: bold; margin: 0; line-height: 1.2; } .membrete-line { font-size: 9px; margin: 0; line-height: 1.2; color: #333; } .membrete-meta { font-size: 10px; color: #333; } .box { border:1px solid #ccc; padding:10px; border-radius:6px; } table { width:100%; border-collapse: collapse; } th, td { border-bottom:1px solid #eee; padding:6px; text-align:left; } th { background:#f8f9fa; } .right { text-align:right; } .warn { background:#ffe5e5; border:2px solid #dc3545; color:#7a1d25; padding:10px; border-radius:6px; font-weight:bold; } </style> </head> <body> <div class="membrete"> <table class="membrete-table"> <tr> <td class="membrete-logo"> <?php if (!empty($logoDataUri)): ?> <img src="<?= htmlspecialchars($logoDataUri) ?>" alt="Logo"> <?php endif; ?> </td> <td class="membrete-info"> <p class="membrete-title"><?= htmlspecialchars($committeeName) ?></p> <?php if ($regRucLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($regRucLine) ?></p> <?php endif; ?> <?php if ($locationLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($locationLine) ?></p> <?php endif; ?> <?php if ($contactLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($contactLine) ?></p> <?php endif; ?> </td> <td class="membrete-right"> <p class="membrete-title">Factura</p> <div class="membrete-meta"><strong>Nº:</strong> <?= htmlspecialchars($inv['invoice_number'] ?? ('#'.$inv['id'])) ?></div> <div class="membrete-meta"><strong>Emisión:</strong> <?= htmlspecialchars(format_date($inv['issue_date'] ?? '')) ?></div> <div class="membrete-meta"><strong>Vence:</strong> <?= htmlspecialchars(format_date($inv['due_date'] ?? '')) ?></div> <div class="membrete-meta"><strong>Estado:</strong> <?= htmlspecialchars($displayStatusLabel) ?></div> </td> </tr> </table> </div> <?php $threshold = defined('SUSPENSION_THRESHOLD') ? SUSPENSION_THRESHOLD : 2; ?> <?php if ($openInvoicesCount > $threshold): ?> <div class="warn" style="margin:10px 0;"> AVISO DE SUSPENSIÓN DEL SERVICIO: Tiene <?= (int)$openInvoicesCount ?> facturas abiertas. Deuda total: <?= format_currency($openBalance) ?>. Evite la suspensión regularizando su cuenta a la brevedad. </div> <?php endif; ?> <hr> <div class="box"> <strong>Cliente:</strong> <?= htmlspecialchars($inv['customer_name'] ?? '') ?><br> <strong>Dirección:</strong> <?= htmlspecialchars($inv['customer_address'] ?? '') ?><br> <strong>Periodo:</strong> <?= htmlspecialchars(format_period($inv['period_start'] ?? '', $inv['period_end'] ?? '')) ?><br> <strong>Consumo:</strong> <?= format_num((int)($inv['consumption_m3'] ?? 0), 0) ?> m³ </div> <h3>Detalle</h3> <table> <thead><tr><th>Descripción</th><th class="right">Cantidad (m³)</th><th class="right">Precio</th><th class="right">Total</th></tr></thead> <tbody> <?php foreach ($lines as $ln): ?> <tr> <td><?= htmlspecialchars($ln['description'] ?? '') ?></td> <td class="right"><?= format_num(($ln['quantity'] ?? 0), 3) ?></td> <td class="right"><?= format_currency($ln['unit_price'] ?? 0) ?></td> <td class="right"><?= format_currency($ln['line_total'] ?? (($ln['quantity'] ?? 0)*($ln['unit_price'] ?? 0))) ?></td> </tr> <?php endforeach; ?> </tbody> <tfoot> <tr><th colspan="3" class="right">Subtotal</th><th class="right"><?= format_currency($inv['subtotal'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Impuestos</th><th class="right"><?= format_currency($inv['tax'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Recargo por mora</th><th class="right"><?= format_currency($inv['late_fee_amount'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Total</th><th class="right"><?= format_currency($inv['total'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Estado</th><th class="right"><?= htmlspecialchars($displayStatusLabel) ?></th></tr> </tfoot> </table> </body> </html> <?php return (string)ob_get_clean(); } private function renderInvoiceTicketHtml(array $inv, ?int &$paperHeight = null): string { // Datos de apoyo $invoiceNumber = (string)($inv['invoice_number'] ?? ('#'.$inv['id'])); $customerName = (string)($inv['customer_name'] ?? ''); $address = (string)($inv['customer_address'] ?? ''); $sectorCasa = trim((string)($inv['customer_sector'] ?? '')); if ($sectorCasa === '') { $sectorCasa = trim((string)($inv['customer_address'] ?? '')); } $issue = (string)($inv['created_at'] ?? ($inv['issue_date'] ?? '')); $due = (string)($inv['due_date'] ?? ''); $pstart = (string)($inv['period_start'] ?? ''); $pend = (string)($inv['period_end'] ?? ''); $cons = (int)($inv['consumption_m3'] ?? 0); $subtotal = (float)($inv['subtotal'] ?? 0); $total = (float)($inv['total'] ?? 0); $status = (string)($inv['status_display'] ?? ($inv['status'] ?? 'Pendiente')); if (class_exists('Invoice') && method_exists('Invoice', 'calculateDisplayStatus')) { $status = (string)Invoice::calculateDisplayStatus($inv); } $category = (string)($inv['category'] ?? 'Servicio'); $lines = (array)($inv['lines'] ?? []); $statusExtra = ''; $todayTs = strtotime(date('Y-m-d') . ' 00:00:00'); $dueRaw = trim((string)$due); $dueTs = $dueRaw !== '' ? strtotime($dueRaw . ' 00:00:00') : false; $grace = 0; if (class_exists('Invoice') && method_exists('Invoice', 'getLateFeeGraceDays')) { $grace = (int)Invoice::getLateFeeGraceDays(); } if ($todayTs !== false && $dueTs !== false) { if ($status === 'Mora') { $moraStartTs = $grace > 0 ? strtotime('+' . $grace . ' days', $dueTs) : false; $base = $moraStartTs !== false ? $moraStartTs : $dueTs; $days = (int)floor(($todayTs - $base) / 86400); $statusExtra = $days > 0 ? (' (' . $days . ' días)') : ''; } elseif ($status === 'Vencida') { $days = (int)floor(($todayTs - $dueTs) / 86400); $statusExtra = $days > 0 ? (' (' . $days . ' días)') : ''; } } $statusLabel = trim($status . $statusExtra); // Cabecera configurable con fallback del diseño provisto $ORG_NAME = defined('ORG_NAME') ? constant('ORG_NAME') : 'Comité de Agua Potable y Saneamiento'; $ORG_RUC = defined('ORG_RUC') ? constant('ORG_RUC') : 'J08100000052464'; $systemData = class_exists('SystemData') ? SystemData::get() : []; $committeeName = trim((string)($systemData['committee_name'] ?? '')); if ($committeeName === '') { $committeeName = $ORG_NAME; } $providerReg = trim((string)($systemData['provider_registration_number'] ?? '')); $municipalCertificate = trim((string)($systemData['municipal_certificate'] ?? '')); $ruc = trim((string)($systemData['ruc_number'] ?? '')); if ($ruc === '') { $ruc = $ORG_RUC; } $municipality = trim((string)($systemData['municipality'] ?? '')); $department = trim((string)($systemData['department'] ?? '')); $physicalAddress = trim((string)($systemData['physical_address'] ?? '')); $phone = trim((string)($systemData['phone'] ?? '')); $locationLine = trim(implode(' - ', array_filter([$municipality, $department]))); $regLines = array_values(array_filter([ $providerReg !== '' ? ('Reg. prestador: ' . $providerReg) : '', $municipalCertificate !== '' ? ('Cert. municipal: ' . $municipalCertificate) : '', $ruc !== '' ? ('RUC: ' . $ruc) : '', ])); $contactLines = array_values(array_filter([ $physicalAddress !== '' ? ('Dir.: ' . $physicalAddress) : '', $phone !== '' ? ('Tel.: ' . $phone) : '', ])); $logoDataUri = null; $logoRel = trim((string)($systemData['logo_path'] ?? '')); if ($logoRel !== '') { $candidatePaths = [ dirname(__DIR__) . '/' . ltrim($logoRel, '/'), dirname(__DIR__) . '/public/' . ltrim($logoRel, '/'), ]; foreach ($candidatePaths as $logoAbs) { if (is_file($logoAbs)) { $ext = strtolower(pathinfo($logoAbs, PATHINFO_EXTENSION)); $mimeMap = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', ]; $mime = $mimeMap[$ext] ?? 'application/octet-stream'; $bin = @file_get_contents($logoAbs); if ($bin !== false) { $logoDataUri = 'data:' . $mime . ';base64,' . base64_encode($bin); break; } } } } // Lectura vinculada por período/cliente (mejor esfuerzo) $meterNumber = ''; $prevReading = null; $currReading = null; try { $pdo = (new Database())->getConnection(); $cid = (int)($inv['customer_id'] ?? 0); if ($cid > 0 && $pstart && $pend) { $q = $pdo->prepare( "SELECT r.*, m.number AS meter_number FROM readings r LEFT JOIN meters m ON m.id = r.meter_id WHERE r.customer_id = :cid AND r.period_start <= :pend AND r.period_end >= :pstart ORDER BY r.period_end DESC, r.reading_date DESC, r.id DESC LIMIT 1" ); $q->execute([':cid'=>$cid, ':pstart'=>$pstart, ':pend'=>$pend]); if ($row = $q->fetch(PDO::FETCH_ASSOC)) { $meterNumber = (string)($row['meter_number'] ?? ''); $prevReading = isset($row['previous_reading']) ? (int)$row['previous_reading'] : null; $currReading = isset($row['reading_value']) ? (int)$row['reading_value'] : null; } } } catch (Throwable $e) { /* ignore for ticket safety */ } // Cálculos auxiliares $days = 0; if ($pstart && $pend) { $d1 = strtotime($pstart); $d2 = strtotime($pend); if ($d1 && $d2 && $d2 >= $d1) { $days = (int)round(($d2 - $d1) / 86400) + 1; } } $avgUnit = ($cons > 0) ? ($subtotal / $cons) : null; // C$ por m3 // Cuentas por cobrar del cliente (saldo pendiente total) $openInvoicesCount = 0; $openBalance = 0.0; try { $cid2 = (int)($inv['customer_id'] ?? 0); if ($cid2 > 0) { $openInvoicesCount = Invoice::countOpenInvoicesByCustomer($cid2); $openBalance = Invoice::openBalanceByCustomer($cid2); } } catch (Throwable $e) { /* ignore */ } $lateFee = (float)($inv['late_fee_amount'] ?? 0); $baseTotal = (float)($inv['subtotal'] ?? 0) + (float)($inv['tax'] ?? 0); $totalConMora = $baseTotal + max(0.0, $lateFee); // Mes legible (preferentemente del periodo) $months = [ 1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril', 5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto', 9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre', ]; $mesTexto = ''; $srcDate = $pstart ?: ($issue ?: $due); if ($srcDate) { $ts = strtotime($srcDate); if ($ts) { $m = (int)date('n', $ts); $y = date('Y', $ts); $mesTexto = ($months[$m] ?? date('m', $ts)) . ' ' . $y; } } // Concepto (primera línea o categoría) $concepto = $category; foreach ($lines as $ln) { if (!empty($ln['description'])) { $concepto = (string)$ln['description']; break; } } $wrapLines = static function (string $text, int $maxChars): int { $t = trim($text); if ($t === '') { return 0; } $len = function_exists('mb_strlen') ? mb_strlen($t) : strlen($t); return (int)max(1, (int)ceil($len / max(1, $maxChars))); }; $headerLines = 0; $headerLines += $wrapLines($committeeName, 28); foreach ($regLines as $l) { $headerLines += $wrapLines((string)$l, 42); } $headerLines += $wrapLines($locationLine, 42); foreach ($contactLines as $l) { $headerLines += $wrapLines((string)$l, 42); } $bodyLines = 0; $bodyLines += 1; $bodyLines += 1 + $wrapLines($customerName, 30); $bodyLines += 9; $bodyLines += 3; $bodyLines += 1; $bodyLines += 2; $bodyLines += 1; $bodyLines += 1; $bodyLines += 1 + $wrapLines($concepto, 32); $bodyLines += 2; $customerCode = (string)($inv['customer_code'] ?? ''); $logoBonus = !empty($logoDataUri) ? 60 : 0; $paperHeight = (int)max(180, min(2000, (int)ceil(80 + $logoBonus + (($headerLines * 9) + ($bodyLines * 10)) + 90))); ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Factura <?= htmlspecialchars($invoiceNumber) ?></title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } @page { size: 58mm auto; margin: 0; } html { margin: 0; padding: 0; } body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 8px; line-height: 1.3; width: 48mm; margin: 0 auto; padding: 3mm 0; color: #222; background: #fff; overflow-wrap: break-word; word-wrap: break-word; } .thermal-ticket { width: 100%; margin: 0; padding: 0; } .center { text-align: center; } .right { text-align: right; } .left { text-align: left; } .b { font-weight: bold; } .uppercase { text-transform: uppercase; } .header { border-bottom: 1.5px solid #0056b3; padding-bottom: 4px; margin-bottom: 5px; } .logo { max-width: 28mm; max-height: 18mm; margin: 0 auto 3px; display: block; object-fit: contain; } .org-name { color: #0056b3; font-size: 9px; margin-bottom: 2px; line-height: 1.1; } .org-detail { font-size: 6px; color: #555; } .invoice-nr { background: #fdf2f2; color: #d9534f; padding: 3px; border-radius: 2px; font-size: 7.5px; margin: 6px 0; border: 0.5px solid #f8d7da; } .section-title { background: #0056b3; color: #fff; padding: 1.5px 4px; font-size: 7.5px; border-radius: 1.5px; margin: 8px 0 4px; display: block; } .grid { display: table; width: 100%; margin-bottom: 2px; table-layout: fixed; } .row { display: table-row; } .col-label { display: table-cell; width: 55%; color: #666; padding: 1.5px 0; vertical-align: top; overflow-wrap: break-word; } .col-value { display: table-cell; width: 45%; text-align: right; padding: 1.5px 0; vertical-align: top; word-break: break-all; } .total-box { margin-top: 12px; border-top: 1.5px dashed #0056b3; padding-top: 8px; text-align: center; } .total-label { font-size: 9.5px; color: #0056b3; font-weight: bold; } .total-amount { font-size: 16px; color: #000; font-weight: 900; display: block; margin-top: 2px; } .footer-msg { font-size: 6.5px; color: #777; margin-top: 12px; font-style: italic; line-height: 1.4; border-top: 0.5px solid #eee; padding-top: 8px; text-align: center; } .sep-lite { height: 0; border-top: 0.5px solid #eee; margin: 4px 0; } </style> </head> <body> <div class="thermal-ticket"> <div class="center header"> <?php if (!empty($logoDataUri)): ?> <img class="logo" src="<?= htmlspecialchars($logoDataUri) ?>" alt="Logo"> <?php endif; ?> <div class="org-name b uppercase"><?= htmlspecialchars($committeeName) ?></div> <div class="org-detail"> <?php if (!empty($regLines)): foreach ($regLines as $line): ?> <div><?= htmlspecialchars($line) ?></div> <?php endforeach; endif; ?> <?php if ($locationLine !== ''): ?> <div><?= htmlspecialchars($locationLine) ?></div> <?php endif; ?> <?php if (!empty($contactLines)): ?> <div><?= htmlspecialchars(implode(' | ', $contactLines)) ?></div> <?php endif; ?> </div> </div> <div class="b center invoice-nr">FACTURA No. <?= htmlspecialchars($invoiceNumber) ?></div> <div class="grid"> <div class="row"> <div class="col-label" style="width:100%; text-align:left; color:#000;">Nombre Usuario:</div> </div> <div class="row"> <div class="col-value b" style="width:100%; text-align:left; font-size:9.5px;"><?= htmlspecialchars($customerName) ?></div> </div> </div> <div class="grid" style="margin-top:4px;"> <?php if ($customerCode !== ''): ?> <div class="row"> <div class="col-label">Código del cliente:</div> <div class="col-value b"><?= htmlspecialchars($customerCode) ?></div> </div> <?php endif; ?> <?php if ($sectorCasa !== ''): ?> <div class="row"> <div class="col-label">Sector/Casa:</div> <div class="col-value"><?= htmlspecialchars($sectorCasa) ?></div> </div> <?php endif; ?> <div class="row"> <div class="col-label">Mes facturado:</div> <div class="col-value b"><?= htmlspecialchars($mesTexto) ?></div> </div> <div class="row"> <div class="col-label">Fecha de Facturación:</div> <div class="col-value"><?= htmlspecialchars(format_date($issue)) ?></div> </div> <div class="row"> <div class="col-label">Fecha de Vencimiento:</div> <div class="col-value"><?= htmlspecialchars(format_date($due)) ?></div> </div> <div class="row"> <div class="col-label">Estado:</div> <div class="col-value b"><?= htmlspecialchars($statusLabel) ?></div> </div> </div> <div class="section-title uppercase b">Detalle de Lectura</div> <div class="grid"> <div class="row"> <div class="col-label">Lectura Anterior:</div> <div class="col-value"><?= $prevReading!==null ? format_num((int)$prevReading, 0) : '-' ?></div> </div> <div class="row"> <div class="col-label">Lectura Actual:</div> <div class="col-value"><?= $currReading!==null ? format_num((int)$currReading, 0) : '-' ?></div> </div> <div class="row"> <div class="col-label">Consumo (m³):</div> <div class="col-value b"><?= format_num((int)$cons, 0) ?></div> </div> <div class="row"> <div class="col-label">Días facturados:</div> <div class="col-value"><?= (int)$days ?></div> </div> </div> <div class="section-title uppercase b">Resumen de Cuenta</div> <div class="grid"> <div class="row"> <div class="col-label">Total Factura Actual:</div> <div class="col-value"><?= format_currency($baseTotal + max(0.0, $lateFee)) ?></div> </div> <?php if ($openBalance > ($baseTotal + max(0.0, $lateFee) + 0.01)): ?> <div class="row"> <div class="col-label">Saldo Anterior:</div> <div class="col-value"><?= format_currency(max(0.0, $openBalance - ($baseTotal + max(0.0, $lateFee)))) ?></div> </div> <?php endif; ?> </div> <div class="total-box center"> <span class="total-label b uppercase">Total a Pagar</span> <span class="total-amount b"><?= format_currency(max($totalConMora, $openBalance)) ?></span> </div> <div class="footer-msg center"> ¡Gracias por su pago puntual!<br> Mantenlo al día para evitar cortes. </div> </div> </body> </html> <?php return (string)ob_get_clean(); } }
Coded With 💗 by
0x6ick