Commit 7313a539 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(hr): payroll posted nothing — the handler had the schema wrong three ways

onPayrollPaid read total_gross and total_net off hr_payroll_runs and grouped
hr_payroll_components_log by component_type. None of those columns exist:

  hr_payroll_runs          has gross_earnings / net_salary
  hr_payroll_components_log has `type`, not component_type
  hr_payroll_periods        has period_code, not period_name

Confirmed with SHOW COLUMNS on the live database. The handler threw "Unknown
column" on its first query, and the listener only logs, so payroll silently
posted NOTHING — no salary expense, no employer insurance share, no withheld
tax anywhere in the ledger.

It also had the grain wrong. PayrollController dispatches hr.payroll.paid once
PER EMPLOYEE; an hr_payroll_runs row is a single payslip, not a whole run, and
the period lives in hr_payroll_periods. Every amount needed is on the payslip.

Rewritten against the real schema:

  Dr Salary Expense              gross_earnings
  Dr Employer Insurance Expense  insurance_employer
  Cr Bank                        net_salary
  Cr Insurance Payable           insurance_employee + insurance_employer
  Cr Tax Payable                 tax_amount
  Cr Employee Loans              loan_deduction
  Cr Other Deductions Payable    penalty + absence + other

Balances by construction: the payslip satisfies gross - total_deductions = net
and the deduction buckets sum to total_deductions. Verified on all three live
payslips — e.g. run 1: Dr 15,000.00 + 2,362.50 = Cr 4,402.20 + 3,748.50 +
9,211.80 = 17,362.50.

