Apply the configured timezone to PHP and both DB connections, wrap unwrapped ajax payloads so delete buttons reach their engines, normalise and validate invoice due dates, reject stock quantities below the stored 4dp scale, and list stock movements across all warehouses.
1417 lines
59 KiB
PHP
1417 lines
59 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.
|
|
*/
|
|
/**
|
|
* Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
|
|
* that is not a real calendar date.
|
|
*
|
|
* The date pickers display d/m/Y, and a page that forgets to convert before
|
|
* posting sends that text straight through to a MySQL DATE column, where it
|
|
* fails as a PDOException and surfaces to the user as the opaque
|
|
* "Database error, please try again." Accepting both spellings here keeps
|
|
* the failure mode a named, actionable message instead.
|
|
*
|
|
* @param string $value ISO or d/m/Y date; '' is treated as "not set".
|
|
* @param string $label Field name used in the error message.
|
|
* @return string|null ISO date, or null when nothing was supplied.
|
|
* @throws Exception When the value is not a valid date.
|
|
*/
|
|
private function normaliseDate(string $value, string $label): ?string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') return null;
|
|
|
|
// Strip a time part, if the caller passed a datetime.
|
|
$value = explode(' ', $value)[0];
|
|
|
|
foreach (['Y-m-d', 'd/m/Y'] as $format) {
|
|
$parsed = DateTime::createFromFormat('!' . $format, $value);
|
|
// createFromFormat() accepts overflowing values such as 32/01/2026
|
|
// and rolls them over, so compare the round-trip to reject those.
|
|
if ($parsed && $parsed->format($format) === $value) {
|
|
return $parsed->format('Y-m-d');
|
|
}
|
|
}
|
|
|
|
throw new Exception("{$label} is not a valid date.");
|
|
}
|
|
|
|
public function saveInvoice(array $data, array $logging): void
|
|
{
|
|
$id = (int)($data['id'] ?? 0);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT status, doc_type, issued_date, due_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;
|
|
|
|
// Absent key means "not being edited" — keep what is stored rather than
|
|
// clearing it, so a caller that posts only tax_adjustment cannot wipe
|
|
// the agreed payment term.
|
|
$due_date = array_key_exists('due_date', $data)
|
|
? $this->normaliseDate((string)$data['due_date'], 'Due date')
|
|
: ($row['due_date'] ?: null);
|
|
|
|
$issued_date = $row['issued_date'] ?: null;
|
|
|
|
if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
|
|
throw new Exception("The due date cannot be earlier than the issue date.");
|
|
}
|
|
|
|
$params = [
|
|
':due_date' => $due_date,
|
|
':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.");
|
|
}
|
|
|
|
$due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
|
|
if ($due_date !== null && $due_date < $issued_date) {
|
|
throw new Exception("The due date cannot be earlier than the issue date.");
|
|
}
|
|
$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, status, 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'];
|
|
|
|
// Drafts have no GL entry — posting window does not apply.
|
|
if ((int)$row['status'] !== 0) {
|
|
$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.");
|
|
}
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*)
|
|
FROM td_receipt_billing_item bi
|
|
JOIN td_receipt_billing b
|
|
ON b.company_id = bi.company_id
|
|
AND b.id = bi.billing_id
|
|
AND b.status = 1
|
|
WHERE bi.company_id = :company_id
|
|
AND bi.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 receipt billing notes are allocated to it. Void the receipt billing note first.");
|
|
}
|
|
}
|
|
|
|
if ($row['doc_type'] === 'invoice') {
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_invoice
|
|
WHERE company_id = :company_id
|
|
AND ref_invoice_id = :invoice_id
|
|
AND doc_type = 'credit_note'
|
|
AND status != 4"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]);
|
|
if ((int)$sth->fetchColumn() > 0) {
|
|
throw new Exception("Cannot void this invoice while active credit notes reference it. Void the credit notes 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.");
|
|
}
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*)
|
|
FROM td_payment_billing_item bi
|
|
JOIN td_payment_billing b
|
|
ON b.company_id = bi.company_id
|
|
AND b.id = bi.billing_id
|
|
AND b.status = 1
|
|
WHERE bi.company_id = :company_id
|
|
AND bi.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 payment billing notes are allocated to it. Void the payment billing note 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);
|
|
}
|
|
}
|