pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private function buildLogEntry(string $action): array { return [ 'user_id' => $_SESSION['login_user_id'] ?? null, 'dt' => date('Y-m-d H:i:s'), 'login' => isset($_SESSION['otpTime']) ? date('Y-m-d H:i:s', $_SESSION['otpTime']) : null, 'action' => $action, ]; } /** * Generate next sequential document number. * * @param string $doc_type 'invoice' | 'credit_note' | 'debit_note' * @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001" */ private function generateInvoiceNumber(string $doc_type): string { $prefix_map = [ 'invoice' => 'INV', 'credit_note' => 'CN', 'debit_note' => 'DN', ]; $prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-'; $sth = $this->pdo->prepare( "SELECT invoice_number FROM td_invoice WHERE company_id = :company_id AND doc_type = :doc_type AND invoice_number LIKE :prefix ORDER BY invoice_number DESC LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':doc_type' => $doc_type, ':prefix' => $prefix . '%', ]); $last = $sth->fetchColumn(); $seq = $last ? ((int)substr($last, -4) + 1) : 1; return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── /** * Return all invoices for the company ordered by id DESC. * Optionally filter by order_id or doc_type. * * @param int $order_id Filter by td_order.id (0 = all) * @param string $doc_type Filter by doc_type ('' = all) * @return array */ public function getInvoiceList(int $order_id = 0, string $doc_type = ''): array { $where = ['i.company_id = :company_id']; $params = [':company_id' => $this->company_id]; if ($order_id > 0) { $where[] = 'i.order_id = :order_id'; $params[':order_id'] = $order_id; } if ($doc_type !== '') { $where[] = 'i.doc_type = :doc_type'; $params[':doc_type'] = $doc_type; } $sth = $this->pdo->prepare( "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name, o.order_number FROM td_invoice i LEFT JOIN md_contact c ON c.company_id = i.company_id AND c.id = i.contact_id LEFT JOIN td_order o ON o.company_id = i.company_id AND o.id = i.order_id WHERE " . implode(' AND ', $where) . " ORDER BY i.id DESC" ); $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single invoice by id, with items decoded. * * @param int $id td_invoice.id * @return array|false */ public function getInvoiceById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name, o.order_number FROM td_invoice i LEFT JOIN md_contact c ON c.company_id = i.company_id AND c.id = i.contact_id LEFT JOIN td_order o ON o.company_id = i.company_id AND o.id = i.order_id WHERE i.company_id = :company_id AND i.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) return false; $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; return $row; } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── /** * Create a draft invoice from a confirmed order. * * Snapshots td_order.items into td_invoice.items. * Copies totals from the order directly. * Always creates status=0 (draft) — user must manually issue. * * Called by: * - confirmOrder() engine when auto_invoice=1 * - "Proceed to Invoice" button on order detail page (manual) * * Must be called inside dbTransaction() by the caller. * * @param int $order_id td_order.id * @param array $logging Audit entry. * @return int New td_invoice.id * @throws Exception If order not found or invoice already exists for this order. */ public function createFromOrder(int $order_id, array $logging): int { // Load order $sth = $this->pdo->prepare( "SELECT * FROM td_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); $order = $sth->fetch(PDO::FETCH_ASSOC); if (!$order) { throw new Exception("Order not found."); } if ((int)$order['status'] < 1) { throw new Exception("Invoice can only be created for confirmed orders."); } // Block duplicate invoice for same order $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_invoice WHERE company_id = :company_id AND order_id = :order_id AND doc_type = 'invoice' AND status != 4" ); $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("An active invoice already exists for this order."); } $log = [array_merge($logging, ['action' => 'create_from_order'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, issued_date, due_date, subtotal, discount, tax, shipping_fee, grand_total, items, status, notes, `log`) VALUES (:company_id, :uuid, 'invoice', :invoice_number, 0, :order_id, :contact_id, :issued_date, NULL, :subtotal, :discount, :tax, :shipping_fee, :grand_total, :items, 0, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':invoice_number' => $this->generateInvoiceNumber('invoice'), ':order_id' => $order_id, ':contact_id' => (int)$order['contact_id'], ':issued_date' => date('Y-m-d'), ':subtotal' => $order['subtotal'], ':discount' => $order['discount'], ':tax' => $order['tax'], ':shipping_fee' => $order['shipping_fee'], ':grand_total' => $order['grand_total'], ':items' => $order['items'], // already JSON string ':log' => json_encode($log), ]); return (int)$this->pdo->lastInsertId(); } /** * Update metadata on an existing draft invoice. * * Only allowed while status = 0 (draft). * Editable fields: due_date, notes. * Totals are not editable — they snapshot from the order. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, due_date, notes. * @param array $logging Audit entry. * @throws Exception If invoice not found or not in draft status. */ public function saveInvoice(array $data, array $logging): void { $id = (int)($data['id'] ?? 0); $sth = $this->pdo->prepare( "SELECT status, `log` FROM td_invoice WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Invoice not found."); } if ((int)$row['status'] !== 0) { throw new Exception("Only draft invoices can be edited."); } $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'update']); $this->pdo->prepare( "UPDATE td_invoice SET due_date = :due_date, notes = :notes, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':due_date' => $data['due_date'] ?: null, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); } /** * Issue a draft invoice (status 0 → 1). * * @param int $id td_invoice.id * @param array $logging Audit entry. * @throws Exception If not found or not draft. */ public function issueInvoice(int $id, array $logging): void { $sth = $this->pdo->prepare( "SELECT status, `log` FROM td_invoice WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception("Invoice not found."); if ((int)$row['status'] !== 0) throw new Exception("Only draft invoices can be issued."); $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'issue']); $this->pdo->prepare( "UPDATE td_invoice SET status = 1, issued_date = :issued_date, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':issued_date' => date('Y-m-d'), ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); } /** * Create a credit note linked to a parent invoice. * * Called automatically by ReturnManager::approveReturn(). * grand_total is stored as negative value for net-balance queries. * * Must be called inside dbTransaction() by the caller. * * @param int $ref_invoice_id Parent td_invoice.id * @param array $items Items being credited (subset of invoice items) * @param float $amount Credit amount (positive — stored as negative internally) * @param array $logging Audit entry. * @return int New td_invoice.id (credit note) * @throws Exception If parent invoice not found or not issued. */ public function createCreditNote(int $ref_invoice_id, array $items, float $amount, array $logging): int { $sth = $this->pdo->prepare( "SELECT * FROM td_invoice WHERE company_id = :company_id AND id = :id AND doc_type = 'invoice'" ); $sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]); $parent = $sth->fetch(PDO::FETCH_ASSOC); if (!$parent) { throw new Exception("Parent invoice not found."); } if ((int)$parent['status'] < 1) { throw new Exception("Cannot credit a draft invoice. Issue it first."); } $log = [array_merge($logging, ['action' => 'create_credit_note'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, issued_date, due_date, subtotal, discount, tax, shipping_fee, grand_total, items, status, notes, `log`) VALUES (:company_id, :uuid, 'credit_note', :invoice_number, :ref_invoice_id, :order_id, :contact_id, :issued_date, NULL, :amount, 0, 0, 0, :grand_total, :items, 1, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':invoice_number' => $this->generateInvoiceNumber('credit_note'), ':ref_invoice_id' => $ref_invoice_id, ':order_id' => (int)$parent['order_id'], ':contact_id' => (int)$parent['contact_id'], ':issued_date' => date('Y-m-d'), ':amount' => $amount, ':grand_total' => -abs($amount), // negative for net-balance queries ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), ':log' => json_encode($log), ]); return (int)$this->pdo->lastInsertId(); } /** * Void an invoice (status → 4). * * Only allowed if no issued credit notes exist for this invoice, * or the sum of credit notes equals the full grand_total. * * @param int $id td_invoice.id * @param array $logging Audit entry. * @throws Exception */ public function voidInvoice(int $id, array $logging): void { $sth = $this->pdo->prepare( "SELECT * FROM td_invoice WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception("Invoice not found."); if ((int)$row['status'] === 4) throw new Exception("Invoice is already void."); $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'void']); $this->pdo->prepare( "UPDATE td_invoice SET status = 4, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); } }