430 lines
17 KiB
PHP
430 lines
17 KiB
PHP
<?php
|
|
|
|
/**
|
|
* InvoiceManager
|
|
*
|
|
* Handles all read and write operations for td_invoice.
|
|
* Supports three doc_type values: invoice | credit_note | debit_note
|
|
*
|
|
* Method order:
|
|
* Transaction basis → getInvoiceList, getInvoiceById,
|
|
* generateInvoiceNumber, saveInvoice,
|
|
* createFromOrder, createCreditNote,
|
|
* voidInvoice
|
|
*
|
|
* Key design decisions:
|
|
* - invoice.items is a JSON snapshot of td_order.items at issue time.
|
|
* credit_note.items contains only the items being corrected.
|
|
* - grand_total is positive for invoice/debit_note, negative for credit_note.
|
|
* - createFromOrder() is called by confirmOrder() when auto_invoice=1,
|
|
* or manually from the order detail page. Always creates status=0 (draft).
|
|
* - createCreditNote() is called by ReturnManager::approveReturn() —
|
|
* never directly by the user.
|
|
* - voidInvoice() only works on invoices with no approved credit notes
|
|
* summing to grand_total (partial credit notes must be resolved first).
|
|
*
|
|
* Note: Write methods do NOT manage their own DB transactions.
|
|
* Callers must wrap multi-step operations inside dbTransaction().
|
|
*
|
|
* Security: All SQL uses PDO prepared statements with bound parameters.
|
|
*/
|
|
class InvoiceManager {
|
|
|
|
private PDO $pdo;
|
|
private int $company_id;
|
|
|
|
public function __construct(PDO $pdo, int $company_id) {
|
|
$this->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,
|
|
]);
|
|
}
|
|
} |