1317 lines
55 KiB
PHP
1317 lines
55 KiB
PHP
<?php
|
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
|
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
|
|
|
/**
|
|
* InvoiceManager
|
|
*
|
|
* Handles all read and write operations for td_invoice.
|
|
* Supports three doc_type values: invoice | credit_note | supplier_credit_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/supplier_credit_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,
|
|
];
|
|
}
|
|
|
|
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' | 'purchase_invoice' | 'supplier_credit_note'
|
|
* @return string e.g. "INV-2605-00001" | "CN-2605-00001" | "PI-2605-00001" | "SCN-2605-00001"
|
|
*/
|
|
private function generateInvoiceNumber(string $doc_type, array $data = []): string
|
|
{
|
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
|
->resolveNumber($data, $doc_type, 'td_invoice', 'invoice_number');
|
|
}
|
|
|
|
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', $data),
|
|
':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', $data),
|
|
':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
|
|
*/
|
|
/**
|
|
* Soft-delete an invoice/credit_note/purchase_invoice/supplier_credit_note by negating
|
|
* company_id. Blocked if any active (non-soft-deleted) downstream documents exist.
|
|
* Deletes the GL entry if one was posted (subject to posting window).
|
|
*/
|
|
public function softDelete(int $id): void
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id, doc_type, issued_date FROM td_invoice
|
|
WHERE company_id = :cid AND id = :id LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) throw new Exception('Invoice not found.');
|
|
$doc_type = (string)$row['doc_type'];
|
|
|
|
$this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $doc_type)) . ' deletion');
|
|
|
|
if (in_array($doc_type, ['invoice', 'credit_note'], true)) {
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_receipt_item
|
|
WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth2->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — receipts are allocated to this document. Delete the receipts first.');
|
|
}
|
|
|
|
$sth3 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_payment_item
|
|
WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth3->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth3->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — payments are allocated to this document. Delete the payments first.');
|
|
}
|
|
|
|
$sth4 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_receipt_billing_item
|
|
WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth4->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth4->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — receipt billings reference this document. Delete the receipt billings first.');
|
|
}
|
|
|
|
$sth5 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_payment_billing_item
|
|
WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth5->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth5->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — payment billings reference this document. Delete the payment billings first.');
|
|
}
|
|
|
|
if ($doc_type === 'invoice') {
|
|
$sth6 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_invoice
|
|
WHERE company_id = :cid AND ref_invoice_id = :id AND doc_type = 'credit_note'"
|
|
);
|
|
$sth6->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth6->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — credit notes reference this invoice. Delete the credit notes first.');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (in_array($doc_type, ['purchase_invoice', 'supplier_credit_note'], true)) {
|
|
$sth7 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_payment_item
|
|
WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth7->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth7->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — payments are allocated to this document. Delete the payments first.');
|
|
}
|
|
|
|
$sth8 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_payment_billing_item
|
|
WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth8->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth8->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — payment billings reference this document. Delete the payment billings first.');
|
|
}
|
|
}
|
|
|
|
// Delete GL entry if posted (no-op if none exists; throws if period is closed)
|
|
global $pdo1;
|
|
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
|
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
|
$gl = new GlManager($this->pdo, $this->company_id, $guard);
|
|
$gl->delete($doc_type, $id);
|
|
}
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_invoice_item SET company_id = company_id * -1
|
|
WHERE invoice_id = :id AND company_id = :cid"
|
|
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_invoice SET company_id = company_id * -1
|
|
WHERE id = :id AND company_id = :cid"
|
|
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|