accounting workflows

This commit is contained in:
Thanakorn S
2026-05-20 10:17:02 +07:00
parent 04a683bd02
commit 7396db6ffc
199 changed files with 23539 additions and 5345 deletions
@@ -0,0 +1,243 @@
<?php
class AccountFormulaManager
{
private PDO $pdo;
private int $companyId;
private array $documentTypes = [
'invoice',
'credit_note',
'receipt',
'purchase_invoice',
'supplier_credit_note',
'payment',
];
private array $amountKeys = ['grand_total', 'total', 'tax', 'amount'];
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT f.*,
COUNT(i.id) AS item_count
FROM md_account_formula f
LEFT JOIN md_account_formula_item i
ON i.company_id = f.company_id
AND i.formula_id = f.id
WHERE f.company_id = :cid
GROUP BY f.id
ORDER BY f.document_type ASC, f.is_default DESC, f.formula_name ASC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getByType(string $document_type): array
{
$sth = $this->pdo->prepare(
"SELECT id, formula_name, is_default
FROM md_account_formula
WHERE company_id = :cid
AND document_type = :doc_type
AND status = 1
ORDER BY is_default DESC, formula_name ASC"
);
$sth->execute([':cid' => $this->companyId, ':doc_type' => $document_type]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_account_formula
WHERE company_id = :cid AND id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$formula = $sth->fetch(PDO::FETCH_ASSOC);
if (!$formula) return null;
$sth = $this->pdo->prepare(
"SELECT i.*, a.account_name, a.account_type
FROM md_account_formula_item i
LEFT JOIN md_account a
ON a.company_id = i.company_id
AND a.account_code = i.account_code
WHERE i.company_id = :cid
AND i.formula_id = :formula_id
ORDER BY i.sort_order ASC, i.id ASC"
);
$sth->execute([':cid' => $this->companyId, ':formula_id' => $id]);
$formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC);
return $formula;
}
public function save(array $data): int
{
$id = (int)($data['id'] ?? 0);
$name = trim((string)($data['formula_name'] ?? ''));
$documentType = trim((string)($data['document_type'] ?? ''));
$description = trim((string)($data['description'] ?? ''));
$isDefault = (int)($data['is_default'] ?? 0) === 1 ? 1 : 0;
$status = (int)($data['status'] ?? 1) === 1 ? 1 : 0;
$items = $data['items'] ?? [];
if (is_string($items)) {
$items = json_decode($items, true) ?: [];
}
if ($name === '') throw new Exception('Formula name is required.');
if (!in_array($documentType, $this->documentTypes, true)) {
throw new Exception('Invalid document type.');
}
$validated = $this->validateItems($items);
if (!$validated) throw new Exception('Formula requires at least one debit and one credit item.');
$debitKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'D'), 'amount_key'));
$creditKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'C'), 'amount_key'));
if (!$debitKeys || !$creditKeys) {
throw new Exception('Formula requires at least one debit and one credit item.');
}
if ($isDefault) {
$this->pdo->prepare(
"UPDATE md_account_formula
SET is_default = 0
WHERE company_id = :cid AND document_type = :document_type"
)->execute([':cid' => $this->companyId, ':document_type' => $documentType]);
}
if ($id > 0) {
$sth = $this->pdo->prepare(
"UPDATE md_account_formula SET
formula_name = :formula_name,
document_type = :document_type,
description = :description,
is_default = :is_default,
status = :status,
updated_at = :updated_at
WHERE company_id = :cid AND id = :id"
);
$sth->execute([
':cid' => $this->companyId,
':id' => $id,
':formula_name' => $name,
':document_type' => $documentType,
':description' => $description !== '' ? $description : null,
':is_default' => $isDefault,
':status' => $status,
':updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$sth = $this->pdo->prepare(
"INSERT INTO md_account_formula
(company_id, formula_name, document_type, description, is_default, status, created_at)
VALUES
(:cid, :formula_name, :document_type, :description, :is_default, :status, :created_at)"
);
$sth->execute([
':cid' => $this->companyId,
':formula_name' => $name,
':document_type' => $documentType,
':description' => $description !== '' ? $description : null,
':is_default' => $isDefault,
':status' => $status,
':created_at' => date('Y-m-d H:i:s'),
]);
$id = (int)$this->pdo->lastInsertId();
}
$this->replaceItems($id, $validated);
return $id;
}
public function delete(int $id): void
{
$this->pdo->prepare(
"UPDATE md_account_formula
SET status = 0, is_default = 0, updated_at = :updated_at
WHERE company_id = :cid AND id = :id"
)->execute([
':cid' => $this->companyId,
':id' => $id,
':updated_at' => date('Y-m-d H:i:s'),
]);
}
private function validateItems(array $items): array
{
$validated = [];
$sort = 1;
foreach ($items as $item) {
$drcr = strtoupper(trim((string)($item['drcr'] ?? '')));
$accountCode = trim((string)($item['account_code'] ?? ''));
$amountKey = trim((string)($item['amount_key'] ?? ''));
$description = trim((string)($item['description'] ?? ''));
if (!in_array($drcr, ['D', 'C'], true) || $accountCode === '' || $amountKey === '') continue;
if (!in_array($amountKey, $this->amountKeys, true)) throw new Exception('Invalid amount key.');
if (!$this->isPostingAccount($accountCode)) {
throw new Exception($accountCode . ' is not an active posting account.');
}
$validated[] = [
'drcr' => $drcr,
'account_code' => $accountCode,
'amount_key' => $amountKey,
'description' => $description,
'sort_order' => $sort++,
];
}
return $validated;
}
private function isPostingAccount(string $accountCode): bool
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*)
FROM md_account
WHERE company_id = :cid
AND account_code = :account_code
AND is_posting = 1
AND status = 1"
);
$sth->execute([':cid' => $this->companyId, ':account_code' => $accountCode]);
return (int)$sth->fetchColumn() > 0;
}
private function replaceItems(int $formulaId, array $items): void
{
$this->pdo->prepare(
"DELETE FROM md_account_formula_item
WHERE company_id = :cid AND formula_id = :formula_id"
)->execute([':cid' => $this->companyId, ':formula_id' => $formulaId]);
$sth = $this->pdo->prepare(
"INSERT INTO md_account_formula_item
(company_id, formula_id, drcr, account_code, amount_key, description, sort_order)
VALUES
(:cid, :formula_id, :drcr, :account_code, :amount_key, :description, :sort_order)"
);
foreach ($items as $item) {
$sth->execute([
':cid' => $this->companyId,
':formula_id' => $formulaId,
':drcr' => $item['drcr'],
':account_code' => $item['account_code'],
':amount_key' => $item['amount_key'],
':description' => $item['description'] !== '' ? $item['description'] : null,
':sort_order' => $item['sort_order'],
]);
}
}
}
?>
+49 -35
View File
@@ -13,7 +13,7 @@ class ChartOfAccounts
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
"SELECT * FROM md_account
WHERE company_id = :cid
ORDER BY account_code ASC"
);
@@ -24,7 +24,7 @@ class ChartOfAccounts
public function getByCode(string $account_code): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
"SELECT * FROM md_account
WHERE company_id = :cid AND account_code = :code
LIMIT 1"
);
@@ -36,7 +36,7 @@ class ChartOfAccounts
public function getByType(string $account_type): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
"SELECT * FROM md_account
WHERE company_id = :cid AND account_type = :type AND status = 1
ORDER BY account_code ASC"
);
@@ -47,7 +47,7 @@ class ChartOfAccounts
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
"SELECT * FROM md_account
WHERE company_id = :cid AND id = :id
LIMIT 1"
);
@@ -59,19 +59,20 @@ class ChartOfAccounts
public function create(array $data): int
{
$sth = $this->pdo->prepare(
"INSERT INTO ac_account
(company_id, account_code, account_name, account_type, parent_code, is_posting, status)
"INSERT INTO md_account
(company_id, account_code, account_name, account_type, account_category, parent_code, is_posting, status)
VALUES
(:cid, :code, :name, :type, :parent, :posting, :status)"
(:cid, :code, :name, :type, :category, :parent, :posting, :status)"
);
$sth->execute([
':cid' => $this->companyId,
':code' => trim($data['account_code']),
':name' => trim($data['account_name']),
':type' => $data['account_type'],
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
':posting' => (int)($data['is_posting'] ?? 1),
':status' => (int)($data['status'] ?? 1),
':cid' => $this->companyId,
':code' => trim($data['account_code']),
':name' => trim($data['account_name']),
':type' => $data['account_type'],
':category' => !empty($data['account_category']) ? trim($data['account_category']) : '',
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
':posting' => (int)($data['is_posting'] ?? 1),
':status' => (int)($data['status'] ?? 1),
]);
return (int)$this->pdo->lastInsertId();
}
@@ -79,31 +80,33 @@ class ChartOfAccounts
public function update(int $id, array $data): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_account SET
account_code = :code,
account_name = :name,
account_type = :type,
parent_code = :parent,
is_posting = :posting,
status = :status
"UPDATE md_account SET
account_code = :code,
account_name = :name,
account_type = :type,
account_category = :category,
parent_code = :parent,
is_posting = :posting,
status = :status
WHERE company_id = :cid AND id = :id"
);
$sth->execute([
':cid' => $this->companyId,
':id' => $id,
':code' => trim($data['account_code']),
':name' => trim($data['account_name']),
':type' => $data['account_type'],
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
':posting' => (int)($data['is_posting'] ?? 1),
':status' => (int)($data['status'] ?? 1),
':cid' => $this->companyId,
':id' => $id,
':code' => trim($data['account_code']),
':name' => trim($data['account_name']),
':type' => $data['account_type'],
':category' => !empty($data['account_category']) ? trim($data['account_category']) : '',
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
':posting' => (int)($data['is_posting'] ?? 1),
':status' => (int)($data['status'] ?? 1),
]);
}
public function delete(int $id): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_account SET status = 0
"UPDATE md_account SET status = 0
WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
@@ -112,7 +115,7 @@ class ChartOfAccounts
public function isPostingAccount(string $account_code): bool
{
$sth = $this->pdo->prepare(
"SELECT is_posting FROM ac_account
"SELECT is_posting FROM md_account
WHERE company_id = :cid AND account_code = :code AND status = 1
LIMIT 1"
);
@@ -124,18 +127,29 @@ class ChartOfAccounts
{
$sth = $this->pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(status = 1) AS active,
SUM(status = 0) AS inactive,
COUNT(*) AS total,
SUM(status = 1) AS active,
SUM(status = 0) AS inactive,
SUM(account_type = 'asset') AS asset,
SUM(account_type = 'liability') AS liability,
SUM(account_type = 'equity') AS equity,
SUM(account_type = 'revenue') AS revenue,
SUM(account_type = 'expense') AS expense
FROM ac_account
FROM md_account
WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
public function getByCategory(string $category): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_account
WHERE company_id = :cid AND account_category = :cat AND status = 1
ORDER BY account_code ASC"
);
$sth->execute([':cid' => $this->companyId, ':cat' => $category]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
}
@@ -13,7 +13,7 @@ class DepartmentManager
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_department
"SELECT * FROM md_department
WHERE company_id = :cid
ORDER BY dept_code ASC"
);
@@ -24,7 +24,7 @@ class DepartmentManager
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_department
"SELECT * FROM md_department
WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
@@ -35,7 +35,7 @@ class DepartmentManager
public function create(array $data): int
{
$sth = $this->pdo->prepare(
"INSERT INTO ac_department (company_id, dept_code, dept_name, description, status)
"INSERT INTO md_department (company_id, dept_code, dept_name, description, status)
VALUES (:cid, :code, :name, :desc, :status)"
);
$sth->execute([
@@ -51,7 +51,7 @@ class DepartmentManager
public function update(int $id, array $data): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_department SET
"UPDATE md_department SET
dept_code = :code,
dept_name = :name,
description = :desc,
@@ -71,7 +71,7 @@ class DepartmentManager
public function delete(int $id): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_department SET status = 0
"UPDATE md_department SET status = 0
WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
@@ -83,7 +83,7 @@ class DepartmentManager
"SELECT COUNT(*) AS total,
SUM(status = 1) AS active,
SUM(status = 0) AS inactive
FROM ac_department WHERE company_id = :cid"
FROM md_department WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
@@ -2,8 +2,8 @@
/**
* FinancialReports
*
* Generates all financial statements from ac_balance + ac_journal_line.
* Read-only — no writes.
* Generates all financial statements from td_gl + td_gl_item joined with md_account.
* Read-only — no writes. All results are reusable across API endpoints and future reports.
*/
class FinancialReports
{
@@ -16,8 +16,476 @@ class FinancialReports
$this->companyId = $company_id;
}
// TODO: getTrialBalance(string $from_period, string $to_period, ?int $dept_id): array
// TODO: getProfitAndLoss(string $from_period, string $to_period, ?int $dept_id): array
// TODO: getBalanceSheet(string $as_of_period, ?int $dept_id): array
// TODO: getGLMovement(string $account_code, string $from_period, string $to_period): array
// ── Trial Balance ─────────────────────────────────────────────────────────
// Returns every posting account with brought-forward balance, period movement,
// and carry-forward balance for the selected date range.
private function normalizeDate(string $date, bool $end_of_month = false): string
{
$date = trim($date);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}$/', $date)) {
return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01';
}
return date('Y-m-d');
}
public function getTrialBalance(string $from_date, string $to_date, int $dept_id = 0): array
{
$from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true);
$sth = $this->pdo->prepare("
SELECT
a.account_code,
a.account_name,
a.account_type,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.debit ELSE 0 END), 0) AS opening_debit,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.credit ELSE 0 END), 0) AS opening_credit,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.debit ELSE 0 END), 0) AS period_debit,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.credit ELSE 0 END), 0) AS period_credit
FROM md_account a
LEFT JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
LEFT JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :to_date
WHERE a.company_id = :cid AND a.is_posting = 1 AND a.status = 1
GROUP BY a.account_code, a.account_name, a.account_type
HAVING opening_debit <> 0
OR opening_credit <> 0
OR period_debit <> 0
OR period_credit <> 0
ORDER BY a.account_code
");
$sth->execute([
':cid' => $this->companyId,
':from_date' => $from_date,
':to_date' => $to_date,
':dept_id' => $dept_id,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$total_opening_debit = 0.0;
$total_opening_credit = 0.0;
$total_period_debit = 0.0;
$total_period_credit = 0.0;
$total_closing_debit = 0.0;
$total_closing_credit = 0.0;
foreach ($rows as &$row) {
$opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit'];
$period_debit = (float)$row['period_debit'];
$period_credit = (float)$row['period_credit'];
$closing_balance = $opening_balance + $period_debit - $period_credit;
$row['opening_balance'] = $opening_balance;
$row['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
$row['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
$row['period_debit'] = $period_debit;
$row['period_credit'] = $period_credit;
$row['closing_balance'] = $closing_balance;
$row['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
$row['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
$total_opening_debit += $row['opening_debit'];
$total_opening_credit += $row['opening_credit'];
$total_period_debit += $row['period_debit'];
$total_period_credit += $row['period_credit'];
$total_closing_debit += $row['closing_debit'];
$total_closing_credit += $row['closing_credit'];
}
unset($row);
return [
'rows' => $rows,
'total_opening_debit' => $total_opening_debit,
'total_opening_credit' => $total_opening_credit,
'total_period_debit' => $total_period_debit,
'total_period_credit' => $total_period_credit,
'total_closing_debit' => $total_closing_debit,
'total_closing_credit' => $total_closing_credit,
'total_debit' => $total_closing_debit,
'total_credit' => $total_closing_credit,
'from_date' => $from_date,
'to_date' => $to_date,
'from_period' => substr($from_date, 0, 7),
'to_period' => substr($to_date, 0, 7),
];
}
// ── Profit & Loss ─────────────────────────────────────────────────────────
// Returns revenue and expense accounts for the date range.
// revenue net = credit - debit; expense net = debit - credit (normal balances).
public function getProfitAndLoss(string $from_date, string $to_date, int $dept_id = 0): array
{
$from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true);
$sth = $this->pdo->prepare("
SELECT
a.account_code,
a.account_name,
a.account_type,
COALESCE(SUM(i.debit), 0) AS total_debit,
COALESCE(SUM(i.credit), 0) AS total_credit
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
WHERE a.company_id = :cid
AND a.account_type IN ('revenue','expense')
AND a.is_posting = 1
GROUP BY a.account_code, a.account_name, a.account_type
ORDER BY a.account_type DESC, a.account_code
");
$sth->execute([
':cid' => $this->companyId,
':from_date' => $from_date,
':to_date' => $to_date,
':dept_id' => $dept_id,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$total_revenue = 0.0;
$total_expense = 0.0;
foreach ($rows as $r) {
if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit'];
if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit'];
}
return [
'rows' => $rows,
'total_revenue' => $total_revenue,
'total_expense' => $total_expense,
'net_profit' => $total_revenue - $total_expense,
'from_date' => $from_date,
'to_date' => $to_date,
'from_period' => substr($from_date, 0, 7),
'to_period' => substr($to_date, 0, 7),
];
}
// ── Balance Sheet ─────────────────────────────────────────────────────────
// Cumulative balances for asset/liability/equity accounts up to as_of_period.
// Retained earnings = cumulative net P&L (revenue - expense) through that period.
public function getBalanceSheet(string $as_of_date, int $dept_id = 0): array
{
$as_of_date = $this->normalizeDate($as_of_date, true);
$sth = $this->pdo->prepare("
SELECT
a.account_code,
a.account_name,
a.account_type,
COALESCE(SUM(i.debit), 0) AS total_debit,
COALESCE(SUM(i.credit), 0) AS total_credit
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
WHERE a.company_id = :cid
AND a.account_type IN ('asset','liability','equity')
AND a.is_posting = 1 AND a.status = 1
GROUP BY a.account_code, a.account_name, a.account_type
ORDER BY a.account_type, a.account_code
");
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$sth = $this->pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN a.account_type = 'revenue'
THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue,
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
WHERE a.company_id = :cid
AND a.account_type IN ('revenue','expense')
AND a.is_posting = 1
");
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
$pl = $sth->fetch(PDO::FETCH_ASSOC);
return [
'rows' => $rows,
'retained_earnings' => (float)($pl['net_revenue'] ?? 0) - (float)($pl['net_expense'] ?? 0),
'as_of_date' => $as_of_date,
'as_of_period' => substr($as_of_date, 0, 7),
];
}
// ── Monthly Trend ─────────────────────────────────────────────────────────
// Revenue vs expense per period for the last N months (defaults to 6).
// Returns rows: [{period, revenue, expense}]
public function getMonthlyTrend(int $months = 6): array
{
$from = date('Y-m', strtotime('-' . ($months - 1) . ' months'));
$sth = $this->pdo->prepare("
SELECT
g.period,
SUM(CASE WHEN a.account_type = 'revenue' THEN i.credit - i.debit ELSE 0 END) AS revenue,
SUM(CASE WHEN a.account_type = 'expense' THEN i.debit - i.credit ELSE 0 END) AS expense
FROM td_gl g
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
WHERE g.company_id = :cid AND g.period >= :from_period
GROUP BY g.period
ORDER BY g.period ASC
");
$sth->execute([':cid' => $this->companyId, ':from_period' => $from]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ── Dashboard focused methods ─────────────────────────────────────────────
// One method per dashboard card/section. Used by separate REST endpoints.
public function getDashboardPL(): array
{
$sth = $this->pdo->prepare("
SELECT
a.account_type,
COALESCE(SUM(i.debit), 0) AS total_debit,
COALESCE(SUM(i.credit), 0) AS total_credit
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id AND i.account_code = a.account_code
JOIN td_gl g ON g.company_id = i.company_id AND g.id = i.gl_id
WHERE a.company_id = :cid
AND a.account_type IN ('revenue', 'expense')
AND a.is_posting = 1
GROUP BY a.account_type
");
$sth->execute([':cid' => $this->companyId]);
$revenue = 0.0;
$expense = 0.0;
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($row['account_type'] === 'revenue') $revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
if ($row['account_type'] === 'expense') $expense += (float)$row['total_debit'] - (float)$row['total_credit'];
}
return ['revenue' => $revenue, 'expense' => $expense, 'net_profit' => $revenue - $expense];
}
public function getDashboardJournalCount(): int
{
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid");
$sth->execute([':cid' => $this->companyId]);
return (int)$sth->fetchColumn();
}
public function getDashboardBySource(): array
{
$sth = $this->pdo->prepare("
SELECT source_type, COUNT(*) AS cnt
FROM td_gl
WHERE company_id = :cid
GROUP BY source_type
ORDER BY cnt DESC
");
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getDashboardRecentEntries(int $limit = 10): array
{
$limit = max(1, min(100, $limit));
$sth = $this->pdo->prepare("
SELECT
g.id, g.source_type,
COALESCE(g.reference, '') AS reference,
COALESCE(g.description, '') AS description,
COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date,
g.period,
SUM(i.debit) AS total_debit,
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
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}
");
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getDashboardSummary(int $trend_months = 6, int $recent_limit = 10): array
{
$sth = $this->pdo->prepare("
SELECT
a.account_type,
COALESCE(SUM(i.debit), 0) AS total_debit,
COALESCE(SUM(i.credit), 0) AS total_credit
FROM md_account a
JOIN td_gl_item i
ON i.company_id = a.company_id
AND i.account_code = a.account_code
JOIN td_gl g
ON g.company_id = i.company_id
AND g.id = i.gl_id
WHERE a.company_id = :cid
AND a.account_type IN ('revenue', 'expense')
AND a.is_posting = 1
GROUP BY a.account_type
");
$sth->execute([':cid' => $this->companyId]);
$total_revenue = 0.0;
$total_expense = 0.0;
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($row['account_type'] === 'revenue') {
$total_revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
}
if ($row['account_type'] === 'expense') {
$total_expense += (float)$row['total_debit'] - (float)$row['total_credit'];
}
}
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid");
$sth->execute([':cid' => $this->companyId]);
$journal_count = (int)$sth->fetchColumn();
$sth = $this->pdo->prepare("
SELECT source_type, COUNT(*) AS cnt
FROM td_gl
WHERE company_id = :cid
GROUP BY source_type
ORDER BY cnt DESC
");
$sth->execute([':cid' => $this->companyId]);
$by_source = $sth->fetchAll(PDO::FETCH_ASSOC);
$sth = $this->pdo->prepare("
SELECT
g.id, g.source_type,
COALESCE(g.reference, '') AS reference,
COALESCE(g.description, '') AS description,
COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date,
g.period,
SUM(i.debit) AS total_debit,
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
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 {$recent_limit}
");
$sth->execute([':cid' => $this->companyId]);
$recent = $sth->fetchAll(PDO::FETCH_ASSOC);
return [
'revenue' => $total_revenue,
'expense' => $total_expense,
'net_profit' => $total_revenue - $total_expense,
'journal_count' => $journal_count,
'trend' => $this->getMonthlyTrend($trend_months),
'by_source' => $by_source,
'recent' => $recent,
];
}
// ── GL Movement ───────────────────────────────────────────────────────────
// Per-account ledger: opening balance + chronological lines with running balance.
public function getGLMovement(string $account_code, string $from_date, string $to_date, int $dept_id = 0): array
{
$from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true);
// Opening balance = all movements before from_date
$sth = $this->pdo->prepare("
SELECT
COALESCE(SUM(i.debit), 0) AS opening_debit,
COALESCE(SUM(i.credit), 0) AS opening_credit
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND i.account_code = :code
AND (:dept_id = 0 OR i.department_id = :dept_id)
AND COALESCE(g.journal_date, DATE(g.created_at)) < :from_date
");
$sth->execute([
':cid' => $this->companyId,
':code' => $account_code,
':from_date' => $from_date,
':dept_id' => $dept_id,
]);
$ob = $sth->fetch(PDO::FETCH_ASSOC);
$opening_balance = (float)$ob['opening_debit'] - (float)$ob['opening_credit'];
// Period lines
$sth = $this->pdo->prepare("
SELECT
g.id AS gl_id,
g.source_type,
COALESCE(g.reference, '') AS reference,
COALESCE(g.description, '') AS gl_description,
g.period,
i.department_id,
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name,
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
i.debit,
i.credit,
COALESCE(i.description, '') AS line_description
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id
WHERE i.company_id = :cid
AND i.account_code = :code
AND (:dept_id = 0 OR i.department_id = :dept_id)
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
ORDER BY g.journal_date ASC, g.id ASC, i.id ASC
");
$sth->execute([
':cid' => $this->companyId,
':code' => $account_code,
':from_date' => $from_date,
':to_date' => $to_date,
':dept_id' => $dept_id,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
// Account metadata
$sth = $this->pdo->prepare(
"SELECT account_name, account_type
FROM md_account
WHERE company_id = :cid AND account_code = :code LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':code' => $account_code]);
$acct = $sth->fetch(PDO::FETCH_ASSOC);
return [
'account_code' => $account_code,
'account_name' => $acct['account_name'] ?? '',
'account_type' => $acct['account_type'] ?? '',
'opening_balance' => $opening_balance,
'rows' => $rows,
'from_date' => $from_date,
'to_date' => $to_date,
'from_period' => substr($from_date, 0, 7),
'to_period' => substr($to_date, 0, 7),
];
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
/**
* GlManager
*
* Owns all read/write operations on td_gl and td_gl_item.
* Called by PostingEngine after building the journal lines.
*
* 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() — hard-delete both td_gl and all its td_gl_item rows (used on void).
*/
class GlManager
{
private PDO $pdo;
private int $companyId;
private ?PostingWindowGuard $postingWindowGuard;
public function __construct(PDO $pdo, int $company_id, ?PostingWindowGuard $postingWindowGuard = null)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
$this->postingWindowGuard = $postingWindowGuard;
}
// ── public ───────────────────────────────────────────────────────────────
public function getBySource(string $source_type, int $source_id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_gl
WHERE company_id = :cid
AND source_type = :source_type
AND source_id = :source_id
LIMIT 1"
);
$sth->execute([
':cid' => $this->companyId,
':source_type' => $source_type,
':source_id' => $source_id,
]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
public function post(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void
{
$journal_date = $this->resolveJournalDate($meta['journal_date'] ?? null, $period);
$this->assertPostingWindow($journal_date, 'GL posting');
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"INSERT INTO td_gl
(company_id, source_type, source_id, reference, description, journal_date,
formula_id, period, current_version, history, created_at, updated_at)
VALUES
(:cid, :source_type, :source_id, :reference, :description, :journal_date,
:formula_id, :period, 1, '[]', :created_at, :updated_at)"
);
$sth->execute([
':cid' => $this->companyId,
':source_type' => $source_type,
':source_id' => $source_id,
':reference' => $meta['reference'] ?? '',
':description' => $meta['description'] ?? '',
':journal_date' => $journal_date,
':formula_id' => $formula_id,
':period' => $period,
':created_at' => $now,
':updated_at' => $now,
]);
$this->insertLines((int)$this->pdo->lastInsertId(), $lines);
}
public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void
{
$this->assertPostingWindow($journal_date, 'Manual journal');
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"SELECT id, current_version, formula_id, history
FROM td_gl
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
FOR UPDATE"
);
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
$gl = $sth->fetch(PDO::FETCH_ASSOC);
if (!$gl) throw new Exception("Manual GL entry #$gl_id not found.");
$version = (int)$gl['current_version'];
$history = json_decode($gl['history'], true) ?: [];
$history[] = [
'version' => $version,
'formula_id' => 0,
'replaced_at' => $now,
'lines' => $this->getLines($gl_id),
];
$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(
"UPDATE td_gl
SET reference = :reference, description = :description,
journal_date = :journal_date, period = :period,
current_version = :current_version, history = :history,
updated_at = :updated_at
WHERE id = :id"
)->execute([
':reference' => $reference,
':description' => $description,
':journal_date' => $journal_date,
':period' => $period,
':current_version' => $version + 1,
':history' => json_encode($history),
':updated_at' => $now,
':id' => $gl_id,
]);
$this->insertLines($gl_id, $lines);
}
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int
{
$this->assertPostingWindow($journal_date, 'Manual journal');
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"INSERT INTO td_gl
(company_id, source_type, source_id, reference, description, journal_date,
formula_id, period, current_version, history, created_at, updated_at)
VALUES
(:cid, 'manual', 0, :reference, :description, :journal_date,
0, :period, 1, '[]', :created_at, :updated_at)"
);
$sth->execute([
':cid' => $this->companyId,
':reference' => $reference,
':description' => $description,
':journal_date' => $journal_date,
':period' => $period,
':created_at' => $now,
':updated_at' => $now,
]);
$gl_id = (int)$this->pdo->lastInsertId();
$this->insertLines($gl_id, $lines);
return $gl_id;
}
public function replace(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void
{
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"SELECT id, current_version, formula_id, history, journal_date
FROM td_gl
WHERE company_id = :cid
AND source_type = :source_type
AND source_id = :source_id
FOR UPDATE"
);
$sth->execute([
':cid' => $this->companyId,
':source_type' => $source_type,
':source_id' => $source_id,
]);
$gl = $sth->fetch(PDO::FETCH_ASSOC);
if (!$gl) throw new Exception("GL record not found for replace.");
$journal_date = $this->resolveJournalDate($meta['journal_date'] ?? ($gl['journal_date'] ?? null), $period);
$this->assertPostingWindow($journal_date, 'GL posting');
$gl_id = (int)$gl['id'];
$version = (int)$gl['current_version'];
$history = json_decode($gl['history'], true) ?: [];
// Snapshot current lines into history
$history[] = [
'version' => $version,
'formula_id' => (int)$gl['formula_id'],
'replaced_at' => $now,
'lines' => $this->getLines($gl_id),
];
$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(
"UPDATE td_gl
SET formula_id = :formula_id,
journal_date = :journal_date,
period = :period,
current_version = :current_version,
history = :history,
updated_at = :updated_at
WHERE id = :id"
)->execute([
':formula_id' => $formula_id,
':journal_date' => $journal_date,
':period' => $period,
':current_version' => $version + 1,
':history' => json_encode($history),
':updated_at' => $now,
':id' => $gl_id,
]);
$this->insertLines($gl_id, $lines);
}
public function delete(string $source_type, int $source_id): void
{
$sth = $this->pdo->prepare(
"SELECT id, journal_date, period FROM td_gl
WHERE company_id = :cid
AND source_type = :source_type
AND source_id = :source_id
LIMIT 1"
);
$sth->execute([
':cid' => $this->companyId,
':source_type' => $source_type,
':source_id' => $source_id,
]);
$gl = $sth->fetch(PDO::FETCH_ASSOC);
$gl_id = (int)($gl['id'] ?? 0);
if (!$gl_id) return;
$journal_date = $this->resolveJournalDate($gl['journal_date'] ?? null, (string)($gl['period'] ?? ''));
$this->assertPostingWindow($journal_date, 'GL posting deletion');
$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"
)->execute([':id' => $gl_id, ':cid' => $this->companyId]);
}
// ── private ──────────────────────────────────────────────────────────────
private function getLines(int $gl_id): array
{
$sth = $this->pdo->prepare(
"SELECT account_code, department_id, debit, credit, description
FROM td_gl_item
WHERE company_id = :cid AND gl_id = :gl_id
ORDER BY id ASC"
);
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function assertPostingWindow(?string $journal_date, string $context): void
{
if ($this->postingWindowGuard) {
$this->postingWindowGuard->assertOpenDate($journal_date, $context);
}
}
private function resolveJournalDate(?string $journal_date, string $period): string
{
$journal_date = trim((string)$journal_date);
if ($journal_date !== '') {
return $journal_date;
}
$period = trim($period);
return preg_match('/^\d{4}-\d{2}$/', $period) ? "{$period}-01" : '';
}
private function insertLines(int $gl_id, array $lines): void
{
$sth = $this->pdo->prepare(
"INSERT INTO td_gl_item
(company_id, gl_id, account_code, department_id, debit, credit, description)
VALUES
(:cid, :gl_id, :account_code, :dept_id, :debit, :credit, :description)"
);
foreach ($lines as $line) {
$sth->execute([
':cid' => $this->companyId,
':gl_id' => $gl_id,
':account_code' => $line['account_code'],
':dept_id' => (int)($line['department_id'] ?? 0),
':debit' => (float)($line['debit'] ?? 0),
':credit' => (float)($line['credit'] ?? 0),
':description' => (string)($line['description'] ?? ''),
]);
}
}
}
@@ -0,0 +1,378 @@
<?php
class GlQueryManager
{
private PDO $pdo;
private int $companyId;
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'];
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public function getPostableDocuments(string $doc_type, string $date_from = '', string $date_to = '', int $formula_id = 0): array
{
$doc_type = trim($doc_type);
$date_from = $this->parseDate($date_from);
$date_to = $this->parseDate($date_to);
if (!in_array($doc_type, $this->postingTypes, true)) {
throw new Exception('Invalid doc_type.');
}
if (in_array($doc_type, $this->invoiceTypes, true)) {
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
} elseif ($doc_type === 'receipt') {
$rows = $this->getReceiptPostableDocuments($date_from, $date_to);
} elseif ($doc_type === 'purchase_order') {
$rows = $this->getPurchaseOrderPostableDocuments($date_from, $date_to, $formula_id);
} else {
$rows = $this->getPaymentPostableDocuments($date_from, $date_to);
}
if ($formula_id > 0) {
$rows = array_values(array_filter($rows, function($row) use ($formula_id) {
return $row['gl_status'] == 0 || (int)$row['gl_formula_id'] === $formula_id;
}));
}
return $rows;
}
public function getJournalListing(string $source_type = '', string $date_from = '', string $date_to = ''): array
{
$source_type = trim($source_type);
$date_from = $this->parseDate($date_from);
$date_to = $this->parseDate($date_to);
$where = ['g.company_id = :cid'];
$params = [':cid' => $this->companyId];
if ($source_type && in_array($source_type, $this->journalTypes, true)) {
$where[] = 'g.source_type = :source_type';
$params[':source_type'] = $source_type;
}
if ($date_from) {
$where[] = 'g.journal_date >= :date_from';
$params[':date_from'] = $date_from;
}
if ($date_to) {
$where[] = 'g.journal_date <= :date_to';
$params[':date_to'] = $date_to;
}
$sql = "
SELECT
g.id,
g.source_type,
g.source_id,
g.period,
g.current_version,
g.formula_id,
COALESCE(f.formula_name, '') AS formula_name,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
CASE g.source_type
WHEN 'receipt' THEN r.receipt_number
WHEN 'payment' THEN p.payment_number
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
WHEN 'purchase_order' THEN po.po_number
ELSE i.invoice_number
END AS doc_number,
COALESCE(
CASE g.source_type
WHEN 'receipt' THEN cr.contact_name
WHEN 'payment' THEN cp.contact_name
WHEN 'manual' THEN g.description
WHEN 'purchase_order' THEN cpo.contact_name
ELSE ci.contact_name
END, ''
) AS contact_name,
COALESCE(SUM(gi.debit), 0) AS total_debit,
COALESCE(SUM(gi.credit), 0) AS total_credit
FROM td_gl g
LEFT JOIN md_account_formula f
ON f.company_id = g.company_id AND f.id = g.formula_id
LEFT JOIN td_gl_item gi
ON gi.company_id = g.company_id AND gi.gl_id = g.id
LEFT JOIN td_invoice i
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
AND i.company_id = g.company_id AND i.id = g.source_id
LEFT JOIN md_contact ci
ON ci.company_id = g.company_id AND ci.id = i.contact_id
LEFT JOIN td_receipt r
ON g.source_type = 'receipt'
AND r.company_id = g.company_id AND r.id = g.source_id
LEFT JOIN md_contact cr
ON cr.company_id = g.company_id AND cr.id = r.contact_id
LEFT JOIN td_payment p
ON g.source_type = 'payment'
AND p.company_id = g.company_id AND p.id = g.source_id
LEFT JOIN md_contact cp
ON cp.company_id = g.company_id AND cp.id = p.contact_id
LEFT JOIN td_purchase_order po
ON g.source_type = 'purchase_order'
AND po.company_id = g.company_id AND po.id = g.source_id
LEFT JOIN md_contact cpo
ON cpo.company_id = g.company_id AND cpo.id = po.contact_id
WHERE " . implode(' AND ', $where) . "
GROUP BY
g.id, g.source_type, g.source_id, g.reference, g.description, g.period,
g.current_version, g.formula_id, f.formula_name, g.created_at, g.updated_at,
i.invoice_number, r.receipt_number, p.payment_number, po.po_number,
ci.contact_name, cr.contact_name, cp.contact_name, cpo.contact_name
ORDER BY g.created_at DESC, g.id DESC
";
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getJournalDetail(int $gl_id): array
{
if ($gl_id <= 0) {
throw new Exception('gl_id is required.');
}
$sth = $this->pdo->prepare(
"SELECT g.*,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
COALESCE(f.formula_name, '') AS formula_name
FROM td_gl g
LEFT JOIN md_account_formula f
ON f.company_id = g.company_id AND f.id = g.formula_id
WHERE g.company_id = :cid AND g.id = :gl_id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
$header = $sth->fetch(PDO::FETCH_ASSOC);
if (!$header) {
throw new Exception('GL entry not found.');
}
$sth = $this->pdo->prepare(
"SELECT gi.account_code, gi.department_id, gi.debit, gi.credit, gi.description,
COALESCE(a.account_name, '') AS account_name,
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name
FROM td_gl_item gi
LEFT JOIN md_account a
ON a.company_id = gi.company_id AND a.account_code = gi.account_code
LEFT JOIN md_department d
ON d.company_id = gi.company_id AND d.id = gi.department_id
WHERE gi.company_id = :cid AND gi.gl_id = :gl_id
ORDER BY gi.id ASC"
);
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
return [
'header' => $header,
'lines' => $sth->fetchAll(PDO::FETCH_ASSOC),
];
}
private function getInvoicePostableDocuments(string $doc_type, string $date_from, string $date_to, int $formula_id): array
{
$requires_product_mapping = $this->formulaRequiresProductMapping($doc_type, $formula_id);
$mapping_column = in_array($doc_type, ['invoice', 'credit_note'], true)
? 'sales_account_code'
: 'purchase_account_code';
$mapping_select = $requires_product_mapping
? ", (
SELECT COUNT(*)
FROM td_invoice_item ii
LEFT JOIN md_product p
ON p.company_id = ii.company_id
AND p.sku = ii.product_sku
WHERE ii.company_id = i.company_id
AND ii.invoice_id = i.id
AND ABS(ii.total_price) > 0.0000001
AND COALESCE(NULLIF(p.{$mapping_column}, ''), '') = ''
) AS product_mapping_missing"
: ", 0 AS product_mapping_missing";
$where = ['i.company_id = :cid', 'i.doc_type = :doc_type', 'i.status != 4'];
$params = [':cid' => $this->companyId, ':doc_type' => $doc_type, ':source_type' => $doc_type];
if ($date_from) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT i.id,
i.invoice_number AS doc_number,
COALESCE(c.contact_name, '') AS contact_name,
i.grand_total,
i.issued_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.formula_id, 0) AS gl_formula_id,
" . ($requires_product_mapping ? '1' : '0') . " AS product_mapping_checked
{$mapping_select}
FROM td_invoice i
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN td_gl g
ON g.company_id = i.company_id
AND g.source_type = :source_type
AND g.source_id = i.id
WHERE " . implode(' AND ', $where) . "
ORDER BY i.issued_date DESC, i.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function getReceiptPostableDocuments(string $date_from, string $date_to): array
{
$where = ['r.company_id = :cid', 'r.status = 1'];
$params = [':cid' => $this->companyId];
if ($date_from) { $where[] = 'r.receipt_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'r.receipt_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT r.id,
r.receipt_number AS doc_number,
COALESCE(c.contact_name, '') AS contact_name,
r.amount AS grand_total,
r.receipt_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.formula_id, 0) AS gl_formula_id,
0 AS product_mapping_checked,
0 AS product_mapping_missing
FROM td_receipt r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
LEFT JOIN td_gl g
ON g.company_id = r.company_id
AND g.source_type = 'receipt'
AND g.source_id = r.id
WHERE " . implode(' AND ', $where) . "
ORDER BY r.receipt_date DESC, r.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function getPaymentPostableDocuments(string $date_from, string $date_to): array
{
$where = ['r.company_id = :cid', 'r.status = 1'];
$params = [':cid' => $this->companyId];
if ($date_from) { $where[] = 'r.payment_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'r.payment_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT r.id,
r.payment_number AS doc_number,
COALESCE(c.contact_name, '') AS contact_name,
r.amount AS grand_total,
r.payment_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.formula_id, 0) AS gl_formula_id,
0 AS product_mapping_checked,
0 AS product_mapping_missing
FROM td_payment r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
LEFT JOIN td_gl g
ON g.company_id = r.company_id
AND g.source_type = 'payment'
AND g.source_id = r.id
WHERE " . implode(' AND ', $where) . "
ORDER BY r.payment_date DESC, r.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function getPurchaseOrderPostableDocuments(string $date_from, string $date_to, int $formula_id): array
{
$requires_mapping = $this->formulaRequiresProductMapping('purchase_order', $formula_id);
$mapping_select = $requires_mapping
? ", (
SELECT COUNT(*)
FROM td_purchase_order_item poi
LEFT JOIN md_product p
ON p.company_id = poi.company_id
AND p.sku = poi.product_sku
WHERE poi.company_id = po.company_id
AND poi.order_id = po.id
AND ABS(poi.total_price) > 0.0000001
AND COALESCE(NULLIF(p.purchase_account_code, ''), '') = ''
) AS product_mapping_missing"
: ", 0 AS product_mapping_missing";
$where = ['po.company_id = :cid', 'po.status = 1'];
$params = [':cid' => $this->companyId];
if ($date_from) { $where[] = 'po.po_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'po.po_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT po.id,
po.po_number AS doc_number,
COALESCE(c.contact_name, '') AS contact_name,
po.grand_total,
po.po_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.formula_id, 0) AS gl_formula_id,
" . ($requires_mapping ? '1' : '0') . " AS product_mapping_checked
{$mapping_select}
FROM td_purchase_order po
LEFT JOIN md_contact c
ON c.company_id = po.company_id AND c.id = po.contact_id
LEFT JOIN td_gl g
ON g.company_id = po.company_id
AND g.source_type = 'purchase_order'
AND g.source_id = po.id
WHERE " . implode(' AND ', $where) . "
ORDER BY po.po_date DESC, po.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function formulaRequiresProductMapping(string $doc_type, int $formula_id): bool
{
if ($formula_id <= 0 || !in_array($doc_type, $this->mappingTypes, true)) {
return false;
}
$sth = $this->pdo->prepare(
"SELECT COUNT(*)
FROM md_account_formula_item fi
JOIN md_account_formula f
ON f.company_id = fi.company_id
AND f.id = fi.formula_id
WHERE fi.company_id = :cid
AND fi.formula_id = :formula_id
AND f.document_type = :doc_type
AND fi.amount_key = 'total'
AND f.status = 1"
);
$sth->execute([
':cid' => $this->companyId,
':formula_id' => $formula_id,
':doc_type' => $doc_type,
]);
return ((int)$sth->fetchColumn()) > 0;
}
private function parseDate(string $value): string
{
$value = trim($value);
if ($value === '') return '';
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) {
return "{$m[3]}-{$m[2]}-{$m[1]}";
}
return $value;
}
}
?>
@@ -1,25 +0,0 @@
<?php
/**
* JournalManager
*
* Create, read, and void journal entries in ac_journal + ac_journal_line.
* Updates ac_balance after each post.
* Never called directly by WMS — goes through PostingManager.
*/
class JournalManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: create(array $header, array $lines): int — returns journal_id
// TODO: void(int $journal_id): void
// TODO: getBySource(string $source_type, int $source_id): ?array
// TODO: getById(int $journal_id): ?array
// TODO: updateBalance(string $period, string $account_code, ?int $dept_id, float $debit, float $credit): void
}
@@ -1,120 +0,0 @@
<?php
class PeriodManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_period
WHERE company_id = :cid
ORDER BY period DESC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_period WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function isOpen(string $period): bool
{
$sth = $this->pdo->prepare(
"SELECT status FROM ac_period
WHERE company_id = :cid AND period = :period LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
$status = $sth->fetchColumn();
return $status === 'open';
}
// Get existing period or auto-create it as open
public function getOrCreate(string $period): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_period WHERE company_id = :cid AND period = :period LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if ($row) return $row;
$this->pdo->prepare(
"INSERT INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
)->execute([':cid' => $this->companyId, ':period' => $period]);
return $this->getOrCreate($period);
}
// Create a single period (manual)
public function create(string $period): void
{
$this->pdo->prepare(
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
)->execute([':cid' => $this->companyId, ':period' => $period]);
}
// Bulk-create all 12 months of a year
public function createYear(int $year): void
{
$sth = $this->pdo->prepare(
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
);
for ($m = 1; $m <= 12; $m++) {
$sth->execute([
':cid' => $this->companyId,
':period' => sprintf('%04d-%02d', $year, $m),
]);
}
}
public function open(int $id): void
{
$this->pdo->prepare(
"UPDATE ac_period SET status = 'open', closed_at = NULL
WHERE company_id = :cid AND id = :id AND status = 'closed'"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function close(int $id): void
{
$this->pdo->prepare(
"UPDATE ac_period SET status = 'closed', closed_at = NOW()
WHERE company_id = :cid AND id = :id AND status = 'open'"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function lock(int $id): void
{
$this->pdo->prepare(
"UPDATE ac_period SET status = 'locked', locked_at = NOW()
WHERE company_id = :cid AND id = :id AND status = 'closed'"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
SUM(status = 'open') AS open_count,
SUM(status = 'closed') AS closed_count,
SUM(status = 'locked') AS locked_count
FROM ac_period WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
}
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../classes/CompanySettingManager.php';
class PostingWindowGuard
{
private CompanySettingManager $settings;
private ?array $window = null;
public function __construct(PDO $settingsPdo, int $company_id)
{
$this->settings = new CompanySettingManager($settingsPdo, $company_id);
}
public function assertOpenDate(?string $posting_date, string $context = 'GL posting'): void
{
$date = $this->normalizeDate($posting_date);
if ($date === '') {
throw new Exception("{$context} date is required for posting-window validation.");
}
$window = $this->getWindow();
$from = $window['open_from'];
$to = $window['open_to'];
if ($from !== '' && $date < $from) {
throw new Exception("{$context} date {$date} is before the open posting window ({$from} to " . ($to ?: 'no upper limit') . ").");
}
if ($to !== '' && $date > $to) {
throw new Exception("{$context} date {$date} is after the open posting window (" . ($from ?: 'no lower limit') . " to {$to}).");
}
}
private function getWindow(): array
{
if ($this->window === null) {
$open_from = $this->settings->get('posting_open_from');
if ($open_from === null || $open_from === '') {
$open_from = $this->settings->get('gl_open_from') ?: '';
}
$open_to = $this->settings->get('posting_open_to');
if ($open_to === null || $open_to === '') {
$open_to = $this->settings->get('gl_open_to') ?: '';
}
$this->window = [
'open_from' => $this->normalizeDate($open_from ?: ''),
'open_to' => $this->normalizeDate($open_to ?: ''),
];
}
return $this->window;
}
private function normalizeDate(?string $date): string
{
$date = trim((string)$date);
if ($date === '') {
return '';
}
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $date, $m)) {
$date = "{$m[3]}-{$m[2]}-{$m[1]}";
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
throw new Exception("Invalid posting date {$date}. Use YYYY-MM-DD.");
}
return $date;
}
}
@@ -3,7 +3,8 @@
* TaxReportManager
*
* Generates VAT and withholding tax reports from journal lines
* tagged to tax accounts (output VAT, input VAT, WHT).
* tagged to tax accounts (output VAT, input VAT, WHT) in md_account.
* Matches accounts where account_name contains 'vat' or 'tax' (case-insensitive).
* Read-only — no writes.
*/
class TaxReportManager
@@ -17,7 +18,89 @@ class TaxReportManager
$this->companyId = $company_id;
}
// TODO: getOutputVAT(string $from_period, string $to_period): array
// TODO: getInputVAT(string $from_period, string $to_period): array
// TODO: getWithholdingTax(string $from_period, string $to_period): array
private function normalizeDate(string $date, bool $end_of_month = false): string
{
$date = trim($date);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}$/', $date)) {
return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01';
}
return date('Y-m-d');
}
// ── VAT Report ────────────────────────────────────────────────────────────
// Returns all GL lines for accounts whose account_category is 'sales_tax'
// or 'purchase_tax'. Pass $categories = [] to get both; pass one or more
// category strings to narrow (e.g. ['sales_tax']).
public function getVATReport(string $from_date, string $to_date, array $categories = [], int $dept_id = 0): array
{
$from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true);
$valid = ['sales_tax', 'purchase_tax'];
$cats = $categories ? array_intersect($categories, $valid) : $valid;
if (empty($cats)) {
return ['rows' => [], 'total_debit' => 0.0, 'total_credit' => 0.0,
'from_date' => $from_date, 'to_date' => $to_date,
'from_period' => substr($from_date, 0, 7), 'to_period' => substr($to_date, 0, 7)];
}
$placeholders = implode(',', array_map(fn($i) => ":cat$i", array_keys($cats)));
$params = [':cid' => $this->companyId, ':from_date' => $from_date, ':to_date' => $to_date];
foreach (array_values($cats) as $i => $c) { $params[":cat$i"] = $c; }
$sth = $this->pdo->prepare("
SELECT
g.id AS gl_id,
g.source_type,
COALESCE(g.reference, '') AS reference,
g.period,
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
a.account_code,
a.account_name,
a.account_category,
i.department_id,
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name,
i.debit,
i.credit,
COALESCE(i.description, '') AS description
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id
WHERE i.company_id = :cid
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
AND a.account_category IN ($placeholders)
AND (:dept_id = 0 OR i.department_id = :dept_id)
ORDER BY g.period ASC, g.journal_date ASC, g.id ASC
");
$params[':dept_id'] = $dept_id;
$sth->execute($params);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
return [
'rows' => $rows,
'total_debit' => (float)array_sum(array_column($rows, 'debit')),
'total_credit' => (float)array_sum(array_column($rows, 'credit')),
'from_date' => $from_date,
'to_date' => $to_date,
'from_period' => substr($from_date, 0, 7),
'to_period' => substr($to_date, 0, 7),
];
}
public function getOutputVAT(string $from_period, string $to_period, int $dept_id = 0): array
{
return $this->getVATReport($from_period, $to_period, ['sales_tax'], $dept_id);
}
public function getInputVAT(string $from_period, string $to_period, int $dept_id = 0): array
{
return $this->getVATReport($from_period, $to_period, ['purchase_tax'], $dept_id);
}
}
@@ -0,0 +1,183 @@
<?php
/**
* BasePosting
*
* Shared build() logic for all six posting types.
* Subclasses implement fetchDocument(), getAmounts(), getDocType(), getDocDate().
*
* build() returns:
* ['formula_id' => int, 'period' => 'YYYY-MM', 'doc_date' => 'YYYY-MM-DD', 'lines' => [...]]
*
* Amount keys understood by formulas:
* grand_total — full document total (abs value used)
* total — pre-tax amount (grand_total - tax)
* tax — tax portion
* amount — used by receipt and payment
*/
abstract class BasePosting
{
protected PDO $pdo;
protected int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
abstract protected function fetchDocument(int $id): array;
abstract protected function getAmounts(array $doc): array;
abstract protected function getDocType(): string;
abstract protected function getDocDate(array $doc): string;
public function build(int $id, ?int $formula_id = null): array
{
$doc = $this->fetchDocument($id);
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
$amounts = $this->getAmounts($doc);
$doc_date = $this->getDocDate($doc);
$period = substr($doc_date, 0, 7);
$dept_id = (int)($doc['department_id'] ?? 0);
$lines = [];
foreach ($formula['items'] as $item) {
$amount = abs((float)($amounts[$item['amount_key']] ?? 0));
if ($amount == 0) continue;
if ($item['amount_key'] === 'total') {
$split_lines = $this->buildSkuTotalLines($doc, $item, $amount, $dept_id);
if ($split_lines) {
$lines = array_merge($lines, $split_lines);
continue;
}
}
$lines[] = [
'account_code' => $item['account_code'],
'department_id' => $dept_id,
'debit' => $item['drcr'] === 'D' ? $amount : 0.0,
'credit' => $item['drcr'] === 'C' ? $amount : 0.0,
'description' => (string)($item['description'] ?? ''),
];
}
return [
'formula_id' => (int)$formula['id'],
'period' => $period,
'doc_date' => $doc_date,
'lines' => $lines,
];
}
protected function resolveFormula(string $doc_type, ?int $formula_id): array
{
if ($formula_id) {
$sth = $this->pdo->prepare(
"SELECT * FROM md_account_formula
WHERE company_id = :cid AND id = :id AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $formula_id]);
} else {
$sth = $this->pdo->prepare(
"SELECT * FROM md_account_formula
WHERE company_id = :cid
AND document_type = :doc_type
AND is_default = 1
AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':doc_type' => $doc_type]);
}
$formula = $sth->fetch(PDO::FETCH_ASSOC);
if (!$formula) throw new Exception("No active formula found for '{$doc_type}'.");
$sth = $this->pdo->prepare(
"SELECT * FROM md_account_formula_item
WHERE company_id = :cid AND formula_id = :formula_id
ORDER BY sort_order ASC"
);
$sth->execute([':cid' => $this->companyId, ':formula_id' => $formula['id']]);
$formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC);
return $formula;
}
protected function getSkuTotalAccountColumn(): string
{
return '';
}
private function buildSkuTotalLines(array $doc, array $formula_item, float $amount, int $dept_id): array
{
$column = $this->getSkuTotalAccountColumn();
if ($column === '' || !in_array($column, ['sales_account_code', 'purchase_account_code'], true)) {
return [];
}
$invoice_id = (int)($doc['id'] ?? 0);
if ($invoice_id <= 0) return [];
$sth = $this->pdo->prepare("
SELECT
i.product_sku,
i.product_name,
i.total_price,
COALESCE(NULLIF(p.{$column}, ''), '') AS mapped_account_code
FROM td_invoice_item i
LEFT JOIN md_product p
ON p.company_id = i.company_id
AND p.sku = i.product_sku
WHERE i.company_id = :cid
AND i.invoice_id = :invoice_id
ORDER BY i.item_id ASC, i.id ASC
");
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id]);
$items = array_values(array_filter($sth->fetchAll(PDO::FETCH_ASSOC), function($row) {
return abs((float)($row['total_price'] ?? 0)) > 0.0000001;
}));
if (!$items) return [];
$base_total = array_reduce($items, function($carry, $row) {
return $carry + abs((float)($row['total_price'] ?? 0));
}, 0.0);
if ($base_total <= 0) return [];
$remaining = round($amount, 4);
$last = count($items) - 1;
$lines = [];
foreach ($items as $idx => $row) {
$line_amount = $idx === $last
? $remaining
: round($amount * (abs((float)$row['total_price']) / $base_total), 4);
$remaining = round($remaining - $line_amount, 4);
if (abs($line_amount) <= 0.0000001) continue;
$account_code = trim((string)($row['mapped_account_code'] ?? ''));
if ($account_code === '') {
$account_code = (string)$formula_item['account_code'];
}
$desc = trim((string)($formula_item['description'] ?? ''));
$sku = trim((string)($row['product_sku'] ?? ''));
$name = trim((string)($row['product_name'] ?? ''));
$item_desc = trim($sku . ($name !== '' ? ' - ' . $name : ''));
if ($item_desc !== '') {
$desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc;
}
$lines[] = [
'account_code' => $account_code,
'department_id' => $dept_id,
'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0,
'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0,
'description' => $desc,
];
}
return $lines;
}
}
@@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/BasePosting.php';
class CreditNotePosting extends BasePosting
{
protected function getDocType(): string { return 'credit_note'; }
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
protected function fetchDocument(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_invoice i
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'credit_note'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Credit note #{$id} not found.");
return $doc;
}
protected function getAmounts(array $doc): array
{
// grand_total is negative in DB for credit notes — use abs()
$grand_total = abs((float)$doc['grand_total']);
$tax = abs((float)$doc['tax']);
return [
'grand_total' => $grand_total,
'tax' => $tax,
'total' => $grand_total - $tax,
'amount' => $grand_total,
];
}
protected function getDocDate(array $doc): string
{
return $doc['issued_date'] ?? date('Y-m-d');
}
}
@@ -1,21 +1,42 @@
<?php
/**
* InvoicePosting
*
* Builds the journal entry for a posted sales invoice.
* DR: Accounts Receivable
* CR: Revenue + Output VAT
*/
class InvoicePosting
{
private PDO $pdo;
private int $companyId;
require_once __DIR__ . '/BasePosting.php';
public function __construct(PDO $pdo, int $company_id)
class InvoicePosting extends BasePosting
{
protected function getDocType(): string { return 'invoice'; }
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
protected function fetchDocument(int $id): array
{
$this->pdo = $pdo;
$this->companyId = $company_id;
$sth = $this->pdo->prepare(
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_invoice i
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'invoice'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Invoice #{$id} not found.");
return $doc;
}
// TODO: build(int $invoice_id): array — returns [header, lines] ready for JournalManager
protected function getAmounts(array $doc): array
{
$grand_total = abs((float)$doc['grand_total']);
$tax = abs((float)$doc['tax']);
return [
'grand_total' => $grand_total,
'tax' => $tax,
'total' => $grand_total - $tax,
'amount' => $grand_total,
];
}
protected function getDocDate(array $doc): string
{
return $doc['issued_date'] ?? date('Y-m-d');
}
}
@@ -1,21 +1,109 @@
<?php
/**
* PaymentPosting
*
* Builds the journal entry when a supplier payment is recorded.
* DR: Accounts Payable
* CR: Bank / Cash
*/
class PaymentPosting
{
private PDO $pdo;
private int $companyId;
require_once __DIR__ . '/BasePosting.php';
public function __construct(PDO $pdo, int $company_id)
class PaymentPosting extends BasePosting
{
protected function getDocType(): string { return 'payment'; }
public function build(int $id, ?int $formula_id = null): array
{
$this->pdo = $pdo;
$this->companyId = $company_id;
$doc = $this->fetchDocument($id);
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
$doc_date = $this->getDocDate($doc);
$period = substr($doc_date, 0, 7);
$dept_amounts = $this->getDepartmentAmounts($id);
$lines = [];
foreach ($dept_amounts as $dept_id => $amount) {
if (abs($amount) <= 0.0001) continue;
$is_negative = $amount < 0;
$line_amount = abs($amount);
foreach ($formula['items'] as $item) {
if ($item['amount_key'] === 'tax') continue;
$drcr = $item['drcr'];
if ($is_negative) {
$drcr = $drcr === 'D' ? 'C' : 'D';
}
$lines[] = [
'account_code' => $item['account_code'],
'department_id' => (int)$dept_id,
'debit' => $drcr === 'D' ? $line_amount : 0.0,
'credit' => $drcr === 'C' ? $line_amount : 0.0,
'description' => (string)($item['description'] ?? ''),
];
}
}
return [
'formula_id' => (int)$formula['id'],
'period' => $period,
'doc_date' => $doc_date,
'lines' => $lines,
];
}
// TODO: build(int $payment_id): array — returns [header, lines] ready for JournalManager
protected function fetchDocument(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_payment r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
WHERE r.company_id = :cid AND r.id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Payment #{$id} not found.");
return $doc;
}
protected function getAmounts(array $doc): array
{
$amount = abs((float)$doc['amount']);
return [
'amount' => $amount,
'grand_total' => $amount,
'total' => $amount,
'tax' => 0.0,
];
}
protected function getDocDate(array $doc): string
{
return $doc['payment_date'] ?? date('Y-m-d');
}
private function getDepartmentAmounts(int $payment_id): array
{
$sth = $this->pdo->prepare(
"SELECT pi.department_id,
pi.doc_type,
pi.amount
FROM td_payment_item pi
WHERE pi.company_id = :cid
AND pi.payment_id = :payment_id"
);
$sth->execute([
':cid' => $this->companyId,
':payment_id' => $payment_id,
]);
$dept_amounts = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) {
$dept_id = (int)($line['department_id'] ?? 0);
$amount = abs((float)($line['amount'] ?? 0));
if ($line['doc_type'] === 'supplier_credit_note') {
$amount *= -1;
}
$dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount;
}
if (!$dept_amounts) throw new Exception("Payment #{$payment_id} has no allocation lines for GL posting.");
return $dept_amounts;
}
}
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/BasePosting.php';
class PurchaseInvoicePosting extends BasePosting
{
protected function getDocType(): string { return 'purchase_invoice'; }
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
protected function fetchDocument(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_invoice i
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'purchase_invoice'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Purchase invoice #{$id} not found.");
return $doc;
}
protected function getAmounts(array $doc): array
{
$grand_total = abs((float)$doc['grand_total']);
$tax = abs((float)$doc['tax']);
return [
'grand_total' => $grand_total,
'tax' => $tax,
'total' => $grand_total - $tax,
'amount' => $grand_total,
];
}
protected function getDocDate(array $doc): string
{
return $doc['issued_date'] ?? date('Y-m-d');
}
}
@@ -1,21 +1,162 @@
<?php
require_once __DIR__ . '/BasePosting.php';
/**
* PurchasePosting
*
* Builds the journal entry when a Purchase Order is received (GRN).
* DR: Inventory
* CR: Accounts Payable
* Builds the GL journal entry for a Purchase Order (GRN).
* Typical formula: DR Inventory (per SKU) + DR Input VAT / CR Accounts Payable.
*
* Amount keys:
* grand_total — full PO total (used for AP credit line)
* tax — VAT portion
* total — pre-tax (grand_total - tax), split per SKU via purchase_account_code
* amount — alias of grand_total
*/
class PurchasePosting
class PurchasePosting extends BasePosting
{
private PDO $pdo;
private int $companyId;
protected function getDocType(): string { return 'purchase_order'; }
public function __construct(PDO $pdo, int $company_id)
protected function fetchDocument(int $id): array
{
$this->pdo = $pdo;
$this->companyId = $company_id;
$sth = $this->pdo->prepare(
"SELECT p.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id AND c.id = p.contact_id
WHERE p.company_id = :cid AND p.id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Purchase order #{$id} not found.");
$sth2 = $this->pdo->prepare(
"SELECT * FROM td_purchase_order_item
WHERE company_id = :cid AND order_id = :id
ORDER BY item_id ASC"
);
$sth2->execute([':cid' => $this->companyId, ':id' => $id]);
$doc['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
return $doc;
}
// TODO: build(int $po_id): array — returns [header, lines] ready for JournalManager
protected function getAmounts(array $doc): array
{
$grand_total = abs((float)$doc['grand_total']);
$tax = abs((float)$doc['tax']);
return [
'grand_total' => $grand_total,
'tax' => $tax,
'total' => $grand_total - $tax,
'amount' => $grand_total,
];
}
protected function getDocDate(array $doc): string
{
return $doc['po_date'] ?? date('Y-m-d');
}
public function build(int $id, ?int $formula_id = null): array
{
$doc = $this->fetchDocument($id);
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
$amounts = $this->getAmounts($doc);
$doc_date = $this->getDocDate($doc);
$period = substr($doc_date, 0, 7);
$dept_id = (int)($doc['department_id'] ?? 0);
$lines = [];
foreach ($formula['items'] as $item) {
$amount = abs((float)($amounts[$item['amount_key']] ?? 0));
if ($amount == 0) continue;
if ($item['amount_key'] === 'total') {
$split_lines = $this->buildSkuSplitLines($doc['items'], $item, $amount, $dept_id);
if ($split_lines) {
$lines = array_merge($lines, $split_lines);
continue;
}
}
$lines[] = [
'account_code' => $item['account_code'],
'department_id' => $dept_id,
'debit' => $item['drcr'] === 'D' ? $amount : 0.0,
'credit' => $item['drcr'] === 'C' ? $amount : 0.0,
'description' => (string)($item['description'] ?? ''),
];
}
return [
'formula_id' => (int)$formula['id'],
'period' => $period,
'doc_date' => $doc_date,
'lines' => $lines,
];
}
private function buildSkuSplitLines(array $items, array $formula_item, float $amount, int $dept_id): array
{
$items = array_values(array_filter($items, function($row) {
return abs((float)($row['total_price'] ?? 0)) > 0.0000001;
}));
if (!$items) return [];
$skus = array_values(array_unique(array_filter(array_column($items, 'product_sku'))));
$account_map = [];
if ($skus) {
$placeholders = implode(',', array_fill(0, count($skus), '?'));
$sth = $this->pdo->prepare(
"SELECT sku, purchase_account_code FROM md_product
WHERE company_id = ? AND sku IN ({$placeholders})"
);
$sth->execute(array_merge([$this->companyId], $skus));
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$account_map[$row['sku']] = trim((string)($row['purchase_account_code'] ?? ''));
}
}
$base_total = array_reduce($items, function($carry, $row) {
return $carry + abs((float)($row['total_price'] ?? 0));
}, 0.0);
if ($base_total <= 0) return [];
$remaining = round($amount, 4);
$last = count($items) - 1;
$lines = [];
foreach ($items as $idx => $row) {
$line_amount = $idx === $last
? $remaining
: round($amount * (abs((float)$row['total_price']) / $base_total), 4);
$remaining = round($remaining - $line_amount, 4);
if (abs($line_amount) <= 0.0000001) continue;
$sku = trim((string)($row['product_sku'] ?? ''));
$account_code = ($sku !== '' && ($account_map[$sku] ?? '') !== '')
? $account_map[$sku]
: (string)$formula_item['account_code'];
$name = trim((string)($row['product_name'] ?? ''));
$desc = trim((string)($formula_item['description'] ?? ''));
$item_desc = trim($sku . ($name !== '' ? ' - ' . $name : ''));
if ($item_desc !== '') {
$desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc;
}
$lines[] = [
'account_code' => $account_code,
'department_id' => $dept_id,
'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0,
'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0,
'description' => $desc,
];
}
return $lines;
}
}
@@ -1,21 +1,109 @@
<?php
/**
* ReceiptPosting
*
* Builds the journal entry when a customer receipt is recorded.
* DR: Bank / Cash
* CR: Accounts Receivable
*/
class ReceiptPosting
{
private PDO $pdo;
private int $companyId;
require_once __DIR__ . '/BasePosting.php';
public function __construct(PDO $pdo, int $company_id)
class ReceiptPosting extends BasePosting
{
protected function getDocType(): string { return 'receipt'; }
public function build(int $id, ?int $formula_id = null): array
{
$this->pdo = $pdo;
$this->companyId = $company_id;
$doc = $this->fetchDocument($id);
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
$doc_date = $this->getDocDate($doc);
$period = substr($doc_date, 0, 7);
$dept_amounts = $this->getDepartmentAmounts($id);
$lines = [];
foreach ($dept_amounts as $dept_id => $amount) {
if (abs($amount) <= 0.0001) continue;
$is_negative = $amount < 0;
$line_amount = abs($amount);
foreach ($formula['items'] as $item) {
if ($item['amount_key'] === 'tax') continue;
$drcr = $item['drcr'];
if ($is_negative) {
$drcr = $drcr === 'D' ? 'C' : 'D';
}
$lines[] = [
'account_code' => $item['account_code'],
'department_id' => (int)$dept_id,
'debit' => $drcr === 'D' ? $line_amount : 0.0,
'credit' => $drcr === 'C' ? $line_amount : 0.0,
'description' => (string)($item['description'] ?? ''),
];
}
}
return [
'formula_id' => (int)$formula['id'],
'period' => $period,
'doc_date' => $doc_date,
'lines' => $lines,
];
}
// TODO: build(int $receipt_id): array — returns [header, lines] ready for JournalManager
protected function fetchDocument(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_receipt r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
WHERE r.company_id = :cid AND r.id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Receipt #{$id} not found.");
return $doc;
}
protected function getAmounts(array $doc): array
{
$amount = abs((float)$doc['amount']);
return [
'amount' => $amount,
'grand_total' => $amount,
'total' => $amount,
'tax' => 0.0,
];
}
protected function getDocDate(array $doc): string
{
return $doc['receipt_date'] ?? date('Y-m-d');
}
private function getDepartmentAmounts(int $receipt_id): array
{
$sth = $this->pdo->prepare(
"SELECT ri.department_id,
ri.doc_type,
ri.amount
FROM td_receipt_item ri
WHERE ri.company_id = :cid
AND ri.receipt_id = :receipt_id"
);
$sth->execute([
':cid' => $this->companyId,
':receipt_id' => $receipt_id,
]);
$dept_amounts = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) {
$dept_id = (int)($line['department_id'] ?? 0);
$amount = abs((float)($line['amount'] ?? 0));
if ($line['doc_type'] === 'credit_note') {
$amount *= -1;
}
$dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount;
}
if (!$dept_amounts) throw new Exception("Receipt #{$receipt_id} has no allocation lines for GL posting.");
return $dept_amounts;
}
}
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/BasePosting.php';
class SupplierCreditNotePosting extends BasePosting
{
protected function getDocType(): string { return 'supplier_credit_note'; }
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
protected function fetchDocument(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_invoice i
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'supplier_credit_note'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$doc = $sth->fetch(PDO::FETCH_ASSOC);
if (!$doc) throw new Exception("Supplier credit note #{$id} not found.");
return $doc;
}
protected function getAmounts(array $doc): array
{
$grand_total = abs((float)$doc['grand_total']);
$tax = abs((float)$doc['tax']);
return [
'grand_total' => $grand_total,
'tax' => $tax,
'total' => $grand_total - $tax,
'amount' => $grand_total,
];
}
protected function getDocDate(array $doc): string
{
return $doc['issued_date'] ?? date('Y-m-d');
}
}