A salary-deducted loan instalment credits the employee-advances receivable
rather than being treated as income. Penalties and absence deductions are parked
in accrued expenses and registered as a configurable pointer, because whether
they belong there or as a reduction of salary expense is a decision for the
accountants, not a constant in code.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent f8c11843
......@@ -303,12 +303,30 @@ final class AccountingIntegrationService
// ────────────────────────────────────────────────────────────
/**
* Auto-post payroll journal when payroll run is paid.
* Dr. Salary Expense (gross salaries)
* Dr. Insurance Expense (employer's insurance share)
* Cr. Bank (net salaries paid)
* Cr. Insurance Payable (employee + employer insurance)
* Cr. Tax Payable (income tax withheld)
* Auto-post the payroll journal when a payslip is marked paid.
*
* PayrollController dispatches hr.payroll.paid once PER EMPLOYEE — an
* hr_payroll_runs row is one payslip, not a whole run; the period lives in
* hr_payroll_periods. Every amount needed is on the payslip row itself.
*
* This previously read total_gross / total_net off the payslip and grouped
* hr_payroll_components_log by component_type. None of those three columns
* exist (the payslip has gross_earnings / net_salary; the log column is `type`),
* so the handler threw "Unknown column" on the very first query. The listener
* only logs, so payroll silently posted NOTHING — salaries, the employer
* insurance share and withheld tax were all absent from the ledger.
*
* Dr Salary Expense gross_earnings
* Dr Employer Insurance Expense insurance_employer
* Cr Bank net_salary
* Cr Insurance Payable insurance_employee + insurance_employer
* Cr Tax Payable tax_amount
* Cr Employee Loans loan_deduction
* Cr Other Deductions Payable penalty + absence + other
*
* Balances by construction, because the payslip satisfies
* gross_earnings - total_deductions = net_salary and the deduction buckets sum
* to total_deductions — verified against the live payroll rows.
*/
public static function onPayrollPaid(array $data): void
{
......@@ -324,27 +342,25 @@ final class AccountingIntegrationService
return;
}
// Get payroll component totals
$components = $db->select(
"SELECT component_type, SUM(amount) as total
FROM hr_payroll_components_log
WHERE payroll_run_id = ?
GROUP BY component_type",
[$payrollRunId]
$num = static fn(?string $v): string => number_format((float) ($v ?? 0), 2, '.', '');
$grossSalary = $num($run['gross_earnings'] ?? '0');
$netSalary = $num($run['net_salary'] ?? '0');
$empInsurance = $num($run['insurance_employee'] ?? '0');
$erInsurance = $num($run['insurance_employer'] ?? '0');
$totalTax = $num($run['tax_amount'] ?? '0');
$loanDeduct = $num($run['loan_deduction'] ?? '0');
$otherDeduct = bcadd(
bcadd($num($run['penalty_deduction'] ?? '0'), $num($run['absence_deduction'] ?? '0'), 2),
$num($run['other_deductions'] ?? '0'),
2
);
$totalInsurance = bcadd($empInsurance, $erInsurance, 2);
$componentMap = [];
foreach ($components as $c) {
$componentMap[$c['component_type']] = (string) $c['total'];
if (bccomp($grossSalary, '0.00', 2) <= 0) {
return;
}
$grossSalary = (string) ($run['total_gross'] ?? '0.00');
$netSalary = (string) ($run['total_net'] ?? '0.00');
$totalTax = $componentMap['income_tax'] ?? '0.00';
$empInsurance = $componentMap['social_insurance_employee'] ?? '0.00';
$erInsurance = $componentMap['social_insurance_employer'] ?? '0.00';
$totalInsurance = bcadd($empInsurance, $erInsurance, 2);
// Each leg is a configurable account pointer, so finance can re-map payroll
// without a code change. The fallbacks are the legacy chart codes — except
// the tax leg, which must not use the header 230804.
......@@ -353,6 +369,8 @@ final class AccountingIntegrationService
$bankAccountId = PostingRouter::accountFor('payroll:net_paid', AccountCodes::CASH_AT_BANK, 'payment');
$insurancePayableId = PostingRouter::accountFor('payroll:insurance_payable', AccountCodes::INSURANCE_PAYABLE,'payment');
$taxPayableId = PostingRouter::accountFor('payroll:tax_withheld', '23080403', 'payment');
$loanReceivableId = PostingRouter::accountFor('payroll:loan_recovery', AccountCodes::EMPLOYEE_LOANS, 'payment');
$otherDeductionsId = PostingRouter::accountFor('payroll:other_deductions', AccountCodes::ACCRUED_EXPENSES, 'payment');
if ($salaryExpenseId === null || $bankAccountId === null) {
Logger::error('Payroll auto-post failed: core accounts unresolved', [
......@@ -363,8 +381,18 @@ final class AccountingIntegrationService
return;
}
// hr_payroll_periods has period_code / month / year — there is no period_name.
$period = $db->selectOne("SELECT * FROM hr_payroll_periods WHERE id = ?", [(int) $run['period_id']]);
$periodName = $period ? $period['period_name'] ?? $period['month'] . '/' . $period['year'] : '';
$periodName = '';
if ($period) {
$periodName = (string) ($period['period_code'] ?? '');
if ($periodName === '') {
$periodName = ($period['month'] ?? '') . '/' . ($period['year'] ?? '');
}
}
$employeeRef = $run['employee_number'] ?? ('#' . ($run['employee_profile_id'] ?? ''));
$slipLabel = $periodName . ' — ' . $employeeRef;
$lines = [];
......@@ -429,6 +457,38 @@ final class AccountingIntegrationService
];
}
// Cr. Employee Loans — a salary-deducted instalment reduces the advance the
// club is owed, so it credits the receivable rather than being income.
if (bccomp($loanDeduct, '0.00', 2) > 0) {
if ($loanReceivableId === null) {
Logger::error('Payroll: employee loans account unresolved — entry not posted', ['payroll_run_id' => $payrollRunId]);
return;
}
$lines[] = [
'account_id' => $loanReceivableId,
'debit' => '0.00',
'credit' => $loanDeduct,
'description_ar' => 'استرداد سلفة من الراتب — ' . $slipLabel,
'employee_id' => isset($run['employee_profile_id']) ? (int) $run['employee_profile_id'] : null,
];
}
// Cr. Other deductions — penalties, absence and sundry withholdings. Parked
// in accrued expenses by default; finance can re-point this to a contra-salary
// account from the posting screen if they prefer it to reduce the expense.
if (bccomp($otherDeduct, '0.00', 2) > 0) {
if ($otherDeductionsId === null) {
Logger::error('Payroll: other deductions account unresolved — entry not posted', ['payroll_run_id' => $payrollRunId]);
return;
}
$lines[] = [
'account_id' => $otherDeductionsId,
'debit' => '0.00',
'credit' => $otherDeduct,
'description_ar' => 'استقطاعات أخرى (جزاءات/غياب) — ' . $slipLabel,
];
}
// Verify double-entry balance before posting.
$totalDebit = '0.00';
$totalCredit = '0.00';
......@@ -456,9 +516,9 @@ final class AccountingIntegrationService
}
$result = JournalService::createEntry([
'entry_date' => $run['paid_at'] ?? $run['payment_date'] ?? date('Y-m-d'),
'description_ar' => 'قيد رواتب — ' . $periodName,
'description_en' => 'Payroll entry — ' . $periodName,
'entry_date' => !empty($run['paid_at']) ? substr((string) $run['paid_at'], 0, 10) : date('Y-m-d'),
'description_ar' => 'قيد رواتب — ' . $slipLabel,
'description_en' => 'Payroll entry — ' . $slipLabel,
'reference_type' => 'payroll',
'reference_id' => $payrollRunId,
'source_module' => 'hr',
......
<?php
declare(strict_types=1);
/**
* Register the two payroll deduction legs as configurable account pointers.
*
* They already resolve through their chart-code fallbacks, so payroll posts without
* this. Seeding them makes both editable from /accounting/revenue-mapping, which
* matters for `payroll:other_deductions` in particular: parking penalties and
* absence deductions in accrued expenses is one defensible treatment, and finance
* may well prefer them to reduce salary expense instead. That should be their call,
* made on a screen, not ours in code.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$pointers = [
'payroll:loan_recovery' => [
'name_ar' => 'استرداد سلف العاملين من الراتب',
'name_en' => 'Employee Loan Recovery',
'code' => '120402',
'type' => 'asset',
'desc' => 'استرداد سلفة من الراتب',
'notes' => 'الاستقطاع يخفّض السلفة المستحقة على الموظف — ليس إيرادًا',
],
'payroll:other_deductions' => [
'name_ar' => 'استقطاعات أخرى (جزاءات وغياب)',
'name_en' => 'Other Payroll Deductions',
'code' => '230810',
'type' => 'expense',
'desc' => 'استقطاعات أخرى',
'notes' => 'مُودَعة في «مصروفات مستحقة» — راجعها مع المحاسبين، قد يفضّلون تخفيض مصروف الأجور',
],
];
foreach ($pointers as $code => $p) {
$accRow = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_header = 0 AND is_active = 1",
[$p['code']]
);
if (!$accRow) {
continue;
}
$accountId = (int) $accRow['id'];
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
if ($stream) {
$streamId = (int) $stream['id'];
} else {
$streamId = $db->insert('revenue_streams', [
'stream_code' => $code,
'name_ar' => $p['name_ar'],
'name_en' => $p['name_en'],
'source_module' => 'hr',
'category' => 'payroll',
'is_system' => 1,
'is_active' => 1,
'notes' => $p['notes'],
'created_at' => $now,
'updated_at' => $now,
]);
}
if ($db->selectOne("SELECT id FROM revenue_posting_rules WHERE stream_id = ? AND stage = 'payment'", [$streamId])) {
continue;
}
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => 1,
'stage' => 'payment',
'direction' => 'outflow',
'name_ar' => 'مؤشر حساب',
'debit_source' => 'fixed_account',
'debit_account_id' => $accountId,
'status' => 'active',
'effective_from' => '2000-01-01',
'notes' => 'مؤشر حساب — يحدد الحساب فقط، لا يوزّع مبلغًا',
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => $p['type'],
'allocation_method' => 'remainder',
'percentage_base' => 'net_after_fixed',
'account_id' => $accountId,
'recognition_method' => 'immediate',
'description_ar' => $p['desc'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment