pdo = $pdo; $this->company_id = $company_id; } private function generateBillingNumber(): string { $prefix = 'PB-' . date('Ymd') . '-'; $sth = $this->pdo->prepare( "SELECT billing_number FROM td_payment_billing WHERE company_id = :company_id AND billing_number LIKE :prefix ORDER BY billing_number DESC LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':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 unavailableDocumentAmount(int $invoice_id): float { $sth = $this->pdo->prepare( "SELECT COALESCE(billed.billed_amount, 0) + COALESCE(direct.paid_amount, 0) FROM ( SELECT :company_id AS company_id, :invoice_id AS invoice_id ) doc LEFT JOIN ( SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount FROM td_payment_billing_item ba JOIN td_payment_billing b ON b.company_id = ba.company_id AND b.id = ba.billing_id WHERE ba.company_id = :company_id_billed AND ba.invoice_id = :invoice_id_billed AND b.status IN (1, 2, 3) GROUP BY ba.company_id, ba.invoice_id ) billed ON billed.company_id = doc.company_id AND billed.invoice_id = doc.invoice_id LEFT JOIN ( SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount FROM td_payment_item ra JOIN td_payment r ON r.company_id = ra.company_id AND r.id = ra.payment_id WHERE ra.company_id = :company_id_direct AND ra.invoice_id = :invoice_id_direct AND r.status = 1 AND r.payment_billing_id = 0 GROUP BY ra.company_id, ra.invoice_id ) direct ON direct.company_id = doc.company_id AND direct.invoice_id = doc.invoice_id" ); $sth->execute([ ':company_id' => $this->company_id, ':invoice_id' => $invoice_id, ':company_id_billed' => $this->company_id, ':invoice_id_billed' => $invoice_id, ':company_id_direct' => $this->company_id, ':invoice_id_direct' => $invoice_id, ]); return (float)$sth->fetchColumn(); } public function getBillableDocuments(int $contact_id = 0): array { $where = [ "i.company_id = :company_id", "i.doc_type IN ('purchase_invoice', 'supplier_credit_note')", "i.status = 1" ]; $params = [':company_id' => $this->company_id]; $where[] = 'i.contact_id = :contact_id'; $params[':contact_id'] = $contact_id; $sth = $this->pdo->prepare( "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name, po.po_number AS order_number, COALESCE(payment.paid_amount, 0) AS paid_amount, COALESCE(billed.billed_amount, 0) AS billed_amount 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_purchase_order po ON po.company_id = i.company_id AND po.id = i.source_id AND i.source = 'po' LEFT JOIN ( SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount FROM td_payment_item ra JOIN td_payment r ON r.company_id = ra.company_id AND r.id = ra.payment_id WHERE r.status = 1 AND r.payment_billing_id = 0 GROUP BY ra.company_id, ra.invoice_id ) payment ON payment.company_id = i.company_id AND payment.invoice_id = i.id LEFT JOIN ( SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount FROM td_payment_billing_item ba JOIN td_payment_billing b ON b.company_id = ba.company_id AND b.id = ba.billing_id WHERE b.status IN (1, 2, 3) GROUP BY ba.company_id, ba.invoice_id ) billed ON billed.company_id = i.company_id AND billed.invoice_id = i.id WHERE " . implode(' AND ', $where) . " ORDER BY i.issued_date ASC, i.id ASC" ); $sth->execute($params); $open = []; foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { $total = abs((float)$row['grand_total']); $used = (float)$row['paid_amount'] + (float)$row['billed_amount']; $balance = max(0, $total - $used); if ($balance <= 0.0001) continue; $row['document_total'] = $total; $row['balance_amount'] = $balance; $open[] = $row; } return $open; } public function getBillingList(): array { $sth = $this->pdo->prepare( "SELECT b.*, COALESCE(c.contact_name, '') AS contact_name, COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount FROM td_payment_billing b LEFT JOIN md_contact c ON c.company_id = b.company_id AND c.id = b.contact_id LEFT JOIN td_payment r ON r.company_id = b.company_id AND r.payment_billing_id = b.id WHERE b.company_id = :company_id GROUP BY b.id ORDER BY b.id DESC" ); $sth->execute([':company_id' => $this->company_id]); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$row) { $row['balance_amount'] = max(0, (float)$row['amount'] - (float)$row['paid_amount']); } return $rows; } public function getOpenBillingList(int $contact_id = 0): array { $rows = $this->getBillingList(); return array_values(array_filter($rows, function($row) use ($contact_id) { if ((int)$row['status'] === 4) return false; if ((int)$row['contact_id'] !== $contact_id) return false; return (float)$row['balance_amount'] > 0.0001; })); } public function getBillingById(int $billing_id): array { $sth = $this->pdo->prepare( "SELECT b.*, COALESCE(c.contact_name, '') AS contact_name, COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount FROM td_payment_billing b LEFT JOIN md_contact c ON c.company_id = b.company_id AND c.id = b.contact_id LEFT JOIN td_payment r ON r.company_id = b.company_id AND r.payment_billing_id = b.id WHERE b.company_id = :company_id AND b.id = :id GROUP BY b.id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); $billing = $sth->fetch(PDO::FETCH_ASSOC); if (!$billing) throw new Exception("Payment billing not found."); $sth = $this->pdo->prepare( "SELECT a.*, i.invoice_number, i.order_id, i.department_id, i.grand_total, i.status AS invoice_status, COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS paid_line_amount, po.po_number AS order_number FROM td_payment_billing_item a JOIN td_invoice i ON i.company_id = a.company_id AND i.id = a.invoice_id LEFT JOIN td_payment_item ra ON ra.company_id = a.company_id AND ra.invoice_id = a.invoice_id LEFT JOIN td_payment r ON r.company_id = ra.company_id AND r.id = ra.payment_id AND r.payment_billing_id = a.billing_id LEFT JOIN td_purchase_order po ON po.company_id = i.company_id AND po.id = i.source_id AND i.source = 'po' WHERE a.company_id = :company_id AND a.billing_id = :billing_id GROUP BY a.id ORDER BY a.id ASC" ); $sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]); $allocations = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($allocations as &$line) { $line['balance_amount'] = max(0, (float)$line['amount'] - (float)$line['paid_line_amount']); } $billing['allocations'] = $allocations; $billing['balance_amount'] = max(0, (float)$billing['amount'] - (float)$billing['paid_amount']); return $billing; } public function refreshBillingStatus(int $billing_id): void { $billing = $this->getBillingById($billing_id); if ((int)$billing['status'] === 4) return; if ((float)$billing['balance_amount'] <= 0.0001) { $new_status = 2; } elseif ((float)$billing['paid_amount'] > 0.0001) { $new_status = 3; } else { $new_status = 1; } $this->pdo->prepare( "UPDATE td_payment_billing SET status = :status WHERE company_id = :company_id AND id = :id" )->execute([ ':status' => $new_status, ':company_id' => $this->company_id, ':id' => $billing_id, ]); } public function createBilling(array $data, array $logging): int { $contact_id = (int)($data['contact_id'] ?? 0); $billing_date = $data['billing_date'] ?? date('Y-m-d'); $notes = trim((string)($data['notes'] ?? '')); $allocations = $data['allocations'] ?? []; if (is_string($allocations)) { $allocations = json_decode($allocations, true) ?: []; } if ($contact_id <= 0) throw new Exception("Supplier is required."); if (!$allocations) throw new Exception("Payment billing requires at least one document."); $validated = []; $purchase_invoice_total = 0.0; $credit_total = 0.0; foreach ($allocations as $line) { $invoice_id = (int)($line['invoice_id'] ?? 0); $amount = (float)($line['amount'] ?? 0); if ($invoice_id <= 0 || $amount <= 0) continue; $sth = $this->pdo->prepare( "SELECT id, doc_type, invoice_number, contact_id, department_id, grand_total, status FROM td_invoice WHERE company_id = :company_id AND id = :id FOR UPDATE" ); $sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]); $doc = $sth->fetch(PDO::FETCH_ASSOC); if (!$doc) throw new Exception("Document not found."); if ((int)$doc['contact_id'] !== $contact_id) { throw new Exception("All billing documents must belong to the selected contact."); } if (!in_array($doc['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) { throw new Exception("Payment billing can only include purchase invoices and supplier credit notes."); } if ((int)$doc['status'] !== 1) { throw new Exception($doc['invoice_number'] . " is not open for billing."); } $balance = abs((float)$doc['grand_total']) - $this->unavailableDocumentAmount($invoice_id); if ($amount - $balance > 0.0001) { throw new Exception($doc['invoice_number'] . " allocation exceeds remaining billable balance."); } if ($doc['doc_type'] === 'supplier_credit_note') $credit_total += $amount; else $purchase_invoice_total += $amount; $validated[] = [ 'invoice_id' => $invoice_id, 'doc_type' => $doc['doc_type'], 'department_id' => (int)($doc['department_id'] ?? 0), 'amount' => round($amount, 4), ]; } if (!$validated) throw new Exception("Payment billing requires at least one valid document."); $net_amount = round($purchase_invoice_total - $credit_total, 4); if ($net_amount <= 0.0001) { throw new Exception("Payment billing amount must be greater than zero."); } $log = [array_merge($logging, ['action' => 'create_payment_billing'])]; $this->pdo->prepare( "INSERT INTO td_payment_billing (company_id, uuid, billing_number, contact_id, billing_date, amount, status, notes, `log`, created_at) VALUES (:company_id, :uuid, :billing_number, :contact_id, :billing_date, :amount, 1, :notes, :log, :created_at)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':billing_number' => $this->generateBillingNumber(), ':contact_id' => $contact_id, ':billing_date' => $billing_date, ':amount' => $net_amount, ':notes' => $notes, ':log' => json_encode($log), ':created_at' => date('Y-m-d H:i:s'), ]); $billing_id = (int)$this->pdo->lastInsertId(); foreach ($validated as $line) { $this->pdo->prepare( "INSERT INTO td_payment_billing_item (company_id, billing_id, invoice_id, doc_type, amount) VALUES (:company_id, :billing_id, :invoice_id, :doc_type, :amount)" )->execute([ ':company_id' => $this->company_id, ':billing_id' => $billing_id, ':invoice_id' => $line['invoice_id'], ':doc_type' => $line['doc_type'], ':amount' => $line['amount'], ]); } return $billing_id; } public function voidBilling(int $billing_id, array $logging): void { $billing = $this->getBillingById($billing_id); if ((int)$billing['status'] === 4) throw new Exception("Payment billing is already void."); if ((float)$billing['paid_amount'] > 0.0001) { throw new Exception("Cannot void a payment billing with posted payments."); } $log = json_decode($billing['log'] ?: '[]', true); if (!is_array($log)) $log = []; $log[] = array_merge($logging, ['action' => 'void_payment_billing']); $this->pdo->prepare( "UPDATE td_payment_billing SET status = 4, `log` = :log WHERE company_id = :company_id AND id = :id" )->execute([ ':log' => json_encode($log), ':company_id' => $this->company_id, ':id' => $billing_id, ]); } } ?>