diff --git a/app/ac_dashboard/api/engine/posting_window.php b/app/ac_dashboard/api/engine/posting_window.php
index 068b2c2..664b768 100644
--- a/app/ac_dashboard/api/engine/posting_window.php
+++ b/app/ac_dashboard/api/engine/posting_window.php
@@ -1,7 +1,7 @@
get('posting_open_from') ?: $settings->get('gl_open_from') ?: '';
diff --git a/app/accounting/api/engine/posting_window.php b/app/accounting/api/engine/posting_window.php
index 52a6290..899e340 100644
--- a/app/accounting/api/engine/posting_window.php
+++ b/app/accounting/api/engine/posting_window.php
@@ -1,7 +1,7 @@
Receipt
+
@@ -186,7 +187,8 @@
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
- manual: 'Manual'
+ manual: 'Manual',
+ reversal: 'Reversal'
};
var SOURCE_BADGE = {
@@ -196,7 +198,8 @@
supplier_credit_note: 'bg-secondary-subtle text-secondary',
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
- manual: 'bg-dark-subtle text-dark'
+ manual: 'bg-dark-subtle text-dark',
+ reversal: 'bg-danger-subtle text-danger'
};
function source_badge(type) {
diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php
index 1399231..f2df951 100644
--- a/app/assets/utils/classes/InvoiceManager.php
+++ b/app/assets/utils/classes/InvoiceManager.php
@@ -1158,6 +1158,112 @@ class InvoiceManager {
* @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(
diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php
index a058639..0adfdb7 100644
--- a/app/assets/utils/classes/OrderManager.php
+++ b/app/assets/utils/classes/OrderManager.php
@@ -1,4 +1,5 @@
pdo->prepare(
+ "SELECT * FROM td_order WHERE company_id = :cid AND id = :id"
+ );
+ $sth->execute([':cid' => $this->company_id, ':id' => $id]);
+ $order = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$order) throw new Exception('Sales order not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($order['order_date'] ?: date('Y-m-d'), 'Order deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_invoice
+ WHERE company_id = :cid AND order_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — this order has linked invoices. Delete the invoices first.');
+ }
+
+ $sth3 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_return
+ WHERE company_id = :cid AND order_id = :id"
+ );
+ $sth3->execute([':cid' => $this->company_id, ':id' => $id]);
+ if ((int)$sth3->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — this order has linked returns. Delete the returns first.');
+ }
+
+ // Block if any approved stock-out rows exist; user must reverse via ICS first
+ $sth4 = $this->pdo->prepare(
+ "SELECT table_name FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
+ );
+ $sth4->execute();
+ $tables = $sth4->fetchAll(PDO::FETCH_COLUMN);
+
+ foreach ($tables as $table) {
+ $chk = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM `{$table}`
+ WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status = 1"
+ );
+ $chk->execute([':cid' => $this->company_id, ':id' => $id]);
+ if ((int)$chk->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — stock-out for this order has been approved. Reverse via ICS first.');
+ }
+ }
+
+ // Negate draft/pending stock-out rows (status != 1)
+ foreach ($tables as $table) {
+ $this->pdo->prepare(
+ "UPDATE `{$table}` SET company_id = company_id * -1
+ WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status != 1"
+ )->execute([':cid' => $this->company_id, ':id' => $id]);
+ }
+
+ // Release converted_qty on source quotation
+ $source = (string)($order['source'] ?? '');
+ $source_id = (int)($order['source_id'] ?? 0);
+ if ($source === 'quotation' && $source_id > 0) {
+ $sth5 = $this->pdo->prepare(
+ "SELECT item_id, quantity FROM td_order_item
+ WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
+ );
+ $sth5->execute([':id' => $id, ':cid' => $this->company_id]);
+ $items = $sth5->fetchAll(PDO::FETCH_ASSOC);
+ $dec = $this->pdo->prepare(
+ "UPDATE td_quotation_item
+ SET converted_qty = GREATEST(0, converted_qty - :qty)
+ WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
+ );
+ foreach ($items as $item) {
+ $dec->execute([
+ ':qty' => (float)($item['quantity'] ?? 0),
+ ':qid' => $source_id,
+ ':item_id' => (int)($item['item_id'] ?? 0),
+ ':cid' => $this->company_id,
+ ]);
+ }
+ $this->pdo->prepare(
+ "UPDATE td_quotation SET status = 2
+ WHERE id = :id AND company_id = :cid AND status = 5"
+ )->execute([':id' => $source_id, ':cid' => $this->company_id]);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_order_item SET company_id = company_id * -1
+ WHERE order_id = :id AND company_id = :cid"
+ )->execute([':id' => $id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_order SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $id, ':cid' => $this->company_id]);
+ }
+
/**
* Update shipping tracking number. Fulfillment itself is derived from
* linked stock-out rows and tracking, not selected manually.
diff --git a/app/assets/utils/classes/PaymentBillingManager.php b/app/assets/utils/classes/PaymentBillingManager.php
index 50db7f8..e0c2cfc 100644
--- a/app/assets/utils/classes/PaymentBillingManager.php
+++ b/app/assets/utils/classes/PaymentBillingManager.php
@@ -1,4 +1,5 @@
$billing_id,
]);
}
+
+ /**
+ * Soft-delete a payment billing by negating company_id on the header and all items.
+ * Blocked if any payment (not yet soft-deleted) is linked to this billing.
+ */
+ public function softDelete(int $billing_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT id, billing_date FROM td_payment_billing WHERE id = :id AND company_id = :cid LIMIT 1"
+ );
+ $sth->execute([':id' => $billing_id, ':cid' => $this->company_id]);
+ $row = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$row) throw new Exception('Payment billing not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($row['billing_date'] ?: date('Y-m-d'), 'Payment billing deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_payment
+ WHERE company_id = :cid AND payment_billing_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $billing_id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — payments are linked to this billing. Delete the payments first.');
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_payment_billing_item SET company_id = company_id * -1
+ WHERE billing_id = :id AND company_id = :cid"
+ )->execute([':id' => $billing_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_payment_billing SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $billing_id, ':cid' => $this->company_id]);
+ }
}
?>
diff --git a/app/assets/utils/classes/PaymentManager.php b/app/assets/utils/classes/PaymentManager.php
index e6d2e13..6eb2abc 100644
--- a/app/assets/utils/classes/PaymentManager.php
+++ b/app/assets/utils/classes/PaymentManager.php
@@ -513,5 +513,53 @@ class PaymentManager {
':company_id' => $this->company_id,
]);
}
+
+ /**
+ * Soft-delete a payment by negating company_id on the header and all items.
+ * Deletes the GL entry if posted. Refreshes settlement and billing status.
+ */
+ public function softDelete(int $payment_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT id, payment_billing_id, payment_date FROM td_payment
+ WHERE company_id = :cid AND id = :id LIMIT 1"
+ );
+ $sth->execute([':cid' => $this->company_id, ':id' => $payment_id]);
+ $payment = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$payment) throw new Exception('Payment not found.');
+
+ $this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment deletion');
+
+ // Collect linked invoice ids before negating
+ $sth2 = $this->pdo->prepare(
+ "SELECT invoice_id FROM td_payment_item
+ WHERE company_id = :cid AND payment_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $payment_id]);
+ $invoice_ids = $sth2->fetchAll(PDO::FETCH_COLUMN);
+
+ // Delete GL entry if posted (no-op if none; throws if period 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('payment', $payment_id);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_payment_item SET company_id = company_id * -1
+ WHERE payment_id = :id AND company_id = :cid"
+ )->execute([':id' => $payment_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_payment SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $payment_id, ':cid' => $this->company_id]);
+
+ foreach ($invoice_ids as $invoice_id) {
+ $this->refreshInvoiceSettlementStatus((int)$invoice_id);
+ }
+ $this->refreshPaymentBillingStatus((int)$payment['payment_billing_id']);
+ }
}
?>
diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php
index e62db71..a840e70 100644
--- a/app/assets/utils/classes/PurchaseOrderManager.php
+++ b/app/assets/utils/classes/PurchaseOrderManager.php
@@ -1,4 +1,5 @@
execute([':id' => $source_id, ':cid' => $this->company_id]);
}
}
+
+ /**
+ * Soft-delete a purchase order by negating company_id on the header, items, and all
+ * linked draft stock-in rows. Blocked if any purchase invoice, supplier return, or
+ * approved (received) stock-in rows exist.
+ */
+ public function softDelete(int $po_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT * FROM td_purchase_order WHERE company_id = :cid AND id = :id"
+ );
+ $sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
+ $po = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$po) throw new Exception('Purchase order not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($po['po_date'] ?: date('Y-m-d'), 'Purchase order deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_invoice
+ WHERE company_id = :cid AND source = 'po' AND source_id = :id AND doc_type = 'purchase_invoice'"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $po_id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — this PO has linked purchase invoices. Delete the invoices first.');
+ }
+
+ $sth3 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_supplier_return
+ WHERE company_id = :cid AND po_id = :id AND status != -1"
+ );
+ $sth3->execute([':cid' => $this->company_id, ':id' => $po_id]);
+ if ((int)$sth3->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — this PO has linked supplier returns. Delete or cancel the returns first.');
+ }
+
+ $sth4 = $this->pdo->prepare(
+ "SELECT table_name FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
+ );
+ $sth4->execute();
+ $tables = $sth4->fetchAll(PDO::FETCH_COLUMN);
+
+ foreach ($tables as $table) {
+ $sth5 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM `{$table}`
+ WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 1"
+ );
+ $sth5->execute([':cid' => $this->company_id, ':id' => $po_id]);
+ if ((int)$sth5->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — received stock from this PO must be reversed via ICS first.');
+ }
+ }
+
+ // Release racks from draft stock-in rows and negate them
+ $whMgmt = new WarehouseManager($this->pdo, $this->company_id);
+ foreach ($tables as $table) {
+ if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) continue;
+ $warehouse_id = (int)$matches[1];
+
+ $row_sth = $this->pdo->prepare(
+ "SELECT id, zone, aisle, rack FROM `{$table}`
+ WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 0"
+ );
+ $row_sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
+ $draft_rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
+
+ foreach ($draft_rows as $row) {
+ $whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE `{$table}` SET company_id = company_id * -1
+ WHERE company_id = :cid AND source = 'po' AND source_id = :id"
+ )->execute([':cid' => $this->company_id, ':id' => $po_id]);
+ }
+
+ // Release converted_qty on source purchase request
+ $source = (string)($po['source'] ?? '');
+ $source_id = (int)($po['source_id'] ?? 0);
+ if ($source === 'purchase_request' && $source_id > 0) {
+ $sth6 = $this->pdo->prepare(
+ "SELECT item_id, quantity FROM td_purchase_order_item
+ WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
+ );
+ $sth6->execute([':id' => $po_id, ':cid' => $this->company_id]);
+ $po_items = $sth6->fetchAll(PDO::FETCH_ASSOC);
+ $dec = $this->pdo->prepare(
+ "UPDATE td_purchase_request_item
+ SET converted_qty = GREATEST(0, converted_qty - :qty)
+ WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
+ );
+ foreach ($po_items as $item) {
+ $dec->execute([
+ ':qty' => (float)($item['quantity'] ?? 0),
+ ':rid' => $source_id,
+ ':item_id' => (int)($item['item_id'] ?? 0),
+ ':cid' => $this->company_id,
+ ]);
+ }
+ $this->pdo->prepare(
+ "UPDATE td_purchase_request SET status = 2
+ WHERE id = :id AND company_id = :cid AND status = 5"
+ )->execute([':id' => $source_id, ':cid' => $this->company_id]);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_purchase_order_item SET company_id = company_id * -1
+ WHERE order_id = :id AND company_id = :cid"
+ )->execute([':id' => $po_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_purchase_order SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $po_id, ':cid' => $this->company_id]);
+ }
}
diff --git a/app/assets/utils/classes/PurchaseRequestManager.php b/app/assets/utils/classes/PurchaseRequestManager.php
index 3091ed0..2067fe0 100644
--- a/app/assets/utils/classes/PurchaseRequestManager.php
+++ b/app/assets/utils/classes/PurchaseRequestManager.php
@@ -1,4 +1,5 @@
execute([':id' => $request_id, ':cid' => $this->company_id]);
}
}
+
+ /**
+ * Soft-delete a purchase request by negating company_id on the header and all items.
+ * Blocked if any purchase order (not yet soft-deleted) was converted from this request.
+ */
+ public function softDelete(int $id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT id, request_date FROM td_purchase_request WHERE id = :id AND company_id = :cid LIMIT 1"
+ );
+ $sth->execute([':id' => $id, ':cid' => $this->company_id]);
+ $row = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$row) throw new Exception('Purchase request not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($row['request_date'] ?: date('Y-m-d'), 'Purchase request deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_purchase_order
+ WHERE company_id = :cid AND source = 'purchase_request' AND source_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — this request has linked purchase orders. Delete the POs first.');
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_purchase_request_item SET company_id = company_id * -1
+ WHERE request_id = :id AND company_id = :cid"
+ )->execute([':id' => $id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_purchase_request SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $id, ':cid' => $this->company_id]);
+ }
}
diff --git a/app/assets/utils/classes/QuotationManager.php b/app/assets/utils/classes/QuotationManager.php
index 0b68a04..e4e3e58 100644
--- a/app/assets/utils/classes/QuotationManager.php
+++ b/app/assets/utils/classes/QuotationManager.php
@@ -1,4 +1,5 @@
execute([':status' => $t['to'], ':id' => $id, ':cid' => $this->company_id]);
}
+ /**
+ * Soft-delete a quotation by negating company_id on the header and all items.
+ * Blocked if any sales order (not yet soft-deleted) was converted from this quotation.
+ */
+ public function softDelete(int $id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT id, quotation_date FROM td_quotation WHERE id = :id AND company_id = :cid LIMIT 1"
+ );
+ $sth->execute([':id' => $id, ':cid' => $this->company_id]);
+ $row = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$row) throw new Exception('Quotation not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($row['quotation_date'] ?: date('Y-m-d'), 'Quotation deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_order
+ WHERE company_id = :cid AND source = 'quotation' AND source_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — this quotation has linked sales orders. Delete the orders first.');
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_quotation_item SET company_id = company_id * -1
+ WHERE quotation_id = :id AND company_id = :cid"
+ )->execute([':id' => $id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_quotation SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $id, ':cid' => $this->company_id]);
+ }
+
/**
* Increment converted_qty per item after a successful order conversion.
* Automatically sets quotation status = 5 when all items are fully converted.
diff --git a/app/assets/utils/classes/ReceiptBillingManager.php b/app/assets/utils/classes/ReceiptBillingManager.php
index 7296080..d102f75 100644
--- a/app/assets/utils/classes/ReceiptBillingManager.php
+++ b/app/assets/utils/classes/ReceiptBillingManager.php
@@ -1,4 +1,5 @@
$billing_id,
]);
}
+
+ /**
+ * Soft-delete a receipt billing by negating company_id on the header and all items.
+ * Blocked if any receipt (not yet soft-deleted) is linked to this billing.
+ */
+ public function softDelete(int $billing_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT id, billing_date FROM td_receipt_billing WHERE id = :id AND company_id = :cid LIMIT 1"
+ );
+ $sth->execute([':id' => $billing_id, ':cid' => $this->company_id]);
+ $row = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$row) throw new Exception('Receipt billing not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($row['billing_date'] ?: date('Y-m-d'), 'Receipt billing deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_receipt
+ WHERE company_id = :cid AND receipt_billing_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $billing_id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — receipts are linked to this billing. Delete the receipts first.');
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_receipt_billing_item SET company_id = company_id * -1
+ WHERE billing_id = :id AND company_id = :cid"
+ )->execute([':id' => $billing_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_receipt_billing SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $billing_id, ':cid' => $this->company_id]);
+ }
}
?>
diff --git a/app/assets/utils/classes/ReceiptManager.php b/app/assets/utils/classes/ReceiptManager.php
index 246eb79..d8a3fe0 100644
--- a/app/assets/utils/classes/ReceiptManager.php
+++ b/app/assets/utils/classes/ReceiptManager.php
@@ -511,5 +511,53 @@ class ReceiptManager {
':company_id' => $this->company_id,
]);
}
+
+ /**
+ * Soft-delete a receipt by negating company_id on the header and all items.
+ * Deletes the GL entry if posted. Refreshes settlement and billing status.
+ */
+ public function softDelete(int $receipt_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT id, receipt_billing_id, receipt_date FROM td_receipt
+ WHERE company_id = :cid AND id = :id LIMIT 1"
+ );
+ $sth->execute([':cid' => $this->company_id, ':id' => $receipt_id]);
+ $receipt = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$receipt) throw new Exception('Receipt not found.');
+
+ $this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt deletion');
+
+ // Collect linked invoice ids before negating
+ $sth2 = $this->pdo->prepare(
+ "SELECT invoice_id FROM td_receipt_item
+ WHERE company_id = :cid AND receipt_id = :id"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $receipt_id]);
+ $invoice_ids = $sth2->fetchAll(PDO::FETCH_COLUMN);
+
+ // Delete GL entry if posted (no-op if none; throws if period 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('receipt', $receipt_id);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_receipt_item SET company_id = company_id * -1
+ WHERE receipt_id = :id AND company_id = :cid"
+ )->execute([':id' => $receipt_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_receipt SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $receipt_id, ':cid' => $this->company_id]);
+
+ foreach ($invoice_ids as $invoice_id) {
+ $this->refreshInvoiceSettlementStatus((int)$invoice_id);
+ }
+ $this->refreshReceiptBillingStatus((int)$receipt['receipt_billing_id']);
+ }
}
?>
diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php
index 432588f..1a03cad 100644
--- a/app/assets/utils/classes/ReturnManager.php
+++ b/app/assets/utils/classes/ReturnManager.php
@@ -1,4 +1,5 @@
$this->company_id,
]);
}
+
+ /**
+ * Soft-delete a return by negating company_id on the header, items, and all linked
+ * stock-in rows. Approved stock-in side effects are reversed first.
+ * Blocked if any credit note (not yet soft-deleted) exists for the linked order.
+ */
+ public function softDelete(int $return_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT * FROM td_return WHERE company_id = :cid AND id = :id"
+ );
+ $sth->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ $return = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$return) throw new Exception('Return not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($return['return_date'] ?: date('Y-m-d'), 'Return deletion');
+ }
+
+ $order_id = (int)$return['order_id'];
+ if ($order_id > 0) {
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_invoice
+ WHERE company_id = :cid AND order_id = :order_id AND doc_type = 'credit_note'"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':order_id' => $order_id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — a credit note exists for this return\'s order. Delete the credit note first.');
+ }
+ }
+
+ // Block if any approved stock-in rows exist; user must reverse via ICS first
+ $sth3 = $this->pdo->prepare(
+ "SELECT table_name FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
+ );
+ $sth3->execute();
+ $tables = $sth3->fetchAll(PDO::FETCH_COLUMN);
+
+ foreach ($tables as $table) {
+ $chk = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM `{$table}`
+ WHERE company_id = :cid AND source = 'return' AND source_id = :id AND status = 1"
+ );
+ $chk->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ if ((int)$chk->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — stock-in for this return has been approved. Reverse via ICS first.');
+ }
+ }
+
+ // Negate draft/pending stock-in rows (status != 1)
+ foreach ($tables as $table) {
+ $this->pdo->prepare(
+ "UPDATE `{$table}` SET company_id = company_id * -1
+ WHERE company_id = :cid AND source = 'return' AND source_id = :id AND status != 1"
+ )->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_return_item SET company_id = company_id * -1
+ WHERE return_id = :id AND company_id = :cid"
+ )->execute([':id' => $return_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_return SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $return_id, ':cid' => $this->company_id]);
+ }
}
diff --git a/app/assets/utils/classes/SupplierReturnManager.php b/app/assets/utils/classes/SupplierReturnManager.php
index 92136ed..bd43275 100644
--- a/app/assets/utils/classes/SupplierReturnManager.php
+++ b/app/assets/utils/classes/SupplierReturnManager.php
@@ -1,4 +1,5 @@
$this->company_id,
]);
}
+
+ /**
+ * Soft-delete a supplier return by negating company_id on the header, items, and all
+ * linked stock-out rows. Approved stock-out side effects are reversed first.
+ * Blocked if any supplier credit note (not yet soft-deleted) exists for this return.
+ */
+ public function softDelete(int $return_id): void
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT * FROM td_supplier_return WHERE company_id = :cid AND id = :id"
+ );
+ $sth->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ $return = $sth->fetch(PDO::FETCH_ASSOC);
+ if (!$return) throw new Exception('Supplier return not found.');
+
+ global $pdo1;
+ if (isset($pdo1) && $pdo1 instanceof PDO) {
+ $guard = new PostingWindowGuard($pdo1, $this->company_id);
+ $guard->assertOpenDate($return['return_date'] ?: date('Y-m-d'), 'Supplier return deletion');
+ }
+
+ $sth2 = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM td_invoice
+ WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id
+ AND doc_type = 'supplier_credit_note'"
+ );
+ $sth2->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ if ((int)$sth2->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — a supplier credit note exists for this return. Delete the credit note first.');
+ }
+
+ // Block if any approved stock-out rows exist; user must reverse via ICS first
+ $sth3 = $this->pdo->prepare(
+ "SELECT table_name FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
+ );
+ $sth3->execute();
+ $tables = $sth3->fetchAll(PDO::FETCH_COLUMN);
+
+ foreach ($tables as $table) {
+ $chk = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM `{$table}`
+ WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id AND status = 1"
+ );
+ $chk->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ if ((int)$chk->fetchColumn() > 0) {
+ throw new Exception('Cannot delete — stock-out for this supplier return has been approved. Reverse via ICS first.');
+ }
+ }
+
+ // Negate draft/pending stock-out rows (status != 1)
+ foreach ($tables as $table) {
+ $this->pdo->prepare(
+ "UPDATE `{$table}` SET company_id = company_id * -1
+ WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id AND status != 1"
+ )->execute([':cid' => $this->company_id, ':id' => $return_id]);
+ }
+
+ $this->pdo->prepare(
+ "UPDATE td_supplier_return_item SET company_id = company_id * -1
+ WHERE return_id = :id AND company_id = :cid"
+ )->execute([':id' => $return_id, ':cid' => $this->company_id]);
+
+ $this->pdo->prepare(
+ "UPDATE td_supplier_return SET company_id = company_id * -1
+ WHERE id = :id AND company_id = :cid"
+ )->execute([':id' => $return_id, ':cid' => $this->company_id]);
+ }
}
diff --git a/app/assets/utils/classes_ac/FinancialReports.php b/app/assets/utils/classes_ac/FinancialReports.php
index 8342df3..f5d3921 100644
--- a/app/assets/utils/classes_ac/FinancialReports.php
+++ b/app/assets/utils/classes_ac/FinancialReports.php
@@ -319,7 +319,7 @@ class FinancialReports
SUM(i.credit) AS total_credit
FROM td_gl g
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
- WHERE g.company_id = :cid
+ WHERE g.company_id = :cid AND g.source_type != 'voided'
GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at
ORDER BY g.created_at DESC
LIMIT {$limit}
diff --git a/app/assets/utils/classes_ac/GlManager.php b/app/assets/utils/classes_ac/GlManager.php
index 9c47523..68198d6 100644
--- a/app/assets/utils/classes_ac/GlManager.php
+++ b/app/assets/utils/classes_ac/GlManager.php
@@ -8,7 +8,9 @@
* Lifecycle:
* post() — first-time GL creation; throws if a record already exists.
* replace() — snapshot current lines into td_gl.history, then delete + re-insert.
- * delete() — create a reversal journal entry (audit trail), then hard-delete the original.
+ * delete() — create a reversal journal entry (debit/credit swapped), then mark the original
+ * source_type = 'voided' so it stays in the table for audit trail but is invisible
+ * to getBySource() lookups.
*/
class GlManager
{
@@ -265,12 +267,10 @@ class GlManager
$this->insertLines((int)$this->pdo->lastInsertId(), $reversal_lines);
}
+ // Mark original as voided — keeps audit trail; getBySource() won't match 'voided' source_type
$this->pdo->prepare(
- "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
- )->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
-
- $this->pdo->prepare(
- "DELETE FROM td_gl WHERE id = :id AND company_id = :cid"
+ "UPDATE td_gl SET source_type = 'voided', updated_at = NOW()
+ WHERE id = :id AND company_id = :cid"
)->execute([':id' => $gl_id, ':cid' => $this->companyId]);
}
diff --git a/app/assets/utils/classes_ac/GlQueryManager.php b/app/assets/utils/classes_ac/GlQueryManager.php
index 653a125..3510a10 100644
--- a/app/assets/utils/classes_ac/GlQueryManager.php
+++ b/app/assets/utils/classes_ac/GlQueryManager.php
@@ -7,7 +7,7 @@ class GlQueryManager
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
- private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order'];
+ private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
public function __construct(PDO $pdo, int $company_id)
{
@@ -50,7 +50,7 @@ class GlQueryManager
$date_from = $this->parseDate($date_from);
$date_to = $this->parseDate($date_to);
- $where = ['g.company_id = :cid'];
+ $where = ['g.company_id = :cid', "g.source_type != 'voided'"];
$params = [':cid' => $this->companyId];
if ($source_type && in_array($source_type, $this->journalTypes, true)) {
diff --git a/app/expense/manage_purchase_order.php b/app/expense/manage_purchase_order.php
index 8bb7011..258be84 100644
--- a/app/expense/manage_purchase_order.php
+++ b/app/expense/manage_purchase_order.php
@@ -129,6 +129,9 @@
+
@@ -139,6 +142,8 @@