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, ]; } private function assertPostingWindow(?string $date, string $context): void { global $pdo1; if (!isset($pdo1) || !($pdo1 instanceof PDO)) { throw new Exception("Posting-window validation is unavailable."); } $guard = new PostingWindowGuard($pdo1, $this->company_id); $guard->assertOpenDate($date ?: date('Y-m-d'), $context); } /** * Generate next sequential document number. * * @param string $doc_type 'invoice' | 'credit_note' | 'supplier_credit_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', 'supplier_credit_note' => 'DN', 'purchase_invoice' => 'PINV', ]; $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); } private function addSettlementFields(array $row): array { $total = abs((float)($row['grand_total'] ?? 0)); $allocated = (float)($row['allocated_amount'] ?? 0); $balance = max(0, $total - $allocated); $row['document_total'] = $total; $row['allocated_amount'] = $allocated; $row['balance_amount'] = $balance; if ((int)$row['status'] === 4) { $row['payment_state'] = 'void'; } elseif ((int)$row['status'] === 0) { $row['payment_state'] = 'draft'; } elseif ($total > 0 && $allocated + 0.0001 >= $total) { $row['payment_state'] = 'paid'; } elseif ($allocated > 0) { $row['payment_state'] = 'partial'; } else { $row['payment_state'] = 'open'; } return $row; } private function syncInvoiceItems(int $invoice_id, array $items): void { $this->pdo->prepare( "DELETE FROM td_invoice_item WHERE invoice_id = :invoice_id AND company_id = :company_id" )->execute([':invoice_id' => $invoice_id, ':company_id' => $this->company_id]); $sth = $this->pdo->prepare( "INSERT INTO td_invoice_item (company_id, invoice_id, item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate) VALUES (:company_id, :invoice_id, :item_id, :product_sku, :product_name, :quantity, :unit_price, :total_price, :tax_amount, :tax_rate)" ); foreach ($items as $pos => $item) { $sth->execute([ ':company_id' => $this->company_id, ':invoice_id' => $invoice_id, ':item_id' => $pos + 1, ':product_sku' => $item['product_sku'] ?? '', ':product_name' => $item['product_name'] ?? $item['description'] ?? '', ':quantity' => (float)($item['quantity'] ?? 0), ':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0), ':total_price' => (float)($item['total_price'] ?? 0), ':tax_amount' => (float)($item['tax_amount'] ?? 0), ':tax_rate' => (float)($item['tax_rate'] ?? 0), ]); } } // ───────────────────────────────────────────────────────────── // 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(ra.allocated_amount, 0) AS allocated_amount, COALESCE(c.contact_name, '') AS contact_name, o.order_number FROM td_invoice i LEFT JOIN ( SELECT a.company_id, a.invoice_id, SUM(a.amount) AS allocated_amount FROM td_receipt_item a JOIN td_receipt r ON r.company_id = a.company_id AND r.id = a.receipt_id AND r.status = 1 GROUP BY a.company_id, a.invoice_id ) ra ON ra.company_id = i.company_id AND ra.invoice_id = i.id 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); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$row) { $row = $this->addSettlementFields($row); } unset($row); return $rows; } /** * 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(ra.allocated_amount, 0) AS allocated_amount, COALESCE(c.contact_name, '') AS contact_name, o.order_number FROM td_invoice i LEFT JOIN ( SELECT a.company_id, a.invoice_id, SUM(a.amount) AS allocated_amount FROM td_receipt_item a JOIN td_receipt r ON r.company_id = a.company_id AND r.id = a.receipt_id AND r.status = 1 GROUP BY a.company_id, a.invoice_id ) ra ON ra.company_id = i.company_id AND ra.invoice_id = i.id 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; $sth2 = $this->pdo->prepare( "SELECT * FROM td_invoice_item WHERE invoice_id = :invoice_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':invoice_id' => $id, ':company_id' => $this->company_id]); $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); $row = $this->addSettlementFields($row); return $row; } public function getLinkedCreditNotes(int $ref_invoice_id, string $doc_type): array { $sth = $this->pdo->prepare( "SELECT * FROM td_invoice WHERE company_id = :company_id AND ref_invoice_id = :ref_id AND doc_type = :doc_type ORDER BY id DESC" ); $sth->execute([ ':company_id' => $this->company_id, ':ref_id' => $ref_invoice_id, ':doc_type' => $doc_type, ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } public function addReferenceInvoiceNumbers(array $rows): array { $sth = $this->pdo->prepare( "SELECT invoice_number FROM td_invoice WHERE id = :id AND company_id = :company_id" ); foreach ($rows as &$row) { if ((int)($row['ref_invoice_id'] ?? 0) > 0) { $sth->execute([ ':id' => (int)$row['ref_invoice_id'], ':company_id' => $this->company_id, ]); $row['ref_invoice_number'] = $sth->fetchColumn() ?: '—'; } else { $row['ref_invoice_number'] = '—'; } } unset($row); return $rows; } // ───────────────────────────────────────────────────────────── // 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 { $issued_date = date('Y-m-d'); $this->assertPostingWindow($issued_date, 'Invoice'); // 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'])]; $source = (string)($order['source'] ?? ''); $source_id = (int)($order['source_id'] ?? 0); $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, department_id, issued_date, due_date, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, :source_id, :source, 'invoice', :invoice_number, 0, :order_id, :contact_id, :department_id, :issued_date, NULL, :subtotal, :discount, :tax, :tax_adjustment, :shipping_fee, :grand_total, 0, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, ':source' => $source, ':invoice_number' => $this->generateInvoiceNumber('invoice'), ':order_id' => $order_id, ':contact_id' => (int)$order['contact_id'], ':department_id' => (int)($order['department_id'] ?? 0), ':issued_date' => $issued_date, ':subtotal' => $order['subtotal'], ':discount' => $order['discount'], ':tax' => $order['tax'], ':tax_adjustment' => (float)($order['tax_adjustment'] ?? 0), ':shipping_fee' => $order['shipping_fee'], ':grand_total' => $order['grand_total'], ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); // Read items from normalized table instead of JSON $sth2 = $this->pdo->prepare( "SELECT * FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); $order_items = $sth2->fetchAll(PDO::FETCH_ASSOC); $this->syncInvoiceItems($new_id, $order_items); return $new_id; } /** * 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, doc_type, issued_date, `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."); } $this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $row['doc_type']))); $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'update']); $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 ? (int)$data['formula_id'] : null; $params = [ ':due_date' => $data['due_date'] ?: null, ':notes' => $data['notes'] ?? '', ':formula_id' => $formula_id, ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]; $extra_set = ''; if (array_key_exists('tax_adjustment', $data)) { $tax_adjustment = (float)$data['tax_adjustment']; if (abs($tax_adjustment) > 0.30) { throw new Exception("Tax adjustment cannot exceed ±0.30."); } $sth3 = $this->pdo->prepare( "SELECT subtotal, discount, shipping_fee FROM td_invoice WHERE company_id = :company_id AND id = :id" ); $sth3->execute([':company_id' => $this->company_id, ':id' => $id]); $inv = $sth3->fetch(PDO::FETCH_ASSOC); $sth4 = $this->pdo->prepare( "SELECT SUM(tax_amount) FROM td_invoice_item WHERE invoice_id = :invoice_id AND company_id = :company_id" ); $sth4->execute([':invoice_id' => $id, ':company_id' => $this->company_id]); $item_tax_sum = (float)$sth4->fetchColumn(); $new_tax = round($item_tax_sum, 2) + $tax_adjustment; $new_grand_total = (float)$inv['subtotal'] - (float)$inv['discount'] + $new_tax + (float)$inv['shipping_fee']; $extra_set = ', tax = :tax, tax_adjustment = :tax_adjustment, grand_total = :grand_total'; $params[':tax'] = $new_tax; $params[':tax_adjustment'] = $tax_adjustment; $params[':grand_total'] = $new_grand_total; } $this->pdo->prepare( "UPDATE td_invoice SET due_date = :due_date, notes = :notes, formula_id = :formula_id{$extra_set}, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); } public function saveFormula(int $id, ?int $formula_id): void { $sth = $this->pdo->prepare( "SELECT status, doc_type, issued_date 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("Cannot update a void document."); $this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $row['doc_type']))); $this->pdo->prepare( "UPDATE td_invoice SET formula_id = :formula_id WHERE id = :id AND company_id = :company_id" )->execute([ ':formula_id' => $formula_id, ':id' => $id, ':company_id' => $this->company_id, ]); } /** * Issue a draft invoice (status 0 → 1). * * @param int $id td_invoice.id * @param string|null $due_date Invoice due date, yyyy-mm-dd. * @param array $logging Audit entry. * @throws Exception If not found or not draft. */ public function issueInvoice(int $id, array $logging, ?string $due_date = null): void { $issued_date = date('Y-m-d'); $sth = $this->pdo->prepare( "SELECT status, doc_type, `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."); if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) { throw new Exception("Due date is required before issuing this document."); } $this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type']))); $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'issue', 'due_date' => $due_date]); $this->pdo->prepare( "UPDATE td_invoice SET status = 1, issued_date = :issued_date, due_date = :due_date, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':issued_date' => $issued_date, ':due_date' => in_array($row['doc_type'], ['invoice', 'purchase_invoice']) ? $due_date : null, ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); } /** * Create a draft purchase invoice from a confirmed PO. * * Mirrors createFromOrder() for the purchase side. * doc_type = 'purchase_invoice', source = 'po', source_id = po_id. * Always creates status=0 (draft). * * @param int $po_id td_purchase_order.id * @param array $logging Audit entry. * @return int New td_invoice.id * @throws Exception */ public function createFromPo(int $po_id, array $logging): int { $issued_date = date('Y-m-d'); $this->assertPostingWindow($issued_date, 'Purchase invoice'); $sth = $this->pdo->prepare( "SELECT * FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception("Purchase order not found."); if ((int)$po['status'] < 1) throw new Exception("Purchase invoice can only be created for confirmed POs."); // Block duplicate active purchase invoice for same PO $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_invoice WHERE company_id = :company_id AND source = 'po' AND source_id = :source_id AND doc_type = 'purchase_invoice' AND status != 4" ); $sth->execute([':company_id' => $this->company_id, ':source_id' => $po_id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("An active purchase invoice already exists for this PO."); } $log = [array_merge($logging, ['action' => 'create_from_po'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, department_id, issued_date, due_date, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, :source_id, 'po', 'purchase_invoice', :invoice_number, 0, 0, :contact_id, :department_id, :issued_date, NULL, :subtotal, :discount, :tax, :tax_adjustment, :shipping_fee, :grand_total, 0, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $po_id, ':invoice_number' => $this->generateInvoiceNumber('purchase_invoice'), ':contact_id' => (int)$po['contact_id'], ':department_id' => (int)($po['department_id'] ?? 0), ':issued_date' => $issued_date, ':subtotal' => $po['subtotal'], ':discount' => $po['discount'], ':tax' => $po['tax'], ':tax_adjustment' => (float)($po['tax_adjustment'] ?? 0), ':shipping_fee' => $po['shipping_fee'], ':grand_total' => $po['grand_total'], ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); // Read items from normalized table instead of JSON $sth2 = $this->pdo->prepare( "SELECT * FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); $po_items = $sth2->fetchAll(PDO::FETCH_ASSOC); $this->syncInvoiceItems($new_id, $po_items); return $new_id; } /** * Create a draft debit note from a confirmed supplier return. * * Mirrors createFromReturn() for the purchase side. * doc_type = 'supplier_credit_note', source = 'supplier_return', source_id = return_id. * Always creates status=0 (draft). * * @param int $return_id td_supplier_return.id * @param array $logging Audit entry. * @return int New td_invoice.id * @throws Exception */ public function createFromSupplierReturn(int $return_id, array $logging): int { $issued_date = date('Y-m-d'); $this->assertPostingWindow($issued_date, 'Supplier credit note'); $sth = $this->pdo->prepare( "SELECT * FROM td_supplier_return WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); $ret = $sth->fetch(PDO::FETCH_ASSOC); if (!$ret) throw new Exception("Supplier return not found."); if ((int)$ret['status'] !== 1) throw new Exception("Supplier credit note can only be created for confirmed supplier returns."); // Block duplicate active debit note for same return $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_invoice WHERE company_id = :company_id AND source = 'supplier_return' AND source_id = :source_id AND doc_type = 'supplier_credit_note' AND status != 4" ); $sth->execute([':company_id' => $this->company_id, ':source_id' => $return_id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("An active supplier credit note already exists for this return."); } $tax = (float)$ret['tax']; $tax_adj = (float)$ret['tax_adjustment']; $subtotal = (float)$ret['refund_amount'] - $tax; $grand_total = (float)$ret['refund_amount']; $log = [array_merge($logging, ['action' => 'create_from_supplier_return'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, department_id, issued_date, due_date, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, :source_id, 'supplier_return', 'supplier_credit_note', :invoice_number, :ref_invoice_id, 0, :contact_id, :department_id, :issued_date, NULL, :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, 0, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $return_id, ':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note'), ':ref_invoice_id' => (int)$ret['invoice_id'], ':contact_id' => (int)$ret['contact_id'], ':department_id' => (int)($ret['department_id'] ?? 0), ':issued_date' => $issued_date, ':subtotal' => $subtotal, ':tax' => $tax, ':tax_adjustment' => $tax_adj, ':grand_total' => $grand_total, ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); $sth2 = $this->pdo->prepare( "SELECT * FROM td_supplier_return_item WHERE return_id = :return_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); $this->syncInvoiceItems($new_id, $sth2->fetchAll(PDO::FETCH_ASSOC)); return $new_id; } /** * Create a draft credit note from a confirmed return. * * Snapshots td_return.items into td_invoice.items. * grand_total stored as negative for net-balance queries. * Always creates status=0 (draft) — user must manually issue. * * Must be called inside dbTransaction() by the caller. * * @param int $return_id td_return.id * @param array $logging Audit entry. * @return int New td_invoice.id * @throws Exception If return not found, not confirmed, or CN already exists. */ public function createFromReturn(int $return_id, array $logging): int { $issued_date = date('Y-m-d'); $this->assertPostingWindow($issued_date, 'Credit note'); $sth = $this->pdo->prepare( "SELECT * FROM td_return WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); $ret = $sth->fetch(PDO::FETCH_ASSOC); if (!$ret) { throw new Exception("Return not found."); } if ((int)$ret['status'] !== 1) { throw new Exception("Credit note can only be created for confirmed returns."); } // Block duplicate active CN for same return $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_invoice WHERE company_id = :company_id AND source = 'return' AND source_id = :return_id AND doc_type = 'credit_note' AND status != 4" ); $sth->execute([':company_id' => $this->company_id, ':return_id' => $return_id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("An active credit note already exists for this return."); } $tax = (float)$ret['tax']; $tax_adj = (float)$ret['tax_adjustment']; $subtotal = (float)$ret['refund_amount'] - $tax; $grand_total = -abs((float)$ret['refund_amount']); $log = [array_merge($logging, ['action' => 'create_from_return'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, department_id, issued_date, due_date, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, :source_id, 'return', 'credit_note', :invoice_number, :ref_invoice_id, :order_id, :contact_id, :department_id, :issued_date, NULL, :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, 0, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $return_id, ':invoice_number' => $this->generateInvoiceNumber('credit_note'), ':ref_invoice_id' => (int)$ret['invoice_id'], ':order_id' => (int)$ret['order_id'], ':contact_id' => (int)$ret['contact_id'], ':department_id' => (int)($ret['department_id'] ?? 0), ':issued_date' => $issued_date, ':subtotal' => $subtotal, ':tax' => $tax, ':tax_adjustment' => $tax_adj, ':grand_total' => $grand_total, ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); // Read items from normalized table instead of JSON $sth2 = $this->pdo->prepare( "SELECT * FROM td_return_item WHERE return_id = :return_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); $return_items = $sth2->fetchAll(PDO::FETCH_ASSOC); $this->syncInvoiceItems($new_id, $return_items); return $new_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, string $source = '', int $source_id = 0 ): int { $issued_date = date('Y-m-d'); $this->assertPostingWindow($issued_date, 'Credit note'); $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, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, issued_date, due_date, subtotal, discount, tax, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id, :order_id, :contact_id, :issued_date, NULL, :amount, 0, 0, 0, :grand_total, 1, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, ':source' => $source, ':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' => $issued_date, ':amount' => $amount, ':grand_total' => -abs($amount), // negative for net-balance queries ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); $this->syncInvoiceItems($new_id, $items); return $new_id; } /** * Create a draft accounting credit note without stock movement. * * ref_invoice_id is optional. When supplied, contact/order are copied from * the original invoice unless explicitly provided. */ public function createManualCreditNote(array $data, array $logging): int { $this->assertPostingWindow(date('Y-m-d'), 'Credit note'); $ref_invoice_id = (int)($data['ref_invoice_id'] ?? 0); $contact_id = (int)($data['contact_id'] ?? 0); $order_id = (int)($data['order_id'] ?? 0); $notes = trim((string)($data['notes'] ?? '')); $items = $data['items'] ?? []; $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); if (is_string($items)) { $items = json_decode($items, true) ?: []; } if (!$items) { throw new Exception("Credit note requires at least one line item."); } if ($notes === '') { throw new Exception("Credit note reason is required."); } if (abs($tax_adjustment) > 0.30) { throw new Exception("Tax adjustment cannot exceed ±0.30."); } if ($ref_invoice_id > 0) { $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("Reference invoice not found."); } if ((int)$parent['status'] === 0) { throw new Exception("Cannot create a credit note for a draft invoice."); } $contact_id = $contact_id ?: (int)$parent['contact_id']; $order_id = $order_id ?: (int)$parent['order_id']; } if ($contact_id <= 0) { throw new Exception("Contact is required."); } $normalized = []; $subtotal = 0.0; foreach ($items as $i => $item) { $description = trim((string)($item['product_name'] ?? $item['description'] ?? '')); $sku = trim((string)($item['product_sku'] ?? '')); $qty = (float)($item['quantity'] ?? 0); $price = (float)($item['price'] ?? $item['unit_price'] ?? 0); $tax_rate = (float)($item['tax_rate'] ?? 0); if ($description === '' && $sku === '') continue; if ($qty <= 0 || $price <= 0) { throw new Exception("Credit note line #" . ($i + 1) . " requires quantity and price."); } $line_total = round($qty * $price, 4); $subtotal += $line_total; $normalized[] = [ 'item_id' => count($normalized), 'product_sku' => $sku, 'product_name' => $description ?: $sku, 'quantity' => $qty, 'unit_price' => $price, 'total_price' => $line_total, 'tax_rate' => $tax_rate, 'tax_amount' => round($qty * $price * $tax_rate / 100, 4), ]; } if (!$normalized || $subtotal <= 0) { throw new Exception("Credit note amount must be greater than zero."); } $tax = round(array_sum(array_column($normalized, 'tax_amount')), 2) + $tax_adjustment; $grand_total = -abs($subtotal + $tax); $log = [array_merge($logging, ['action' => 'create_manual_credit_note'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, issued_date, due_date, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, 0, 'revenue', 'credit_note', :invoice_number, :ref_invoice_id, :order_id, :contact_id, NULL, NULL, :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, 0, :notes, :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' => $order_id, ':contact_id' => $contact_id, ':subtotal' => $subtotal, ':tax' => $tax, ':tax_adjustment' => $tax_adjustment, ':grand_total' => $grand_total, ':notes' => $notes, ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); $this->syncInvoiceItems($new_id, $normalized); return $new_id; } /** * Create a draft accounting supplier credit note (supplier_credit_note) without stock movement. */ public function createManualDebitNote(array $data, array $logging): int { $this->assertPostingWindow(date('Y-m-d'), 'Supplier credit note'); $ref_invoice_id = (int)($data['ref_invoice_id'] ?? 0); $contact_id = (int)($data['contact_id'] ?? 0); $notes = trim((string)($data['notes'] ?? '')); $items = $data['items'] ?? []; $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); if (is_string($items)) { $items = json_decode($items, true) ?: []; } if (!$items) { throw new Exception("Supplier credit note requires at least one line item."); } if ($notes === '') { throw new Exception("Supplier credit note reason is required."); } if (abs($tax_adjustment) > 0.30) { throw new Exception("Tax adjustment cannot exceed ±0.30."); } if ($ref_invoice_id > 0) { $sth = $this->pdo->prepare( "SELECT * FROM td_invoice WHERE company_id = :company_id AND id = :id AND doc_type = 'purchase_invoice'" ); $sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]); $parent = $sth->fetch(PDO::FETCH_ASSOC); if (!$parent) { throw new Exception("Reference purchase invoice not found."); } if ((int)$parent['status'] === 0) { throw new Exception("Cannot create a supplier credit note for a draft purchase invoice."); } $contact_id = $contact_id ?: (int)$parent['contact_id']; } if ($contact_id <= 0) { throw new Exception("Supplier contact is required."); } $normalized = []; $subtotal = 0.0; foreach ($items as $i => $item) { $description = trim((string)($item['product_name'] ?? $item['description'] ?? '')); $sku = trim((string)($item['product_sku'] ?? '')); $qty = (float)($item['quantity'] ?? 0); $price = (float)($item['price'] ?? $item['unit_price'] ?? 0); $tax_rate = (float)($item['tax_rate'] ?? 0); if ($description === '' && $sku === '') continue; if ($qty <= 0 || $price <= 0) { throw new Exception("Line #" . ($i + 1) . " requires quantity and price."); } $line_total = round($qty * $price, 4); $subtotal += $line_total; $normalized[] = [ 'item_id' => count($normalized), 'product_sku' => $sku, 'product_name' => $description ?: $sku, 'quantity' => $qty, 'unit_price' => $price, 'total_price' => $line_total, 'tax_rate' => $tax_rate, 'tax_amount' => round($qty * $price * $tax_rate / 100, 4), ]; } if (!$normalized || $subtotal <= 0) { throw new Exception("Supplier credit note amount must be greater than zero."); } $tax = round(array_sum(array_column($normalized, 'tax_amount')), 2) + $tax_adjustment; $grand_total = $subtotal + $tax; $log = [array_merge($logging, ['action' => 'create_manual_supplier_credit_note'])]; $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, issued_date, due_date, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, 0, 'expense', 'supplier_credit_note', :invoice_number, :ref_invoice_id, 0, :contact_id, NULL, NULL, :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, 0, :notes, :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note'), ':ref_invoice_id' => $ref_invoice_id, ':contact_id' => $contact_id, ':subtotal' => $subtotal, ':tax' => $tax, ':tax_adjustment' => $tax_adjustment, ':grand_total' => $grand_total, ':notes' => $notes, ':log' => json_encode($log), ]); $new_id = (int)$this->pdo->lastInsertId(); $this->syncInvoiceItems($new_id, $normalized); return $new_id; } /** * 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 (!in_array($row['doc_type'], ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'], true)) { throw new Exception("This document type cannot be voided from here."); } if ((int)$row['status'] === 4) throw new Exception("Invoice is already void."); if ((int)$row['status'] === 0) throw new Exception("Draft invoices cannot be voided from here."); $this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $row['doc_type']))); if (in_array($row['doc_type'], ['invoice', 'credit_note'], true)) { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_receipt_item ri JOIN td_receipt r ON r.company_id = ri.company_id AND r.id = ri.receipt_id AND r.status = 1 WHERE ri.company_id = :company_id AND ri.invoice_id = :invoice_id" ); $sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("Cannot void this document while posted receipts are allocated to it. Void the receipt first."); } } if (in_array($row['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_payment_item pi JOIN td_payment p ON p.company_id = pi.company_id AND p.id = pi.payment_id AND p.status = 1 WHERE pi.company_id = :company_id AND pi.invoice_id = :invoice_id" ); $sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("Cannot void this document while posted payments are allocated to it. Void the payment first."); } } $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge([ 'action' => 'void', 'status' => 4, 'previous_status' => (int)$row['status'], ], $logging); $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, ]); global $pdo1; $guard = new PostingWindowGuard($pdo1, $this->company_id); $gl = new GlManager($this->pdo, $this->company_id, $guard); $gl->delete((string)$row['doc_type'], $id); } }