Document lifecycle review: spec updates and C2/M9 code fixes

Spec (docs/reviewed/document-lifecycle.md):
- C1: Correct GlManager::delete() description — reversal entry, not hard delete
- C3: Clarify PO status 2/3 are derived (receipt_status), never stored
- C4: Add invoice status=2 (Paid/Settled) to status table
- C5: Add full Receipt/Payment Billing Note lifecycle section
- C6: Add Quotation status table and transition rules
- C7: Document soft-delete tombstone mechanism (company_id negation)

Code (InvoiceManager.php):
- C2: voidInvoice() now blocks on active credit notes, posted receipt
  billing notes, and posted payment billing notes in addition to the
  existing receipt/payment checks
- M9: softDelete() skips assertPostingWindow for draft invoices (status=0)
  since drafts have no GL entry and no accounting impact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thanakorn S
2026-05-26 16:23:46 +07:00
co-authored by Claude Sonnet 4.6
parent 5687b562fb
commit cb36d3b8fd
2 changed files with 197 additions and 2 deletions
+49 -2
View File
@@ -1143,7 +1143,7 @@ class InvoiceManager {
public function softDelete(int $id): void public function softDelete(int $id): void
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT id, doc_type, issued_date FROM td_invoice "SELECT id, doc_type, status, issued_date FROM td_invoice
WHERE company_id = :cid AND id = :id LIMIT 1" WHERE company_id = :cid AND id = :id LIMIT 1"
); );
$sth->execute([':cid' => $this->company_id, ':id' => $id]); $sth->execute([':cid' => $this->company_id, ':id' => $id]);
@@ -1151,7 +1151,10 @@ class InvoiceManager {
if (!$row) throw new Exception('Invoice not found.'); if (!$row) throw new Exception('Invoice not found.');
$doc_type = (string)$row['doc_type']; $doc_type = (string)$row['doc_type'];
$this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $doc_type)) . ' deletion'); // 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)) { if (in_array($doc_type, ['invoice', 'credit_note'], true)) {
$sth2 = $this->pdo->prepare( $sth2 = $this->pdo->prepare(
@@ -1273,6 +1276,35 @@ class InvoiceManager {
if ((int)$sth->fetchColumn() > 0) { if ((int)$sth->fetchColumn() > 0) {
throw new Exception("Cannot void this document while posted receipts are allocated to it. Void the receipt first."); 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)) { if (in_array($row['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) {
@@ -1290,6 +1322,21 @@ class InvoiceManager {
if ((int)$sth->fetchColumn() > 0) { if ((int)$sth->fetchColumn() > 0) {
throw new Exception("Cannot void this document while posted payments are allocated to it. Void the payment first."); 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 = json_decode($row['log'] ?? '[]', true) ?: [];
+148
View File
@@ -0,0 +1,148 @@
# Document Lifecycle And Status
## Status Values
Document status is stored as an integer column on each transaction table. The meaning is consistent across most document types but not guaranteed to be identical everywhere — always read the manager before assuming.
### Sales and Purchase Invoices (`td_invoice`, all `doc_type` values)
| Status | Meaning |
|---|---|
| `0` | Draft |
| `1` | Issued / Active |
| `2` | Paid / Settled (fully allocated) |
| `4` | Void |
`doc_type` values on `td_invoice`: `invoice`, `credit_note`, `purchase_invoice`, `supplier_credit_note`.
Voiding is handled by a shared status endpoint. When a document is voided, `InvoiceManager` calls `GlManager::delete()` to remove any posted GL entry. Void is blocked if settlement documents (receipts or payments) are still allocated to the invoice — those must be voided first.
### Receipts and Payments (`td_receipt`, `td_payment`)
| Status | Meaning |
|---|---|
| `1` | Active / posted business document |
| `4` | Void |
Void calls `GlManager::delete()` through `ReceiptManager` and `PaymentManager` respectively. Posting window is enforced on both save and void.
### Receipt Billing Notes and Payment Billing Notes (`td_receipt_billing`, `td_payment_billing`)
Billing notes group one or more invoices into a single collection document sent to a customer (receipt billing) or supplier (payment billing). Each line in `td_receipt_billing_item` / `td_payment_billing_item` references a `td_invoice.id` and carries an allocated amount.
| Status | Meaning |
|---|---|
| `1` | Open — no receipts / payments posted against it yet |
| `2` | Fully received / paid — allocated receipts or payments equal the billing total |
| `3` | Partially received / paid — some but not all receipts or payments have been posted |
| `4` | Void |
Status is never set manually — `ReceiptBillingManager::refreshBillingStatus()` and `PaymentBillingManager::refreshBillingStatus()` derive and write the correct value after each receipt or payment change.
Void is blocked if any posted receipt (`td_receipt.status = 1`) or payment (`td_payment.status = 1`) is allocated to the billing note — those must be voided first. Voiding a billing note does **not** void the invoices it references. Invoices cannot be voided while an active billing note (`status` ∈ `{1, 2, 3}`) references them.
### Quotations (`td_quotation`)
| Status | Meaning |
|---|---|
| `-1` | Cancelled |
| `0` | Draft |
| `1` | Sent |
| `2` | Accepted |
| `3` | Rejected |
| `5` | Converted |
Status transitions are enforced by `QuotationManager::updateStatus()`:
- `0 → 1` (send), `1 → 2` (accept), `1 → 3` (reject)
- `1 / 2 / 3 → 0` (reopen) — blocked if any item already has `converted_qty > 0`
- `0 / 1 → -1` (cancel)
`status = 5` is never set via `updateStatus()`. It is written automatically by `QuotationManager::incrementConvertedQty()` once all line items are fully converted to a sales order. `OrderManager::linkQuotationToOrder()` calls this after a successful order creation. When the linked order is later cancelled or soft-deleted, `OrderManager` reverts the quotation back to `status = 2` (Accepted).
### Sales Orders (`td_order`)
| Status | Meaning |
|---|---|
| `-2` | Pending warehouse selection or source completion |
| `-1` | Cancelled |
| `0` | Draft |
| `1` | Confirmed |
Confirmation can create stock-out rows. Cancellation reverses stock-out rows where required and is blocked when active invoices or returns exist.
### Purchase Orders (`td_purchase_order`)
| Status | Meaning |
|---|---|
| `-2` | Pending warehouse selection or source completion |
| `-1` | Cancelled |
| `0` | Draft |
| `1` | Confirmed |
`td_purchase_order.status` only ever holds the values above. **Partially received** and **fully received** are not stored statuses — they are derived on the fly by `PurchaseOrderManager::deriveReceiptStatus()` into a virtual `receipt_status` field returned alongside the PO row. This derived value reflects the ratio of approved stock-in rows to ordered quantities and is never written back to `td_purchase_order.status`.
Receiving creates stock-in rows. Close and cancel rules apply based on receiving progress and approved stock-in rows.
### Purchase Requests (`td_purchase_request`)
| Status | Meaning |
|---|---|
| `-1` | Cancelled |
| `0` | Draft |
| `1` | Submitted |
| `2` | Approved / reopened after conversion rollback |
| `3` | Rejected |
| `5` | Converted |
Converted requests have a linked purchase order.
### Returns and Supplier Returns (`td_return`, `td_supplier_return`)
| Status | Meaning |
|---|---|
| `-1` | Cancelled |
| `0` | Draft |
| `1` | Confirmed |
Confirmation creates stock-in (sales return) or stock-out (supplier return) rows. Conversion creates a credit note or supplier credit note document via `InvoiceManager`.
### Stock Entries (`td_stock_<warehouse_id>`)
Draft stock entries await approval. Approved entries update warehouse balances. Approved entries cannot be deleted without reversal; editing and deletion are also blocked outside the posting window.
## Void And Soft Delete Strategy
The current strategy (as of 2026-05-19):
- **Draft or unposted documents** may be soft-deleted or cancelled freely.
- **Posted or issued documents** use a void workflow. The source document is preserved in audit history. Void removes the linked GL entry through `GlManager::delete()` and enforces the posting window.
- **Master data** is soft-deleted by setting `status = 0`. Deletion is blocked when active documents reference the record.
Void convention uses `status = 4` for `td_invoice` document types (covers `invoice`, `credit_note`, `purchase_invoice`, `supplier_credit_note`). Credit notes are considered inactive at `status = 4`, not `status = -1`.
### Soft-delete tombstone mechanism
Draft transaction documents (invoices, receipts, payments, billing notes, orders, quotations, returns, etc.) are soft-deleted by **negating `company_id`** on the header row and all child item rows:
```sql
UPDATE td_invoice SET company_id = company_id * -1 WHERE id = :id AND company_id = :cid
UPDATE td_invoice_item SET company_id = company_id * -1 WHERE invoice_id = :id AND company_id = :cid
```
This is the tombstone convention used by every `softDelete()` method across all managers. Because every read query filters `WHERE company_id = :cid` (positive), tombstoned rows are automatically excluded without any additional flag. Any query that omits the `company_id` filter will expose tombstoned rows — treat this filter as mandatory.
`deleted_at`, `deleted_by`, and `delete_reason` audit columns are not yet implemented.
## GL Versioning
When a source document is posted to the GL more than once (re-post after edit), `GlManager::replace()` is called instead of `GlManager::post()`. The replace path:
1. Reads the current `td_gl` and `td_gl_item` rows.
2. Appends a snapshot of the current lines as JSON into the `td_gl.history` column.
3. Increments `td_gl.current_version`.
4. Writes the new lines into `td_gl_item`.
This means the full posting history for a document is preserved in `td_gl.history` as a versioned JSON array. `GlManager::delete()` creates a **reversal journal entry** (debits and credits swapped, lines prefixed with `VOID:`) linked to the original `source_id`, then tombstones the original `td_gl` row. No rows are physically deleted — the full audit trail is preserved in the database.
Manual journal entries use `GlManager::postManual()` and `GlManager::replaceManual()` which follow the same versioning pattern.