Commit d74b0ee5 authored by DevPilot's avatar DevPilot

feat(accounting): implement accountant's spec sheet — split fees, branch...

feat(accounting): implement accountant's spec sheet — split fees, branch policy, government withholding

Sheraton's accountant handed over a 274-row spec for tomorrow's meeting.
Most of it was already correct (payroll, rentals, academies); this closes
the concrete gaps and builds the tools for the parts that are policy
decisions, not code.

ADDITION-FEE REVENUE SPLIT (spec rows 72-88)
The spec asks for the fee split "according to the ratio of the revenue"
without ever giving a ratio — but the ratio already existed:
ChildFeeCalculator/SpouseFeeCalculator compute a membership-value
component, a form-fee component and an annual-subscription component on
every addition. It just wasn't persisted past the display breakdown, so
by payment time nothing to split by. Now stored (fee_component_*
columns) and posted as three separate credits instead of one lump
"dependant addition" line, with a safety check that skips the split
(falls through unchanged) if the collected amount doesn't match the
stored components exactly.

MEMBERSHIP-FORM STAMP FEE (row 12)
BillingService's own price breakdown already reads "form fee: 500 —
stamp: 5" — the 505 EGP was never wrong, accounting just credited all of
it to form revenue. Now splits the 5 EGP to the government stamp
liability that already existed in the chart, unused
(23082103 طابع الشهداء). Only fires where a branch has it configured —
every other branch posts exactly as before.

BRANCH FEE SETTINGS — a real tool, not a guess
The spec argues with itself about several fees: "add this at Sheraton"
against "cancel it everywhere" in consecutive lines, "each branch has
its own card-commission rate" with a rate given for exactly one branch.
/accounting/branch-fees is where that gets decided per branch or once
for all of them, with a one-click "generalize to every branch" action —
covers the stamp fee, cheque clearing fee, bounced-cheque fee, and a
tiered card commission (threshold + rate + whole-vs-excess basis, with
a live preview). Seeded active for Sheraton with the exact figures the
spec gives; every other branch starts with nothing configured.

CARD COMMISSION — posted as an expense, not left in the bank figure
Implemented as a small adjusting entry after the main collection posts
(Dr commission expense / Cr the same card account, reclassifying part of
what was already debited) rather than rewritten into ~30 payment types'
posting logic. Verified: 50,000 EGP visa payment above a 10,000
threshold at 2% posts exactly 800.00, non-visa payments are untouched,
replay does not double-post.

GOVERNMENT WITHHOLDING ON EXPENSES (rows 223-264)
Ordinary stamp duty, additional stamp duty, commercial-profits
withholding — deducted at source from every vendor invoice, reusing
liability accounts the chart already had unused (23081202-23081204).
Rates are not guessed: Egyptian withholding schedules are progressive
law, not a percentage this migration could safely invent, so it ships
at 0% and inactive until finance sets real rates on the same settings
screen. Accounts payable now records the NET amount owed after
withholding, which is what the club actually pays.

CASH DISBURSEMENT BANNED FOR EXPENSES (rows 190-191)
"As a government institution, cash disbursement is forbidden — every
expense by cheque." Enforced once, at VendorPaymentService::createPayment,
with a system_config kill switch for the day this genuinely needs an
exception (logged as a deliberate override, not a silent bypass).

NOTES PAYABLE — cheques now a liability until the bank clears them
onVendorPaymentCompleted was crediting Bank directly the moment a
cheque was issued, before the bank had paid anything. Now credits
أوراق دفع (notes payable) instead, and /accounting/notes-payable is the
monthly reconciliation the spec asks for at rows 267-269: pick the
cheques the bank statement confirms cleared, post one closing entry.
Zero live vendor payments existed, so this changes no historical data.

Verified end-to-end against a scratch copy of production: every
scenario checked exactly against hand-computed expected values (not
just "it posted something") — split amounts, commission arithmetic,
withholding math, net payable — trial balance nets to 0.00, zero
unbalanced entries, zero postings to header accounts, every path
idempotent under replay.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent f239fadf
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\BranchFeeService;
use App\Modules\Accounting\Services\ExpenseTaxProfileService;
/**
* رسوم الفروع — where the accountant settles the policy questions the spec
* argues with itself over: "add this fee at Sheraton" against "cancel it
* everywhere", "each branch has its own card-commission rate" with no rate
* given for any branch but one.
*
* Every fee here starts inactive. Turning one on is a decision with a name
* and a date on it, exactly like the accrual gap tools — this screen is that
* same pattern applied to per-branch pricing policy instead of missing
* amounts.
*/
class BranchFeeController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.branch_fees.view');
$ready = BranchFeeService::ready();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
$settings = $ready ? BranchFeeService::all() : [];
// Group by fee code so the screen reads as "one policy, every branch's
// position on it" rather than a flat, order-independent table.
$byFee = [];
foreach (BranchFeeService::FEE_LABELS as $code => $label) {
$byFee[$code] = ['label' => $label, 'global' => null, 'branches' => []];
}
foreach ($settings as $s) {
if ($s['branch_id'] === null) {
$byFee[$s['fee_code']]['global'] = $s;
} else {
$byFee[$s['fee_code']]['branches'][(int) $s['branch_id']] = $s;
}
}
return $this->view('Accounting.Views.branch_fees.index', [
'ready' => $ready,
'branches' => $branches,
'byFee' => $byFee,
'taxReady' => ExpenseTaxProfileService::ready(),
'taxProfile' => ExpenseTaxProfileService::get(),
'cashBanEnabled' => $this->cashBanEnabled(),
]);
}
public function save(Request $request): Response
{
$this->authorize('accounting.branch_fees.manage');
$employee = App::getInstance()->currentEmployee();
$result = BranchFeeService::save([
'fee_code' => $request->post('fee_code'),
'branch_id' => $request->post('branch_id'),
'amount' => $request->post('amount'),
'threshold_amount' => $request->post('threshold_amount'),
'rate_percentage' => $request->post('rate_percentage'),
'rate_basis' => $request->post('rate_basis'),
'is_active' => $request->post('is_active'),
'notes' => $request->post('notes'),
'effective_from' => $request->post('effective_from'),
], $employee ? (int) $employee->id : null);
if (!$result['success']) {
return $this->redirect('/accounting/branch-fees')->withError($result['error']);
}
return $this->redirect('/accounting/branch-fees')->withSuccess('تم الحفظ');
}
/** "Generalize this to every branch" — the exact action several spec notes ask for. */
public function generalize(Request $request): Response
{
$this->authorize('accounting.branch_fees.manage');
$employee = App::getInstance()->currentEmployee();
$result = BranchFeeService::generalize(
(string) $request->post('fee_code', ''),
(int) $request->post('from_branch_id', 0),
$employee ? (int) $employee->id : null
);
if (!$result['success']) {
return $this->redirect('/accounting/branch-fees')->withError($result['error']);
}
return $this->redirect('/accounting/branch-fees')->withSuccess('اتعمّم على كل الفروع');
}
/** Card-commission preview — same arithmetic BranchFeeService::cardCommission posts with. */
public function previewCommission(Request $request): Response
{
$this->authorize('accounting.branch_fees.view');
$amount = (string) $request->get('amount', '0');
$threshold = (string) $request->get('threshold_amount', '0');
$rate = (string) $request->get('rate_percentage', '0');
$basis = (string) $request->get('rate_basis', 'excess_only');
$base = $basis === 'whole_amount' ? $amount : bcsub($amount, $threshold, 2);
$commission = bccomp($amount, $threshold, 2) <= 0 || bccomp($rate, '0', 4) <= 0
? '0.00'
: number_format((float) bcdiv(bcmul($base, $rate, 6), '100', 6), 2, '.', '');
return $this->json([
'ok' => true,
'commission' => $commission,
'net' => number_format((float) bcsub($amount, $commission, 2), 2, '.', ''),
]);
}
public function saveExpenseTax(Request $request): Response
{
$this->authorize('accounting.branch_fees.manage');
$employee = App::getInstance()->currentEmployee();
$result = ExpenseTaxProfileService::save([
'ordinary_stamp_pct' => $request->post('ordinary_stamp_pct'),
'additional_stamp_pct' => $request->post('additional_stamp_pct'),
'commercial_profit_pct' => $request->post('commercial_profit_pct'),
'is_active' => $request->post('is_active'),
'notes' => $request->post('notes'),
], $employee ? (int) $employee->id : null);
if (!$result['success']) {
return $this->redirect('/accounting/branch-fees')->withError($result['error']);
}
return $this->redirect('/accounting/branch-fees')->withSuccess('تم حفظ نسب الحسم الحكومي');
}
public function toggleCashBan(Request $request): Response
{
$this->authorize('accounting.branch_fees.manage');
$db = App::getInstance()->db();
$val = $request->post('expenses_cash_disbursement_allowed', '0') === '1' ? '1' : '0';
$exists = $db->selectOne("SELECT id FROM system_config WHERE config_key = 'expenses_cash_disbursement_allowed'");
if ($exists) {
$db->update('system_config', ['config_value' => $val], '`id` = ?', [(int) $exists['id']]);
} else {
$db->insert('system_config', ['config_key' => 'expenses_cash_disbursement_allowed', 'config_value' => $val]);
}
return $this->redirect('/accounting/branch-fees')->withSuccess(
$val === '1'
? 'تنبيه: الصرف النقدي على المصروفات مسموح دلوقتي — ده استثناء عن قاعدة المؤسسة الحكومية'
: 'تم تفعيل منع الصرف النقدي على المصروفات'
);
}
private function cashBanEnabled(): bool
{
try {
$row = App::getInstance()->db()->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'expenses_cash_disbursement_allowed'"
);
return ($row['config_value'] ?? '0') !== '1';
} catch (\Throwable) {
return true;
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\NotesPayableClosingService;
/**
* إقفال أوراق الدفع الشهري — the tool for rows 267–269: reconcile issued
* cheques against the bank statement and close the ones that cleared.
*/
class NotesPayableController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.notes_payable.view');
$db = App::getInstance()->db();
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 ORDER BY account_name_ar");
return $this->view('Accounting.Views.notes_payable.index', [
'open' => NotesPayableClosingService::open(),
'bankAccounts' => $bankAccounts,
]);
}
public function close(Request $request): Response
{
$this->authorize('accounting.notes_payable.manage');
$employee = App::getInstance()->currentEmployee();
$result = NotesPayableClosingService::close(
(array) $request->post('payment_ids', []),
(int) $request->post('bank_account_id', 0),
(string) $request->post('entry_date', ''),
$employee ? (int) $employee->id : null
);
if (!$result['success']) {
return $this->redirect('/accounting/notes-payable')->withError($result['error']);
}
return $this->redirect('/accounting/journal-entries/' . $result['journal_entry_id'])
->withSuccess('اتقفل ' . $result['closed'] . ' شيك بإجمالي ' . money($result['total']));
}
}
......@@ -189,6 +189,18 @@ return [
['POST', '/accounting/gaps', 'Accounting\Controllers\GapController@save', ['auth', 'csrf'], 'accounting.gaps.manage'],
['POST', '/accounting/gaps/academy-contracts', 'Accounting\Controllers\GapController@importAcademyContracts', ['auth', 'csrf'], 'accounting.gaps.manage'],
// ── Branch fee settings (per-branch policy, government withholding) ──
['GET', '/accounting/branch-fees', 'Accounting\Controllers\BranchFeeController@index', ['auth'], 'accounting.branch_fees.view'],
['POST', '/accounting/branch-fees', 'Accounting\Controllers\BranchFeeController@save', ['auth', 'csrf'], 'accounting.branch_fees.manage'],
['POST', '/accounting/branch-fees/generalize', 'Accounting\Controllers\BranchFeeController@generalize', ['auth', 'csrf'], 'accounting.branch_fees.manage'],
['GET', '/accounting/branch-fees/preview-commission', 'Accounting\Controllers\BranchFeeController@previewCommission', ['auth'], 'accounting.branch_fees.view'],
['POST', '/accounting/branch-fees/expense-tax', 'Accounting\Controllers\BranchFeeController@saveExpenseTax', ['auth', 'csrf'], 'accounting.branch_fees.manage'],
['POST', '/accounting/branch-fees/cash-ban', 'Accounting\Controllers\BranchFeeController@toggleCashBan', ['auth', 'csrf'], 'accounting.branch_fees.manage'],
// ── Notes payable monthly closing ─────────────────────────
['GET', '/accounting/notes-payable', 'Accounting\Controllers\NotesPayableController@index', ['auth'], 'accounting.notes_payable.view'],
['POST', '/accounting/notes-payable/close', 'Accounting\Controllers\NotesPayableController@close', ['auth', 'csrf'], 'accounting.notes_payable.manage'],
// ── Billing (universal collection) ──────────────────────
['GET', '/accounting/billing', 'Accounting\Controllers\BillingController@index', ['auth'], 'accounting.billing.view'],
['POST', '/accounting/billing/collect', 'Accounting\Controllers\BillingController@collect', ['auth', 'csrf'], 'accounting.billing.collect'],
......
......@@ -44,6 +44,31 @@ final class AccountingIntegrationService
return;
}
$branchId = self::resolveBranchId($data, $paymentId, $memberId);
// ── Dependant-addition split ─────────────────────────────
// The accountant's spec asks for this amount split "according to the
// ratio of the revenue" without ever stating the ratio. The ratio was
// never missing — ChildFeeCalculator/SpouseFeeCalculator compute it on
// every addition — it just was not persisted anywhere accounting could
// read it back from at payment time. Phase_111_001 added the memory;
// this reads it. Runs before postViaRule/legacy so it takes priority
// over the lump-sum credit either of those would otherwise post.
if ($type === 'addition_fee' && self::postAdditionFeeSplit($paymentId, $amount, $method, $memberId, $branchId, $data)) {
return;
}
// ── Membership-form stamp split ──────────────────────────
// Sheraton's form price already includes a 5 EGP «طابع الشهداء» —
// BillingService's own price breakdown says so ("رسوم الاستمارة: 500 —
// طابع شهداء: 5"). Nothing was crediting the 5 EGP to the government
// liability account it belongs in; all of it landed in form-fee revenue.
// Only fires where a branch has an active stamp-fee row — everywhere
// else this returns false and the amount posts exactly as before.
if ($type === 'form_fee' && self::postFormFeeWithStamp($paymentId, $amount, $method, $memberId, $branchId)) {
return;
}
// ── Account determination ───────────────────────────────
// A configured posting rule wins. Without one we fall through to the legacy
// hardcoded mapping below, so an unconfigured stream keeps posting as before.
......@@ -191,6 +216,252 @@ final class AccountingIntegrationService
return $routed['handled'];
}
/** The branch a payment belongs to — via its treasury first, the member second. */
private static function resolveBranchId(array $data, int $paymentId, int $memberId): ?int
{
$db = App::getInstance()->db();
$treasuryId = isset($data['treasury_id']) && $data['treasury_id'] ? (int) $data['treasury_id'] : null;
if ($treasuryId === null && $paymentId > 0) {
$row = $db->selectOne("SELECT treasury_id FROM payments WHERE id = ?", [$paymentId]);
$treasuryId = $row && $row['treasury_id'] ? (int) $row['treasury_id'] : null;
}
if ($treasuryId !== null) {
$row = $db->selectOne("SELECT branch_id FROM treasuries WHERE id = ?", [$treasuryId]);
if ($row && $row['branch_id']) {
return (int) $row['branch_id'];
}
}
if ($memberId > 0) {
$row = $db->selectOne("SELECT branch_id FROM members WHERE id = ?", [$memberId]);
if ($row && $row['branch_id']) {
return (int) $row['branch_id'];
}
}
return null;
}
/**
* Split an addition-fee collection across the components the fee calculator
* already computed at creation time — membership-value percentage, form fee,
* annual subscription — instead of crediting the whole thing to one lump
* "dependant addition" account.
*
* @return bool true when this fully handled the posting (caller must return)
*/
private static function postAdditionFeeSplit(
int $paymentId,
string $amount,
string $method,
int $memberId,
?int $branchId,
array $data
): bool {
// A retried or re-dispatched event must not post this twice. Checked
// first and returns true (handled, nothing more to do) rather than
// false, which would send the caller on to postViaRule/legacy and post
// the SAME payment a second time through a different path.
if ($paymentId > 0 && \App\Modules\Accounting\Models\JournalEntry::findByReference('payment', $paymentId)) {
return true;
}
$db = App::getInstance()->db();
$payment = $paymentId > 0 ? $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]) : null;
$relType = $payment['related_entity_type'] ?? null;
$relId = $payment['related_entity_id'] ?? null;
if (!$relType || !$relId || !\in_array($relType, ['children', 'spouses'], true)) {
return false; // no traceable dependant record — fall through unchanged
}
$row = $db->selectOne("SELECT * FROM `{$relType}` WHERE id = ?", [(int) $relId]);
if (!$row) {
return false;
}
$membershipComponent = self::money((string) ($row['fee_component_membership'] ?? '0'));
$formComponent = self::money((string) ($row['fee_component_form'] ?? '0'));
$annualComponent = self::money((string) ($row['fee_component_annual'] ?? '0'));
$componentTotal = bcadd(bcadd($membershipComponent, $formComponent, 2), $annualComponent, 2);
if (bccomp($componentTotal, '0.00', 2) <= 0) {
return false; // pre-migration record with no stored breakdown — fall through
}
// The components were computed when the dependant was added; the amount
// actually collected can differ (a partial payment, a manual override).
// Rather than post a number nobody can trace back to a real transaction,
// only apply the split when the two agree.
if (bccomp($componentTotal, $amount, 2) !== 0) {
Logger::info('Addition-fee split skipped — collected amount does not match stored components', [
'payment_id' => $paymentId, 'collected' => $amount, 'components' => $componentTotal,
]);
return false;
}
$treasuryId = isset($data['treasury_id']) && $data['treasury_id'] ? (int) $data['treasury_id'] : null;
$debitAccountCode = AccountCodes::debitAccountForTreasury($method, $treasuryId);
$debitAccount = self::getAccountByCode($debitAccountCode);
if (!$debitAccount) {
Logger::error('Addition-fee split failed: debit account unresolved', ['payment_id' => $paymentId]);
return false;
}
$membershipAccountId = PostingRouter::accountFor('payment:addition_fee', AccountCodes::DEPENDENT_ADDITION_REVENUE, 'collection');
$formAccountId = PostingRouter::accountFor('payment:form_fee', AccountCodes::FORM_FEE_REVENUE, 'collection');
$annualAccountId = PostingRouter::accountFor('subscription:annual_accrual', AccountCodes::MEMBERSHIP_RENEWAL_REVENUE, 'accrual');
$receipt = !empty($payment['receipt_id'])
? $db->selectOne("SELECT receipt_number FROM receipts WHERE id = ?", [(int) $payment['receipt_id']])
: null;
$receiptNumber = $receipt['receipt_number'] ?? '';
$description = 'رسوم إضافة تابع' . ($receiptNumber ? ' — إيصال ' . $receiptNumber : '');
$lines = [[
'account_id' => (int) $debitAccount['id'],
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $description,
'member_id' => $memberId > 0 ? $memberId : null,
]];
$addCredit = static function (?int $accountId, string $amt, string $label) use (&$lines, $description, $memberId): void {
if ($accountId === null || bccomp($amt, '0.00', 2) <= 0) {
return;
}
$lines[] = [
'account_id' => $accountId,
'debit' => '0.00',
'credit' => $amt,
'description_ar' => $label . ' — ' . $description,
'member_id' => $memberId > 0 ? $memberId : null,
];
};
$addCredit($membershipAccountId, $membershipComponent, 'رسوم إضافة (نسبة من قيمة العضوية)');
$addCredit($formAccountId, $formComponent, 'رسوم استمارة إضافة');
$addCredit($annualAccountId, $annualComponent, 'اشتراك سنوي');
if (\count($lines) < 2) {
Logger::error('Addition-fee split failed: no credit accounts resolved', ['payment_id' => $paymentId]);
return false;
}
$result = JournalService::createEntry([
'entry_date' => $payment['payment_date'] ?? date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Dependant addition fee, split by component',
'reference_type' => 'payment',
'reference_id' => $paymentId,
'reference_number' => $receiptNumber,
'source_module' => 'payments',
'branch_id' => $branchId,
'is_auto_generated' => 1,
], $lines, true);
if (!$result['success']) {
Logger::error('Addition-fee split entry failed', ['payment_id' => $paymentId, 'error' => $result['error'] ?? '']);
return false;
}
return true;
}
/**
* Split a membership-form-fee collection between form revenue and the
* government stamp-duty liability, when the branch has an active stamp fee
* configured. Silently declines (returns false) everywhere else, so a branch
* with nothing configured posts exactly as it always has.
*/
private static function postFormFeeWithStamp(int $paymentId, string $amount, string $method, int $memberId, ?int $branchId): bool
{
// Same reasoning as postAdditionFeeSplit: already posted means handled,
// not "fall through and post again via the legacy path".
if ($paymentId > 0 && \App\Modules\Accounting\Models\JournalEntry::findByReference('payment', $paymentId)) {
return true;
}
$stamp = BranchFeeService::amount('martyr_stamp', $branchId);
if (bccomp($stamp, '0.00', 2) <= 0) {
return false;
}
if (bccomp($amount, $stamp, 2) <= 0) {
Logger::error('Form-fee stamp split skipped — collected amount does not exceed the stamp fee', [
'payment_id' => $paymentId, 'amount' => $amount, 'stamp' => $stamp,
]);
return false;
}
$db = App::getInstance()->db();
$payment = $paymentId > 0 ? $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]) : null;
$treasuryId = $payment['treasury_id'] ?? null;
$debitAccountCode = AccountCodes::debitAccountForTreasury($method, $treasuryId !== null ? (int) $treasuryId : null);
$debitAccount = self::getAccountByCode($debitAccountCode);
$formAccountId = PostingRouter::accountFor('payment:form_fee', AccountCodes::FORM_FEE_REVENUE, 'collection');
$stampAccountId = PostingRouter::accountFor('treasury:martyr_stamp', '23082103', 'collection');
if (!$debitAccount || $formAccountId === null || $stampAccountId === null) {
Logger::error('Form-fee stamp split failed: an account is unresolved', [
'payment_id' => $paymentId, 'debit' => $debitAccount['id'] ?? null,
'form' => $formAccountId, 'stamp' => $stampAccountId,
]);
return false;
}
$revenuePortion = bcsub($amount, $stamp, 2);
$receipt = !empty($payment['receipt_id'] ?? null)
? $db->selectOne("SELECT receipt_number FROM receipts WHERE id = ?", [(int) $payment['receipt_id']])
: null;
$receiptNumber = $receipt['receipt_number'] ?? '';
$description = 'رسوم استمارة عضوية' . ($receiptNumber ? ' — إيصال ' . $receiptNumber : '');
$result = JournalService::createEntry([
'entry_date' => $payment['payment_date'] ?? date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Membership form fee, split with government stamp duty',
'reference_type' => 'payment',
'reference_id' => $paymentId,
'reference_number' => $receiptNumber,
'source_module' => 'payments',
'branch_id' => $branchId,
'is_auto_generated' => 1,
], [
[
'account_id' => (int) $debitAccount['id'],
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $description,
'member_id' => $memberId > 0 ? $memberId : null,
],
[
'account_id' => $formAccountId,
'debit' => '0.00',
'credit' => $revenuePortion,
'description_ar' => $description,
'member_id' => $memberId > 0 ? $memberId : null,
],
[
'account_id' => $stampAccountId,
'debit' => '0.00',
'credit' => $stamp,
'description_ar' => 'طابع الشهداء — ' . $description,
],
], true);
if (!$result['success']) {
Logger::error('Form-fee stamp split entry failed', ['payment_id' => $paymentId, 'error' => $result['error'] ?? '']);
return false;
}
return true;
}
/**
* Auto-reverse journal entry when a payment is voided.
*/
......@@ -1218,11 +1489,51 @@ final class AccountingIntegrationService
];
}
// Cr. Accounts Payable (total)
// ── Government withholding, deducted at source ───────────────
// The accountant's spec applies stamp duty and commercial-profits
// withholding to every expense category uniformly — the club never pays
// a supplier the gross invoice; it withholds these and remits them to
// the government separately. Silent (adds nothing) until finance sets
// real rates on the settings screen — see ExpenseTaxProfileService for
// why the rates are not guessed here.
$netPayable = $totalAmount;
$profile = ExpenseTaxProfileService::active();
if ($profile !== null) {
$withholding = ExpenseTaxProfileService::compute($subtotal, $profile);
$ordinaryStampId = PostingRouter::accountFor('expense:ordinary_stamp', '23081202', 'accrual');
$additionalStampId = PostingRouter::accountFor('expense:additional_stamp', '23081203', 'accrual');
$commercialTaxId = PostingRouter::accountFor('expense:commercial_profit', '23081204', 'accrual');
$withheldTotal = '0.00';
foreach ([
[$ordinaryStampId, $withholding['ordinary_stamp'], 'الدمغة العادية'],
[$additionalStampId, $withholding['additional_stamp'], 'الدمغة الإضافية'],
[$commercialTaxId, $withholding['commercial_profit'], 'ضريبة الأرباح التجارية والصناعية'],
] as [$accountId, $amt, $label]) {
if ($accountId === null || bccomp($amt, '0.00', 2) <= 0) {
continue;
}
$lines[] = [
'account_id' => $accountId,
'debit' => '0.00',
'credit' => $amt,
'description_ar' => $label . ' مخصومة عند المنبع — فاتورة مورد ' . $invoiceNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
];
$withheldTotal = bcadd($withheldTotal, $amt, 2);
}
$netPayable = bcsub($totalAmount, $withheldTotal, 2);
}
// Cr. Accounts Payable (net of any withholding)
$lines[] = [
'account_id' => $apAccountId,
'debit' => '0.00',
'credit' => $totalAmount,
'credit' => $netPayable,
'description_ar' => 'دائنون — فاتورة مورد ' . $invoiceNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
];
......@@ -1259,9 +1570,11 @@ final class AccountingIntegrationService
'due_date' => $invoice['due_date'] ?? date('Y-m-d', strtotime('+30 days')),
'description_ar' => $description,
'description_en' => 'Vendor invoice — ' . $invoiceNumber,
'total_amount' => $totalAmount,
// Net of withholding — this is what the club actually owes the
// supplier in cash or cheque, not the gross invoice total.
'total_amount' => $netPayable,
'paid_amount' => '0.00',
'balance' => $totalAmount,
'balance' => $netPayable,
'currency' => $invoice['currency'] ?? 'EGP',
'status' => 'pending',
'journal_entry_id' => $result['journal_entry_id'],
......@@ -1303,18 +1616,27 @@ final class AccountingIntegrationService
return;
}
$cashBankCode = \in_array($paymentMethod, ['bank_transfer', 'check', 'wire'], true)
// A cheque is not bank cash the moment it is signed — the bank has not
// paid it yet. Crediting Bank directly here would leave the account
// short by every outstanding cheque until, coincidentally, the bank
// statement was reconciled some other way. Notes Payable is the
// holding account; NotesPayableClosingService moves it to Bank when the
// statement actually shows the cheque cleared — see Phase_111_004.
$isCheque = $paymentMethod === 'check';
$cashBankCode = \in_array($paymentMethod, ['bank_transfer', 'wire'], true)
? AccountCodes::CASH_AT_BANK
: AccountCodes::CASH_ON_HAND;
$apAccountId = PostingRouter::accountFor('procurement:payable', '230601002', 'accrual');
$cashBankAccountId = PostingRouter::accountFor('procurement:cash_out', $cashBankCode, 'payment');
$apAccountId = PostingRouter::accountFor('procurement:payable', '230601002', 'accrual');
$creditLegId = $isCheque
? PostingRouter::accountFor('instrument:notes_payable', '23060201', 'payment')
: PostingRouter::accountFor('procurement:cash_out', $cashBankCode, 'payment');
if ($apAccountId === null || $cashBankAccountId === null) {
if ($apAccountId === null || $creditLegId === null) {
Logger::error("Vendor payment auto-post failed: accounts unresolved", [
'payment_id' => $paymentId,
'payable' => $apAccountId,
'cash' => $cashBankAccountId,
'credit_leg' => $creditLegId,
]);
return;
}
......@@ -1344,10 +1666,10 @@ final class AccountingIntegrationService
'supplier_id' => $supplierId > 0 ? $supplierId : null,
],
[
'account_id' => $cashBankAccountId,
'account_id' => $creditLegId,
'debit' => '0.00',
'credit' => $amount,
'description_ar' => 'صرف نقدي/بنكي — ' . $paymentNumber,
'description_ar' => $isCheque ? 'أوراق دفع — ' . $paymentNumber : 'صرف نقدي/بنكي — ' . $paymentNumber,
],
], true);
......@@ -1546,6 +1868,11 @@ final class AccountingIntegrationService
);
}
private static function money(string $v): string
{
return number_format((float) $v, 2, '.', '');
}
/**
* Record (or refresh) the open receivable behind an accrual.
*
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* The one answer to "how much is this fee at this branch".
*
* The accountant's spec describes three of these fees as live policy debates,
* not settled numbers — the same note arguing to "generalize this fee to every
* branch" and "cancel it everywhere" in consecutive lines (rows 12, 31, 32, 39
* of the spec). A migration cannot resolve a policy debate; it can only make
* the debate resolvable without touching code again. That is what this class
* and its settings screen are for.
*
* Resolution order: a row scoped to the specific branch wins; failing that, a
* row with branch_id NULL is the club-wide default; failing that, the fee does
* not apply. "Generalize to every branch" is then just deleting the branch-
* specific row and activating the global one — no redeploy.
*/
final class BranchFeeService
{
private const SCALE = 2;
public const FEE_LABELS = [
'martyr_stamp' => 'طابع الشهداء — يُضاف على بيع استمارة العضوية',
'cheque_clearing_fee' => 'مصاريف مقاصة — على كل شيك يُستلم في بيع بالتقسيط',
'bounced_cheque_fee' => 'غرامة تحصيل شيك مرتد',
'card_commission' => 'عمولة تحصيل الفيزا/البطاقات',
];
public static function ready(): bool
{
try {
$row = App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'branch_fee_settings'"
);
return ((int) ($row['n'] ?? 0)) === 1;
} catch (\Throwable) {
return false;
}
}
/** The active setting for a fee at a branch, branch-specific first, then global. */
public static function resolve(string $feeCode, ?int $branchId): ?array
{
if (!self::ready()) {
return null;
}
$db = App::getInstance()->db();
if ($branchId !== null) {
$row = $db->selectOne(
"SELECT * FROM branch_fee_settings WHERE fee_code = ? AND branch_id = ? AND is_active = 1",
[$feeCode, $branchId]
);
if ($row) {
return $row;
}
}
return $db->selectOne(
"SELECT * FROM branch_fee_settings WHERE fee_code = ? AND branch_id IS NULL AND is_active = 1",
[$feeCode]
);
}
/** The flat amount for a fee, or '0.00' when none is configured. */
public static function amount(string $feeCode, ?int $branchId): string
{
$row = self::resolve($feeCode, $branchId);
return $row !== null ? self::money((string) ($row['amount'] ?? '0')) : '0.00';
}
/**
* Card-commission calculator, isolated so the settings screen and the actual
* posting call the exact same arithmetic — a preview that lies about what
* will post is worse than no preview.
*/
public static function cardCommission(string $amount, ?int $branchId): string
{
$row = self::resolve('card_commission', $branchId);
if ($row === null) {
return '0.00';
}
$amount = self::money($amount);
$threshold = self::money((string) ($row['threshold_amount'] ?? '0'));
$rate = (string) ($row['rate_percentage'] ?? '0');
$basis = (string) ($row['rate_basis'] ?? 'excess_only');
if (bccomp($amount, $threshold, self::SCALE) <= 0 || bccomp($rate, '0', 4) <= 0) {
return '0.00';
}
$base = $basis === 'whole_amount' ? $amount : bcsub($amount, $threshold, self::SCALE);
return self::money(bcdiv(bcmul($base, $rate, 6), '100', 6));
}
/** Every fee row, for the settings screen — branch-specific and global together. */
public static function all(): array
{
if (!self::ready()) {
return [];
}
return App::getInstance()->db()->select(
"SELECT f.*, b.name_ar AS branch_name
FROM branch_fee_settings f
LEFT JOIN branches b ON b.id = f.branch_id
ORDER BY FIELD(f.fee_code, 'martyr_stamp','cheque_clearing_fee','bounced_cheque_fee','card_commission'),
f.branch_id IS NULL DESC, b.name_ar"
);
}
public static function save(array $input, ?int $employeeId): array
{
if (!self::ready()) {
return ['success' => false, 'error' => 'الجدول لسه مش منصّب'];
}
$feeCode = (string) ($input['fee_code'] ?? '');
if (!isset(self::FEE_LABELS[$feeCode])) {
return ['success' => false, 'error' => 'نوع رسم غير معروف'];
}
$branchId = self::nullableInt($input['branch_id'] ?? null);
$isActive = !empty($input['is_active']) ? 1 : 0;
$notes = trim((string) ($input['notes'] ?? ''));
if ($isActive && $notes === '') {
return ['success' => false, 'error' => 'اكتب سبب أو مرجع الرسم — ده اللي المراجع هيسأل عنه'];
}
$amount = null;
$threshold = null;
$rate = null;
$basis = null;
if ($feeCode === 'card_commission') {
$threshold = self::money((string) ($input['threshold_amount'] ?? '0'));
$rate = (string) ($input['rate_percentage'] ?? '0');
$basis = (string) ($input['rate_basis'] ?? 'excess_only');
if (!\in_array($basis, ['whole_amount', 'excess_only'], true)) {
return ['success' => false, 'error' => 'أساس النسبة غير معروف'];
}
if ($isActive && bccomp($rate, '0', 4) <= 0) {
return ['success' => false, 'error' => 'النسبة لازم تكون أكبر من صفر'];
}
if ($isActive && bccomp($threshold, '0', self::SCALE) < 0) {
return ['success' => false, 'error' => 'الحد الأدنى مينفعش يكون سالب'];
}
} else {
$amount = self::money((string) ($input['amount'] ?? '0'));
if ($isActive && bccomp($amount, '0', self::SCALE) <= 0) {
return ['success' => false, 'error' => 'المبلغ لازم يكون أكبر من صفر'];
}
}
$from = trim((string) ($input['effective_from'] ?? ''));
if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
return ['success' => false, 'error' => 'تاريخ السريان غير صحيح'];
}
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$payload = [
'amount' => $amount,
'threshold_amount' => $threshold,
'rate_percentage' => $rate,
'rate_basis' => $basis,
'is_active' => $isActive,
'notes' => $notes !== '' ? mb_substr($notes, 0, 500) : null,
'effective_from' => $from !== '' ? $from : null,
'approved_by' => $employeeId,
'approved_at' => $now,
'updated_at' => $now,
];
$existing = $db->selectOne(
$branchId === null
? "SELECT id FROM branch_fee_settings WHERE fee_code = ? AND branch_id IS NULL"
: "SELECT id FROM branch_fee_settings WHERE fee_code = ? AND branch_id = ?",
$branchId === null ? [$feeCode] : [$feeCode, $branchId]
);
if ($existing) {
$db->update('branch_fee_settings', $payload, '`id` = ?', [(int) $existing['id']]);
} else {
$db->insert('branch_fee_settings', $payload + [
'fee_code' => $feeCode,
'branch_id' => $branchId,
'created_at' => $now,
]);
}
return ['success' => true, 'error' => null];
}
/**
* "Generalize to every branch" — the exact action the sheet's notes ask for
* on the stamp fee (row 12) and debate for the card commission (row 37): copy
* a branch's active setting to the global (branch_id NULL) row, so every
* branch without its own override now gets it.
*/
public static function generalize(string $feeCode, int $fromBranchId, ?int $employeeId): array
{
$source = self::resolve($feeCode, $fromBranchId);
if ($source === null) {
return ['success' => false, 'error' => 'مفيش إعداد مفعّل للفرع ده يتعمم منه'];
}
return self::save([
'fee_code' => $feeCode,
'branch_id' => null,
'amount' => $source['amount'],
'threshold_amount' => $source['threshold_amount'],
'rate_percentage' => $source['rate_percentage'],
'rate_basis' => $source['rate_basis'],
'is_active' => 1,
'notes' => 'معمَّم من ' . ($source['branch_name'] ?? ('فرع #' . $fromBranchId))
. ' — ' . ($source['notes'] ?? ''),
'effective_from' => date('Y-m-d'),
], $employeeId);
}
private static function nullableInt(mixed $v): ?int
{
return ($v === null || $v === '' || (int) $v <= 0) ? null : (int) $v;
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Models\JournalEntry;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
/**
* The card-processor's cut, recognised as an expense instead of silently
* shrinking what lands in the bank.
*
* The spec's own entry for this (rows 34–37) writes the commission as an
* EXTRA debit alongside the card debit, with the credit side unchanged — which
* only balances if the bank side is actually the NET amount (gross minus
* commission) and the commission is the difference. That is exactly how a
* merchant-service fee taken at source works: the member is charged the full
* membership value, the processor deposits value-minus-commission, and the
* gap has to be recognised as an expense or the books would not reconcile with
* the bank statement.
*
* Implemented as a small ADJUSTING entry after the main collection posts,
* rather than rewritten into every revenue stream's collection logic: Dr
* commission expense / Cr the same card account the main entry just debited —
* reclassifying part of that debit as an expense rather than cash. The net
* effect on the bank account is identical to netting the fee at the point of
* collection, without touching how any of the ~30 payment types post today.
*
* Fires only for payment_method = 'visa' — the value AccountCodes already uses
* to mean a card transaction — and only where a branch has an active
* card_commission row. Everywhere else this does nothing.
*/
final class CardCommissionPostingService
{
public static function onPaymentCompleted(array $data): void
{
try {
self::run($data);
} catch (\Throwable $e) {
Logger::error('Card commission posting failed: ' . $e->getMessage());
}
}
private static function run(array $data): void
{
$method = (string) ($data['method'] ?? '');
if ($method !== 'visa') {
return;
}
$paymentId = (int) ($data['payment_id'] ?? 0);
$amount = (string) ($data['amount'] ?? '0');
if ($paymentId <= 0 || bccomp($amount, '0.00', 2) <= 0) {
return;
}
// Idempotent: a retried or re-dispatched event must not double the
// adjustment.
if (JournalEntry::findByReference('card_commission', $paymentId)) {
return;
}
$db = App::getInstance()->db();
$treasuryId = isset($data['treasury_id']) && $data['treasury_id'] ? (int) $data['treasury_id'] : null;
$branchId = null;
if ($treasuryId !== null) {
$row = $db->selectOne("SELECT branch_id FROM treasuries WHERE id = ?", [$treasuryId]);
$branchId = $row && $row['branch_id'] ? (int) $row['branch_id'] : null;
}
if ($branchId === null && !empty($data['member_id'])) {
$row = $db->selectOne("SELECT branch_id FROM members WHERE id = ?", [(int) $data['member_id']]);
$branchId = $row && $row['branch_id'] ? (int) $row['branch_id'] : null;
}
$commission = BranchFeeService::cardCommission($amount, $branchId);
if (bccomp($commission, '0.00', 2) <= 0) {
return;
}
// The exact account the main entry debited for this card payment — the
// pointer PostingRouter/AccountCodes resolve for a visa collection.
$cardAccountId = PostingRouter::accountFor('treasury:method_visa', \App\Modules\Accounting\AccountCodes::CASH_AT_BANK, 'collection');
$expenseAccountId = PostingRouter::accountFor('treasury:card_commission_expense', '331401', 'payment');
if ($cardAccountId === null || $expenseAccountId === null) {
Logger::error('Card commission adjustment failed: an account is unresolved', [
'payment_id' => $paymentId, 'card' => $cardAccountId, 'expense' => $expenseAccountId,
]);
return;
}
if ($cardAccountId === $expenseAccountId) {
return; // misconfigured pointers — refuse rather than post a no-op
}
$description = 'عمولة تحصيل بالفيزا — دفعة رقم ' . $paymentId;
$result = JournalService::createEntry([
'entry_date' => (string) ($data['payment_date'] ?? date('Y-m-d')),
'description_ar' => $description,
'description_en' => 'Card commission adjustment',
'reference_type' => 'card_commission',
'reference_id' => $paymentId,
'source_module' => 'payments',
'branch_id' => $branchId,
'is_auto_generated' => 1,
'notes' => 'قيد تسوية — بيحوّل جزء من رصيد البطاقة اللي اتحصّله المتحصل الرئيسي '
. 'لمصروف عمولة، عشان رصيد الحساب يتفق مع كشف حساب البنك.',
], [
[
'account_id' => $expenseAccountId,
'debit' => $commission,
'credit' => '0.00',
'description_ar' => $description,
],
[
'account_id' => $cardAccountId,
'debit' => '0.00',
'credit' => $commission,
'description_ar' => $description,
],
], true);
if (!$result['success']) {
Logger::error('Card commission adjustment entry failed', [
'payment_id' => $paymentId, 'error' => $result['error'] ?? '',
]);
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* The government withholding applied to every expense the club pays a
* supplier for — ordinary stamp duty, additional stamp duty, commercial-profits
* withholding tax — deducted at source, exactly as the accountant's spec
* applies it uniformly across operating companies, operating expenses,
* academies and sundry expenses.
*
* One active profile drives every vendor invoice. The rates are not guessed
* here — see the migration for why — so this ships inactive with every rate
* at zero, and `onVendorInvoiceApproved` adds no withholding lines until
* finance sets real percentages and switches it on.
*/
final class ExpenseTaxProfileService
{
public static function ready(): bool
{
try {
$row = App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'expense_tax_profiles'"
);
return ((int) ($row['n'] ?? 0)) === 1;
} catch (\Throwable) {
return false;
}
}
/** The one active profile, or null when withholding is switched off. */
public static function active(): ?array
{
if (!self::ready()) {
return null;
}
return App::getInstance()->db()->selectOne(
"SELECT * FROM expense_tax_profiles WHERE is_active = 1 ORDER BY id LIMIT 1"
);
}
public static function get(): ?array
{
if (!self::ready()) {
return null;
}
return App::getInstance()->db()->selectOne("SELECT * FROM expense_tax_profiles ORDER BY id LIMIT 1");
}
/**
* What withholding this profile takes off a given expense subtotal, per leg.
* Used both by the actual posting and by the settings screen's preview, so
* a preview can never show a different number than what will post.
*/
public static function compute(string $subtotal, array $profile): array
{
$pct = static fn(string $p): string => number_format(
(float) bcdiv(bcmul($subtotal, $p, 6), '100', 6), 2, '.', ''
);
return [
'ordinary_stamp' => $pct((string) ($profile['ordinary_stamp_pct'] ?? '0')),
'additional_stamp' => $pct((string) ($profile['additional_stamp_pct'] ?? '0')),
'commercial_profit' => $pct((string) ($profile['commercial_profit_pct'] ?? '0')),
];
}
public static function save(array $input, ?int $employeeId): array
{
if (!self::ready()) {
return ['success' => false, 'error' => 'الجدول لسه مش منصّب'];
}
$ordinary = (string) ($input['ordinary_stamp_pct'] ?? '0');
$additional = (string) ($input['additional_stamp_pct'] ?? '0');
$commercial = (string) ($input['commercial_profit_pct'] ?? '0');
$isActive = !empty($input['is_active']) ? 1 : 0;
$notes = trim((string) ($input['notes'] ?? ''));
foreach (['ordinary_stamp_pct' => $ordinary, 'additional_stamp_pct' => $additional, 'commercial_profit_pct' => $commercial] as $label => $v) {
if (bccomp($v, '0', 4) < 0 || bccomp($v, '100', 4) > 0) {
return ['success' => false, 'error' => 'النسبة لازم تكون بين ٠ و١٠٠'];
}
}
$allZero = bccomp($ordinary, '0', 4) <= 0 && bccomp($additional, '0', 4) <= 0 && bccomp($commercial, '0', 4) <= 0;
if ($isActive && $allZero) {
return ['success' => false, 'error' => 'مينفعش تفعّل الحسم والنسب كلها صفر'];
}
if ($isActive && $notes === '') {
return ['success' => false, 'error' => 'اكتب سبب/مرجع النسب — ده اللي المراجع هيسأل عنه'];
}
$profile = self::get();
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$payload = [
'ordinary_stamp_pct' => $ordinary,
'additional_stamp_pct' => $additional,
'commercial_profit_pct' => $commercial,
'is_active' => $isActive,
'notes' => $notes !== '' ? mb_substr($notes, 0, 500) : null,
'approved_by' => $employeeId,
'approved_at' => $now,
'updated_at' => $now,
];
if ($profile) {
$db->update('expense_tax_profiles', $payload, '`id` = ?', [(int) $profile['id']]);
} else {
$db->insert('expense_tax_profiles', $payload + ['name_ar' => 'الحسم الحكومي الافتراضي على المصروفات', 'created_at' => $now]);
}
return ['success' => true, 'error' => null];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
/**
* The monthly reconciliation the spec asks for at rows 267–269: "close notes
* payable at the bank monthly, from the bank statement."
*
* A cheque handed to a supplier sits in the أوراق دفع liability from the day it
* is issued — see the change to onVendorPaymentCompleted — until the bank
* statement shows it actually cleared. This is where the accountant confirms
* which ones cleared and posts the one entry that moves them from "we owe
* this" to "the bank paid it": Dr Notes Payable / Cr Bank.
*/
final class NotesPayableClosingService
{
/** Cheque payments still open — issued, not yet confirmed against a statement. */
public static function open(): array
{
try {
return App::getInstance()->db()->select(
"SELECT p.id, p.payment_number, p.amount, p.payment_date, p.check_number,
s.name_ar AS supplier_name
FROM vendor_payments p
LEFT JOIN suppliers s ON s.id = p.supplier_id
WHERE p.payment_method = 'check'
AND p.status = 'completed'
AND p.notes_payable_closed_at IS NULL
AND p.is_archived = 0
ORDER BY p.payment_date ASC"
);
} catch (\Throwable) {
return [];
}
}
/**
* Post the closing entry for the selected cheques and mark them closed.
*
* @param int[] $paymentIds
*/
public static function close(array $paymentIds, int $bankAccountId, ?string $entryDate, ?int $employeeId): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $paymentIds), static fn(int $i): bool => $i > 0)));
if (!$ids) {
return ['success' => false, 'error' => 'ما اخترتش أي شيك', 'closed' => 0, 'total' => '0.00'];
}
if ($bankAccountId <= 0) {
return ['success' => false, 'error' => 'اختار الحساب البنكي', 'closed' => 0, 'total' => '0.00'];
}
$db = App::getInstance()->db();
$bank = $db->selectOne(
"SELECT gl_account_id FROM bank_accounts WHERE id = ? AND is_active = 1",
[$bankAccountId]
);
if (!$bank || empty($bank['gl_account_id'])) {
return ['success' => false, 'error' => 'الحساب البنكي مش مربوط بحساب في الدليل', 'closed' => 0, 'total' => '0.00'];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = $db->select(
"SELECT * FROM vendor_payments
WHERE id IN ({$placeholders}) AND payment_method = 'check' AND status = 'completed'
AND notes_payable_closed_at IS NULL",
$ids
);
if (!$rows) {
return ['success' => false, 'error' => 'الشيكات دي مقفولة بالفعل أو مش موجودة', 'closed' => 0, 'total' => '0.00'];
}
$notesPayableId = PostingRouter::accountFor('instrument:notes_payable', '23060201', 'payment');
if ($notesPayableId === null) {
return ['success' => false, 'error' => 'حساب أوراق الدفع غير محدد', 'closed' => 0, 'total' => '0.00'];
}
$total = '0.00';
$lines = [];
foreach ($rows as $r) {
$total = bcadd($total, (string) $r['amount'], 2);
}
$lines[] = [
'account_id' => $notesPayableId,
'debit' => $total,
'credit' => '0.00',
'description_ar' => 'إقفال أوراق دفع شهري — من كشف حساب البنك',
];
$lines[] = [
'account_id' => (int) $bank['gl_account_id'],
'debit' => '0.00',
'credit' => $total,
'description_ar' => 'إقفال أوراق دفع شهري — من كشف حساب البنك',
];
$date = $entryDate && preg_match('/^\d{4}-\d{2}-\d{2}$/', $entryDate) ? $entryDate : date('Y-m-d');
$result = JournalService::createEntry([
'entry_date' => $date,
'description_ar' => 'إقفال أوراق دفع شهري (' . count($rows) . ' شيك)',
'description_en' => 'Monthly notes payable closing',
'reference_type' => 'notes_payable_closing',
'source_module' => 'accounting',
'is_auto_generated' => 0,
'notes' => 'من كشف حساب البنك — بيقفل ' . count($rows) . ' شيك بإجمالي ' . $total,
], $lines, true);
if (empty($result['success'])) {
return ['success' => false, 'error' => $result['error'] ?? 'فشل ترحيل القيد', 'closed' => 0, 'total' => '0.00'];
}
$entryId = (int) $result['journal_entry_id'];
$now = date('Y-m-d H:i:s');
foreach ($rows as $r) {
$db->update('vendor_payments', [
'notes_payable_closed_at' => $now,
'notes_payable_closing_entry_id' => $entryId,
], '`id` = ?', [(int) $r['id']]);
}
return ['success' => true, 'error' => null, 'closed' => count($rows), 'total' => $total, 'journal_entry_id' => $entryId];
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>رسوم الفروع<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<h2 style="margin:6px 0 4px;">رسوم الفروع والحسم الحكومي</h2>
<p style="margin:0;color:#6B7280;font-size:13px;line-height:1.9;max-width:820px;">
الأرقام دي مش ناقصة كود — هي قرارات سياسة. طابع الشهداء اتضاف في شيراتون وملوش
ذكر في باقي الفروع، وعمولة الفيزا «كل فرع له نسبة» من غير ما تتحدد نسبة لأي فرع.
الشاشة دي هي المكان اللي القرار ده بيتاخد فيه — لكل فرع لوحده، أو مرة واحدة
لكل الفروع، وباسم مين اعتمده وامتى.
<br><br>
<strong>مفيش رسم شغّال من نفسه.</strong> أي رسم لسه متوقف فضل زي ما هو —
التفعيل قرار صريح.
</p>
</div>
<?php if (!$ready): ?>
<div class="card" style="border-right:3px solid #DC2626;">
<div style="padding:16px 18px;color:#991B1B;">
جدول رسوم الفروع لسه مش منصّب. شغّل <code>php cli.php migrate</code> ثم <code>php cli.php seed</code>.
</div>
</div>
<?php else: ?>
<!-- ══ Cash disbursement ban ══ -->
<div class="card" style="margin-bottom:16px;border-right:3px solid <?= $cashBanEnabled ? '#059669' : '#DC2626' ?>;">
<div style="padding:14px 18px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;">
<div>
<h3 style="margin:0 0 4px;font-size:14px;">منع الصرف النقدي على المصروفات</h3>
<p style="margin:0;color:#6B7280;font-size:12.5px;line-height:1.8;max-width:600px;">
كمؤسسة تابعة للدولة، كل المصروفات المفروض تُصرف بشيك أو تحويل بنكي —
النظام دلوقتي <strong><?= $cashBanEnabled ? 'بيرفض' : 'بيسمح بـ' ?></strong>
تسجيل دفعة مورد نقدًا.
</p>
</div>
<form method="POST" action="/accounting/branch-fees/cash-ban"
onsubmit="return confirm('<?= $cashBanEnabled ? 'متأكد إنك عايز تسمح بالصرف النقدي على المصروفات؟ ده استثناء عن قاعدة المؤسسة الحكومية.' : 'متأكد إنك عايز تمنع الصرف النقدي؟' ?>');">
<?= csrf_field() ?>
<input type="hidden" name="expenses_cash_disbursement_allowed" value="<?= $cashBanEnabled ? '1' : '0' ?>">
<button type="submit" class="btn <?= $cashBanEnabled ? 'btn-outline' : 'btn-primary' ?>">
<?= $cashBanEnabled ? 'اسمح بالاستثناء' : 'فعّل المنع تاني' ?>
</button>
</form>
</div>
</div>
<!-- ══ Government withholding on expenses ══ -->
<?php if ($taxReady): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid <?= !empty($taxProfile['is_active']) ? '#059669' : '#D97706' ?>;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">الحسم الحكومي على المصروفات</h3>
<p style="margin:6px 0 0;color:#6B7280;font-size:12.5px;line-height:1.8;">
الدمغة العادية والإضافية وضريبة الأرباح التجارية — بتتخصم من مستحق المورد
وقت اعتماد الفاتورة، مش بتتصرف من جيب النادي. النسب دي مش نسب قانونية
بنفترضها — لازم تتحدد من هنا قبل ما أي خصم يبدأ.
</p>
</div>
<form method="POST" action="/accounting/branch-fees/expense-tax" style="padding:16px 18px;">
<?= csrf_field() ?>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-end;">
<div style="min-width:150px;">
<label class="form-label">الدمغة العادية ٪</label>
<input type="number" name="ordinary_stamp_pct" step="0.0001" min="0" max="100" class="form-input"
dir="ltr" style="text-align:right;" value="<?= e((string) ($taxProfile['ordinary_stamp_pct'] ?? '0')) ?>">
</div>
<div style="min-width:150px;">
<label class="form-label">الدمغة الإضافية ٪</label>
<input type="number" name="additional_stamp_pct" step="0.0001" min="0" max="100" class="form-input"
dir="ltr" style="text-align:right;" value="<?= e((string) ($taxProfile['additional_stamp_pct'] ?? '0')) ?>">
</div>
<div style="min-width:170px;">
<label class="form-label">ضريبة الأرباح التجارية ٪</label>
<input type="number" name="commercial_profit_pct" step="0.0001" min="0" max="100" class="form-input"
dir="ltr" style="text-align:right;" value="<?= e((string) ($taxProfile['commercial_profit_pct'] ?? '0')) ?>">
</div>
<div style="min-width:220px;flex:1;">
<label class="form-label">السبب / المرجع القانوني</label>
<input type="text" name="notes" class="form-input" value="<?= e((string) ($taxProfile['notes'] ?? '')) ?>">
</div>
<div style="min-width:110px;">
<label class="form-label">
<input type="checkbox" name="is_active" value="1" <?= !empty($taxProfile['is_active']) ? 'checked' : '' ?>>
مفعّل
</label>
</div>
<div><button type="submit" class="btn btn-primary">احفظ</button></div>
</div>
</form>
</div>
<?php endif; ?>
<!-- ══ Per-fee, per-branch ══ -->
<?php foreach ($byFee as $code => $fee): ?>
<?php $isCommission = $code === 'card_commission'; ?>
<div class="card" style="margin-bottom:16px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;"><?= e($fee['label']) ?></h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>الفرع</th><th>الحالة</th>
<?php if ($isCommission): ?>
<th>حد أدنى</th><th>النسبة٪</th><th>الأساس</th>
<?php else: ?>
<th>المبلغ</th>
<?php endif; ?>
<th>السبب</th><th>يسري من</th><th></th>
</tr>
</thead>
<tbody>
<?php
$rows = [['label' => 'كل الفروع (افتراضي)', 'branch_id' => null, 'row' => $fee['global']]];
foreach ($branches as $b) {
$rows[] = ['label' => $b['name_ar'], 'branch_id' => (int) $b['id'], 'row' => $fee['branches'][(int) $b['id']] ?? null];
}
?>
<?php foreach ($rows as $r): ?>
<?php $s = $r['row']; $formId = 'fee-' . $code . '-' . ($r['branch_id'] ?? 'g'); $isOn = !empty($s['is_active']); ?>
<tr<?= $isOn ? '' : ' style="color:#9CA3AF;"' ?>>
<td style="font-weight:<?= $r['branch_id'] === null ? '700' : '400' ?>;"><?= e($r['label']) ?></td>
<td><?= $isOn ? '<span class="badge badge-success">مفعّل</span>' : '<span class="badge badge-neutral">متوقف</span>' ?></td>
<?php if ($isCommission): ?>
<td><?= $s ? money($s['threshold_amount'] ?? '0') : '—' ?></td>
<td><?= $s ? e((string) ($s['rate_percentage'] ?? '0')) : '—' ?></td>
<td style="font-size:11px;"><?= $s && ($s['rate_basis'] ?? '') === 'whole_amount' ? 'على كل المبلغ' : 'على الزيادة بس' ?></td>
<?php else: ?>
<td><?= $s ? money($s['amount'] ?? '0') : '—' ?></td>
<?php endif; ?>
<td style="font-size:11.5px;color:#6B7280;max-width:200px;"><?= e($s['notes'] ?? '') ?></td>
<td style="font-size:11.5px;color:#6B7280;"><?= e($s['effective_from'] ?? '') ?></td>
<td>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('<?= $formId ?>').classList.toggle('hidden-form')">
تعديل
</button>
<?php if ($r['branch_id'] !== null && $isOn): ?>
<form method="POST" action="/accounting/branch-fees/generalize" style="display:inline;"
onsubmit="return confirm('هيتم تطبيق نفس إعدادات <?= e($r['label']) ?> على كل الفروع اللي مالهاش إعداد خاص. متأكد؟');">
<?= csrf_field() ?>
<input type="hidden" name="fee_code" value="<?= e($code) ?>">
<input type="hidden" name="from_branch_id" value="<?= $r['branch_id'] ?>">
<button type="submit" class="btn btn-sm btn-outline">عمّم على كل الفروع</button>
</form>
<?php endif; ?>
</td>
</tr>
<tr id="<?= $formId ?>" class="hidden-form">
<td colspan="<?= $isCommission ? 8 : 6 ?>" style="background:#F9FAFB;padding:14px;">
<form method="POST" action="/accounting/branch-fees">
<?= csrf_field() ?>
<input type="hidden" name="fee_code" value="<?= e($code) ?>">
<input type="hidden" name="branch_id" value="<?= $r['branch_id'] ?? '' ?>">
<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;">
<?php if ($isCommission): ?>
<div style="min-width:140px;">
<label class="form-label">الحد الأدنى</label>
<input type="number" name="threshold_amount" step="0.01" min="0" class="form-input commission-threshold"
dir="ltr" style="text-align:right;" value="<?= e((string) ($s['threshold_amount'] ?? '10000')) ?>">
</div>
<div style="min-width:120px;">
<label class="form-label">النسبة٪</label>
<input type="number" name="rate_percentage" step="0.0001" min="0" max="100" class="form-input commission-rate"
dir="ltr" style="text-align:right;" value="<?= e((string) ($s['rate_percentage'] ?? '2')) ?>">
</div>
<div style="min-width:170px;">
<label class="form-label">الأساس</label>
<select name="rate_basis" class="form-select commission-basis">
<option value="excess_only" <?= ($s['rate_basis'] ?? 'excess_only') === 'excess_only' ? 'selected' : '' ?>>على الزيادة عن الحد بس</option>
<option value="whole_amount" <?= ($s['rate_basis'] ?? '') === 'whole_amount' ? 'selected' : '' ?>>على كل المبلغ لو تخطى الحد</option>
</select>
</div>
<?php else: ?>
<div style="min-width:140px;">
<label class="form-label">المبلغ</label>
<input type="number" name="amount" step="0.01" min="0" class="form-input"
dir="ltr" style="text-align:right;" value="<?= e((string) ($s['amount'] ?? '')) ?>">
</div>
<?php endif; ?>
<div style="min-width:150px;">
<label class="form-label">يسري من تاريخ</label>
<input type="date" name="effective_from" class="form-input" value="<?= e((string) ($s['effective_from'] ?? date('Y-m-d'))) ?>">
</div>
<div style="flex:1;min-width:240px;">
<label class="form-label">السبب / المرجع</label>
<input type="text" name="notes" class="form-input" value="<?= e((string) ($s['notes'] ?? '')) ?>">
</div>
<div style="min-width:90px;">
<label class="form-label">
<input type="checkbox" name="is_active" value="1" <?= $isOn ? 'checked' : '' ?>>
مفعّل
</label>
</div>
<?php if ($isCommission): ?>
<div>
<button type="button" class="btn btn-outline commission-preview" data-form="<?= $formId ?>">جرّب على مبلغ</button>
</div>
<?php endif; ?>
<div><button type="submit" class="btn btn-primary">احفظ</button></div>
</div>
<?php if ($isCommission): ?>
<div style="margin-top:10px;display:flex;gap:10px;align-items:center;">
<input type="number" class="form-input commission-sample" placeholder="مبلغ تجريبي" style="max-width:160px;" dir="ltr">
<span class="commission-out" style="font-size:13px;color:#065F46;"></span>
</div>
<?php endif; ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<style>.hidden-form{display:none;}</style>
<script>
document.querySelectorAll('.commission-preview').forEach(function (btn) {
btn.addEventListener('click', function () {
var form = document.getElementById(btn.dataset.form);
var amount = form.querySelector('.commission-sample').value || '0';
var q = '/accounting/branch-fees/preview-commission'
+ '?amount=' + encodeURIComponent(amount)
+ '&threshold_amount=' + encodeURIComponent(form.querySelector('.commission-threshold').value || '0')
+ '&rate_percentage=' + encodeURIComponent(form.querySelector('.commission-rate').value || '0')
+ '&rate_basis=' + encodeURIComponent(form.querySelector('.commission-basis').value);
fetch(q).then(function (r) { return r.json(); }).then(function (d) {
form.querySelector('.commission-out').textContent =
'العمولة: ' + d.commission + ' ج.م — صافي هيوصل البنك: ' + d.net + ' ج.م';
});
});
});
</script>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إقفال أوراق الدفع الشهري<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<h2 style="margin:6px 0 4px;">إقفال أوراق الدفع الشهري</h2>
<p style="margin:0;color:#6B7280;font-size:13px;line-height:1.9;max-width:800px;">
الشيك لما بيتسلّم للمورد بيبقى «ورقة دفع» — التزام على النادي، مش فلوس خرجت من
البنك فعلًا. البنك ما بيدفعش غير لما الشيك يتحصّل. الشاشة دي هي المطابقة الشهرية:
بتشوف كشف حساب البنك، وتحدد أنهي شيكات اتصرفت فعلًا، والباقي بيفضل «ورقة دفع»
لحد الشهر اللي بعده.
</p>
</div>
<?php if (empty($open)): ?>
<div class="card"><div style="padding:34px;text-align:center;color:#059669;font-size:14px;">
مفيش شيكات واقفة — كل الشيكات الصادرة اتقفلت.
</div></div>
<?php else: ?>
<form method="POST" action="/accounting/notes-payable/close"
onsubmit="return confirm('هيتم ترحيل قيد إقفال للشيكات المختارة. متأكد؟');">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:16px;">
<div style="padding:14px 18px;display:flex;gap:16px;flex-wrap:wrap;align-items:flex-end;">
<div style="min-width:220px;">
<label class="form-label">الحساب البنكي</label>
<select name="bank_account_id" class="form-select" required>
<option value="">اختار الحساب</option>
<?php foreach ($bankAccounts as $b): ?>
<option value="<?= (int) $b['id'] ?>"><?= e($b['account_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:170px;">
<label class="form-label">تاريخ القيد</label>
<input type="date" name="entry_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div><button type="submit" class="btn btn-primary">رحّل قيد الإقفال للمختار</button></div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:36px;"><input type="checkbox" id="np-check-all"></th>
<th>رقم الدفعة</th><th>المورد</th><th>رقم الشيك</th><th>التاريخ</th><th>المبلغ</th>
</tr>
</thead>
<tbody>
<?php $total = '0.00'; ?>
<?php foreach ($open as $o): $total = bcadd($total, (string) $o['amount'], 2); ?>
<tr>
<td><input type="checkbox" name="payment_ids[]" value="<?= (int) $o['id'] ?>" class="np-row" checked></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($o['payment_number']) ?></td>
<td><?= e($o['supplier_name'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($o['check_number'] ?: '—') ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($o['payment_date']) ?></td>
<td style="font-weight:600;"><?= money($o['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="background:#F9FAFB;">
<td colspan="5" style="text-align:left;font-weight:600;">الإجمالي الواقف</td>
<td style="font-weight:700;font-size:15px;"><?= money($total) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
</form>
<script>
document.getElementById('np-check-all').addEventListener('change', function () {
document.querySelectorAll('.np-row').forEach(function (c) { c.checked = this.checked; }.bind(this));
});
</script>
<?php endif; ?>
<?php $__template->endSection(); ?>
......@@ -119,6 +119,14 @@ PermissionRegistry::register('accounting', [
'accounting.gaps.view' => ['ar' => 'عرض الفجوات المحاسبية', 'en' => 'View Accounting Gaps'],
'accounting.gaps.manage' => ['ar' => 'اعتماد تسعيرات سد الفجوات', 'en' => 'Approve Gap Valuations'],
// Branch fee settings (per-branch policy, government expense withholding)
'accounting.branch_fees.view' => ['ar' => 'عرض رسوم الفروع', 'en' => 'View Branch Fees'],
'accounting.branch_fees.manage' => ['ar' => 'إدارة رسوم الفروع والحسم الحكومي', 'en' => 'Manage Branch Fees'],
// Notes payable monthly closing
'accounting.notes_payable.view' => ['ar' => 'عرض أوراق الدفع', 'en' => 'View Notes Payable'],
'accounting.notes_payable.manage' => ['ar' => 'إقفال أوراق الدفع الشهري', 'en' => 'Close Notes Payable'],
// Vouchers
'accounting.voucher.view' => ['ar' => 'عرض السندات', 'en' => 'View Vouchers'],
'accounting.voucher.create' => ['ar' => 'إنشاء سند', 'en' => 'Create Voucher'],
......@@ -153,6 +161,8 @@ MenuRegistry::register('accounting', [
['label_ar' => 'فين الفلوس دلوقتي', 'label_en' => 'Money in Transit', 'route' => '/accounting/posting-chains/parked', 'permission' => 'accounting.chains.view', 'order' => 2],
['label_ar' => 'الاستحقاقات', 'label_en' => 'Accruals', 'route' => '/accounting/accruals', 'permission' => 'accounting.accruals.view', 'order' => 2],
['label_ar' => 'سد الفجوات', 'label_en' => 'Accounting Gaps', 'route' => '/accounting/gaps', 'permission' => 'accounting.gaps.view', 'order' => 2],
['label_ar' => 'رسوم الفروع', 'label_en' => 'Branch Fees', 'route' => '/accounting/branch-fees', 'permission' => 'accounting.branch_fees.view', 'order' => 2],
['label_ar' => 'إقفال أوراق الدفع', 'label_en' => 'Notes Payable Closing', 'route' => '/accounting/notes-payable', 'permission' => 'accounting.notes_payable.view', 'order' => 23],
['label_ar' => 'المطالبات والتحصيل', 'label_en' => 'Billing & Collection', 'route' => '/accounting/billing', 'permission' => 'accounting.billing.view', 'order' => 2],
['label_ar' => 'سندات الصرف والقبض', 'label_en' => 'Vouchers', 'route' => '/accounting/vouchers', 'permission' => 'accounting.voucher.view', 'order' => 3],
['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
......@@ -235,6 +245,13 @@ EventBus::listen('payment.completed', function (array $data): void {
}
}, 50); // Priority 50 — runs after Payments module's own listener (100)
// Card-processor commission, recognised as an expense — see
// CardCommissionPostingService for why this is a separate adjusting entry
// rather than woven into every revenue stream's own posting.
EventBus::listen('payment.completed', function (array $data): void {
\App\Modules\Accounting\Services\CardCommissionPostingService::onPaymentCompleted($data);
}, 60);
// When a payment is voided, reverse the journal entry
EventBus::listen('payment.voided', function (array $data): void {
try {
......
......@@ -191,6 +191,13 @@ class ChildController extends Controller
$breakdownJson = !empty($feeCalc['breakdown']) ? json_encode($feeCalc['breakdown'], JSON_UNESCAPED_UNICODE) : null;
// The exact split accounting needs at payment time — see Phase_111_001.
// `fee_breakdown_json` above only stores the display bullet points, not
// numbers a posting can read back.
$feeComponentMembership = $feeCalc['fee'] ?? '0.00';
$feeComponentForm = $feeCalc['form_fee'] ?? '0.00';
$feeComponentAnnual = $feeCalc['annual_subscription'] ?? '0.00';
$child = Child::create([
'member_id' => (int) $memberId,
'child_order' => $childOrder,
......@@ -199,6 +206,9 @@ class ChildController extends Controller
'national_id' => $nid ?: null,
'passport_number' => $isNonEgyptian ? $passportNumber : null,
'birth_certificate_number' => $data['birth_certificate_number'] ?? null,
'fee_component_membership' => $feeComponentMembership,
'fee_component_form' => $feeComponentForm,
'fee_component_annual' => $feeComponentAnnual,
'date_of_birth' => $data['date_of_birth'],
'age_years' => (int) ($data['age_years'] ?? 0),
'age_months' => (int) ($data['age_months'] ?? 0),
......
......@@ -20,7 +20,8 @@ class Child extends Model
'national_id', 'passport_number', 'birth_certificate_number', 'date_of_birth',
'age_years', 'age_months', 'gender', 'relationship',
'school_faculty', 'nationality', 'classification',
'addition_fee', 'fee_breakdown_json', 'fee_receipt_number', 'status',
'addition_fee', 'fee_component_membership', 'fee_component_form', 'fee_component_annual',
'fee_breakdown_json', 'fee_receipt_number', 'status',
'join_date', 'is_frozen', 'frozen_at', 'frozen_reason', 'photo_path', 'remarks',
];
......
......@@ -24,6 +24,20 @@ final class VendorPaymentService
throw new \RuntimeException('يجب تحديد مبلغ الدفعة');
}
// As a government-affiliated entity the club settles every expense by
// cheque or bank transfer — never cash. Enforced here, at the one place
// every vendor payment is created, rather than left as a UI convention
// someone eventually forgets. A kill switch exists in system_config for
// the day this genuinely needs to change, so a future exception is a
// deliberate policy decision, not a bypassed validation.
$method = (string) ($data['payment_method'] ?? 'bank_transfer');
if ($method === 'cash' && self::cashDisbursementBlocked()) {
throw new \RuntimeException(
'الصرف النقدي على المصروفات ممنوع — النادي مؤسسة تابعة للدولة وكل المصروفات '
. 'تُصرف بشيك أو تحويل بنكي. اختر طريقة دفع تانية.'
);
}
$paymentNumber = ProcurementNumberGenerator::nextPaymentNumber();
$paymentId = $db->insert('vendor_payments', [
......@@ -182,4 +196,21 @@ final class VendorPaymentService
Logger::info("Vendor payment #{$paymentId} voided: {$reason}");
}
/**
* Defaults enforced (system_config missing or unset = the government-entity
* rule stands). Only an explicit '0' relaxes it, so the ban cannot be
* silently defeated by the config table not existing yet on a fresh install.
*/
private static function cashDisbursementBlocked(): bool
{
try {
$row = App::getInstance()->db()->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'expenses_cash_disbursement_allowed'"
);
return ($row['config_value'] ?? '0') !== '1';
} catch (\Throwable) {
return true;
}
}
}
......@@ -206,6 +206,13 @@ class SpouseController extends Controller
$breakdownJson = !empty($feeCalc['breakdown']) ? json_encode($feeCalc['breakdown'], JSON_UNESCAPED_UNICODE) : null;
// The exact split accounting needs at payment time — see Phase_111_001.
// SpouseFeeCalculator's own 'addition_fee' key is its percentage-of-
// membership-value component, not the grand total (that is 'total_fee').
$feeComponentMembership = $feeCalc['addition_fee'] ?? '0.00';
$feeComponentForm = $feeCalc['form_fee'] ?? '0.00';
$feeComponentAnnual = $feeCalc['annual_subscription'] ?? '0.00';
$spouse = Spouse::create([
'member_id' => (int) $memberId,
'spouse_order' => $spouseOrder,
......@@ -228,6 +235,9 @@ class SpouseController extends Controller
'join_date' => date('Y-m-d'),
'classification' => ((int) ($data['age_years'] ?? 0) >= 21) ? 'working' : 'dependent',
'addition_fee' => $totalFee,
'fee_component_membership' => $feeComponentMembership,
'fee_component_form' => $feeComponentForm,
'fee_component_annual' => $feeComponentAnnual,
'fee_breakdown_json' => $breakdownJson,
'status' => ($hasFee || $pendingCalc) ? 'pending_payment' : 'active',
]);
......
......@@ -21,6 +21,7 @@ class Spouse extends Model
'age_years', 'age_months', 'gender', 'nationality', 'religion',
'qualification_id', 'occupation', 'work_address', 'work_phone', 'mobile',
'marriage_date', 'join_date', 'classification', 'addition_fee',
'fee_component_membership', 'fee_component_form', 'fee_component_annual',
'fee_breakdown_json', 'fee_receipt_number', 'status', 'photo_path',
];
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The numeric split the accountant's spec calls for but never defines a ratio
* for ("إضافة زوج أو زوجة... تقسم بنفس الطريقة السابقة حسب النسبة من الايراد").
*
* That ratio already exists — it is computed by ChildFeeCalculator and
* SpouseFeeCalculator every time a dependant is added: a percentage-of-
* membership-value component, a form-fee component, and an annual-subscription
* component. It was just never PERSISTED — `fee_breakdown_json` only stores the
* human-readable bullet points shown on screen, not the numbers — so by the time
* the fee is paid, accounting has nothing to split by and posts the whole amount
* to one account.
*
* These three columns are that missing memory. They are filled once, at
* creation time, by the same calculator that already decided the numbers; nothing
* about the pricing rules changes.
*/
return [
'up' => static function (Database $db): void {
foreach (['children', 'spouses'] as $table) {
$exists = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = 'fee_component_membership'",
[$table]
);
if ((int) ($exists['n'] ?? 0) > 0) {
continue;
}
$db->raw("
ALTER TABLE `{$table}`
ADD COLUMN fee_component_membership DECIMAL(15,2) NULL
COMMENT 'percentage-of-membership-value component, from the fee calculator'
AFTER addition_fee,
ADD COLUMN fee_component_form DECIMAL(15,2) NULL
COMMENT 'the 570 EGP addition-form-fee component'
AFTER fee_component_membership,
ADD COLUMN fee_component_annual DECIMAL(15,2) NULL
COMMENT 'the annual-subscription component, when the addition is post-activation'
AFTER fee_component_form
");
}
},
'down' => static function (Database $db): void {
foreach (['children', 'spouses'] as $table) {
$exists = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = 'fee_component_membership'",
[$table]
);
if ((int) ($exists['n'] ?? 0) > 0) {
$db->raw("ALTER TABLE `{$table}`
DROP COLUMN fee_component_membership,
DROP COLUMN fee_component_form,
DROP COLUMN fee_component_annual");
}
}
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Per-branch fees the accountant's spec describes as unsettled policy, not
* missing code: a stamp fee added at Sheraton but not elsewhere, a card
* commission that "each branch has its own rate" for, a cheque clearing fee
* and a bounced-cheque fee whose notes read "generalize this" against "cancel
* this" in the same breath.
*
* None of that is a number a migration should decide. This table is where the
* accountant decides it, per branch or once for all branches (branch_id NULL =
* global default, overridden by a branch-specific row when one exists) — see
* BranchFeeSettingsController for the screen and BranchFeeService for how a
* caller resolves one.
*
* Ships with no rows active. A fee that has no row, or whose row is inactive,
* simply does not apply — exactly like the accrual gap tools, configuring
* nothing changes nothing.
*/
return [
'up' => static function (Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS branch_fee_settings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
branch_id BIGINT UNSIGNED NULL COMMENT 'NULL = applies to every branch with no fee row of its own',
fee_code ENUM(
'martyr_stamp', -- طابع الشهداء، يُضاف على بيع استمارة العضوية
'cheque_clearing_fee', -- مصاريف مقاصة، على كل شيك يُستلم في بيع بالتقسيط
'bounced_cheque_fee', -- غرامة تحصيل شيك مرتد
'card_commission' -- عمولة تحصيل الفيزا/البطاقات، بحد أدنى ونسبة
) NOT NULL,
amount DECIMAL(15,2) NULL COMMENT 'flat amount — stamp, clearing fee, bounced fee',
threshold_amount DECIMAL(15,2) NULL COMMENT 'card_commission only: the amount the rate kicks in above',
rate_percentage DECIMAL(7,4) NULL COMMENT 'card_commission only: the % charged once past the threshold',
rate_basis ENUM('whole_amount', 'excess_only') NULL
COMMENT 'card_commission only: rate on the WHOLE transaction once it crosses the threshold, or only on the amount above it',
is_active TINYINT(1) NOT NULL DEFAULT 0,
notes VARCHAR(500) NULL COMMENT 'why this figure — what a reviewer will ask for',
effective_from DATE NULL,
approved_by BIGINT UNSIGNED NULL,
approved_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_branch_fee (branch_id, fee_code),
KEY idx_branch_fee_code (fee_code, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// One account the sheet asks for that the chart genuinely lacked: a
// dedicated place for the cheque-clearing fee, so the day it is wired to
// a live charge point — or posted by hand in the meantime — it lands
// somewhere named, not in «إيرادات متنوعه» with everything else.
//
// The stamp fee needed no new account: «٢٣٠٨٢١٠٣ طابع الشهداء — محصّل
// لحساب الغير» already exists in this chart, unused. The withholding-tax
// legs below reuse ٢٣٠٨١٢٠١–٢٣٠٨١٢٠٤ for the same reason.
$exists = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = '410540'");
if (!$exists) {
$parent = $db->selectOne("SELECT id, account_type, account_nature FROM chart_of_accounts WHERE account_code = '4105'");
if ($parent) {
$now = date('Y-m-d H:i:s');
$db->insert('chart_of_accounts', [
'account_code' => '410540',
'name_ar' => 'مصاريف مقاصة شيكات',
'name_en' => 'Cheque Clearing Fee Revenue',
'account_type' => $parent['account_type'] ?: 'revenue',
'account_nature' => $parent['account_nature'] ?: 'credit',
'parent_id' => (int) $parent['id'],
'level' => 5,
'level_name' => 'جزئي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 1,
'description_ar' => 'الرسم اللي بيتحصّل على كل شيك بيستلمه النادي في بيع بالتقسيط — '
. 'المبلغ بيتحدد من شاشة رسوم الفروع.',
'opening_balance' => '0.00',
'current_balance' => '0.00',
'currency' => 'EGP',
'is_archived' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
},
'down' => static function (Database $db): void {
$db->raw("DROP TABLE IF EXISTS branch_fee_settings");
$acc = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = '410540'");
if ($acc) {
$used = $db->selectOne("SELECT 1 AS n FROM journal_entry_lines WHERE account_id = ? LIMIT 1", [(int) $acc['id']]);
if (!$used) {
$db->query("DELETE FROM chart_of_accounts WHERE id = ?", [(int) $acc['id']]);
}
}
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Government withholding on expenses — ordinary stamp duty, additional stamp
* duty, commercial-profits withholding tax — deducted at source when the club
* pays a supplier, exactly as the accountant's spec applies it to every
* expense category (operating companies, operating expenses, academies,
* sundry).
*
* The rates are NOT in the spec. Egyptian stamp duty and withholding-tax rates
* are progressive schedules set by law and by activity type, not a single
* percentage this migration could safely guess — a wrong rate here is a
* mis-filed government return, not a rounding error. So this ships as ONE
* profile with every rate at zero and inactive; nothing is withheld until
* finance sets real rates on the settings screen.
*
* One profile, not one per invoice or per supplier: the sheet applies the same
* three withholding legs uniformly across every expense category, so a single
* active profile is what every vendor invoice reads.
*/
return [
'up' => static function (Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS expense_tax_profiles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(150) NOT NULL DEFAULT 'الحسم الحكومي الافتراضي على المصروفات',
ordinary_stamp_pct DECIMAL(7,4) NOT NULL DEFAULT 0.0000 COMMENT 'الدمغة العادية %',
additional_stamp_pct DECIMAL(7,4) NOT NULL DEFAULT 0.0000 COMMENT 'الدمغة الإضافية %',
commercial_profit_pct DECIMAL(7,4) NOT NULL DEFAULT 0.0000 COMMENT 'ضريبة الأرباح التجارية والصناعية %',
is_active TINYINT(1) NOT NULL DEFAULT 0,
notes VARCHAR(500) NULL,
approved_by BIGINT UNSIGNED NULL,
approved_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$exists = $db->selectOne("SELECT id FROM expense_tax_profiles LIMIT 1");
if (!$exists) {
$db->insert('expense_tax_profiles', [
'name_ar' => 'الحسم الحكومي الافتراضي على المصروفات',
'is_active' => 0,
'notes' => 'مطفأة — النسب لازم تتحدد من شاشة الإعدادات قبل التفعيل.',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
},
'down' => static function (Database $db): void {
$db->raw("DROP TABLE IF EXISTS expense_tax_profiles");
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* A cheque handed to a supplier is not bank cash the moment it is signed — the
* bank has not paid it yet. `onVendorPaymentCompleted` was crediting Bank
* directly for a cheque payment, which means the account was already short by
* the cheque's value days or weeks before the bank actually cleared it.
*
* The correct two-step, and the one the accountant's spec asks for at rows
* 209/267–269 ("أوراق الدفع" after every expense, closed monthly "من كشف حساب
* البنك"):
*
* cheque issued Dr Accounts Payable Cr Notes Payable
* bank statement Dr Notes Payable Cr Bank
*
* These three columns are what let the second step find the first: which
* vendor payments are still open cheques, and which bank statement closed
* them. `notes_payable_closed_at IS NULL` on a completed cheque payment is
* "the bank has not shown this yet" — exactly the number the monthly closing
* screen chases.
*/
return [
'up' => static function (Database $db): void {
$exists = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'vendor_payments'
AND column_name = 'notes_payable_closed_at'"
);
if ((int) ($exists['n'] ?? 0) > 0) {
return;
}
$db->raw("
ALTER TABLE vendor_payments
ADD COLUMN notes_payable_closed_at DATETIME NULL
COMMENT 'when the bank statement confirmed this cheque cleared'
AFTER journal_entry_id,
ADD COLUMN notes_payable_closing_entry_id BIGINT UNSIGNED NULL
COMMENT 'the monthly closing journal entry that cleared it'
AFTER notes_payable_closed_at
");
},
'down' => static function (Database $db): void {
$exists = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'vendor_payments'
AND column_name = 'notes_payable_closed_at'"
);
if ((int) ($exists['n'] ?? 0) > 0) {
$db->raw("ALTER TABLE vendor_payments
DROP COLUMN notes_payable_closed_at,
DROP COLUMN notes_payable_closing_entry_id");
}
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The numbers the accountant's spec actually gives — all of them for Sheraton,
* this deployment's branch — loaded as active rows so the demo shows exactly
* what the sheet describes: 5 EGP stamp fee, 50 EGP bounced-cheque fee, and a
* 2% card commission above 10,000.
*
* Every other branch is left with no row, which means none of these apply
* there — matching the sheet's own uncertainty about whether to "generalize"
* or "cancel" each one. Generalizing from here is one click on the branch-fees
* screen, not a redeploy.
*
* The cheque-clearing fee (25 EGP, row 28) is seeded active too — finance HAS
* given a number for it — but nothing in this deployment automatically charges
* it yet: it belongs on the installment/cheque-intake screen, a separate
* legacy subsystem (App\Modules\Installments) that does not yet talk to the
* accounting cheque lifecycle at all (zero rows in negotiable_instruments).
* Wiring a live charge into that flow blind, hours before a meeting, was a
* worse risk than shipping the number configured-and-visible with an honest
* note. It shows on the branch-fees screen so it is not silently missing.
*
* Idempotent.
*/
return static function (Database $db): void {
$sheraton = $db->selectOne("SELECT id FROM branches WHERE branch_code = 'sheraton'");
if (!$sheraton) {
return; // this deployment has no Sheraton branch — nothing to seed
}
$branchId = (int) $sheraton['id'];
$now = date('Y-m-d H:i:s');
$upsert = static function (array $row) use ($db, $now): void {
$existing = $db->selectOne(
$row['branch_id'] === null
? "SELECT id FROM branch_fee_settings WHERE fee_code = ? AND branch_id IS NULL"
: "SELECT id FROM branch_fee_settings WHERE fee_code = ? AND branch_id = ?",
$row['branch_id'] === null ? [$row['fee_code']] : [$row['fee_code'], $row['branch_id']]
);
if ($existing) {
return; // finance may have already adjusted it — never overwrite
}
$db->insert('branch_fee_settings', $row + [
'is_active' => 1,
'approved_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
};
$upsert([
'branch_id' => $branchId,
'fee_code' => 'martyr_stamp',
'amount' => '5.00',
'notes' => 'من ملف تعليمات المحاسب — طابع الشهداء المضاف على استمارة العضوية في فرع شيراتون.',
'effective_from' => date('Y-m-d'),
]);
$upsert([
'branch_id' => $branchId,
'fee_code' => 'cheque_clearing_fee',
'amount' => '25.00',
'notes' => 'من ملف تعليمات المحاسب — مصاريف مقاصة على كل شيك في البيع بالتقسيط. '
. 'المبلغ محدد، بس التطبيق التلقائي لسه محتاج ربط شاشة الأقساط — راجع الملاحظة في التسوية.',
'effective_from' => date('Y-m-d'),
]);
$upsert([
'branch_id' => $branchId,
'fee_code' => 'bounced_cheque_fee',
'amount' => '50.00',
'notes' => 'من ملف تعليمات المحاسب — غرامة شيك مرتد في فرع شيراتون.',
'effective_from' => date('Y-m-d'),
]);
$upsert([
'branch_id' => $branchId,
'fee_code' => 'card_commission',
'threshold_amount' => '10000.00',
'rate_percentage' => '2.0000',
'rate_basis' => 'excess_only',
'notes' => 'من ملف تعليمات المحاسب — صفر حتى ١٠٬٠٠٠، ٢٪ فوق كده. '
. 'النسبة مطبّقة على الزيادة عن الحد بس — لو المقصود على كل المبلغ، غيّرها من هنا.',
'effective_from' => date('Y-m-d'),
]);
// Cash disbursement stays banned by default even without this row — the
// service treats a missing row as enforced — but an explicit row makes the
// decision auditable rather than implicit.
$cfgExists = $db->selectOne("SELECT id FROM system_config WHERE config_key = 'expenses_cash_disbursement_allowed'");
if (!$cfgExists) {
$db->insert('system_config', [
'config_key' => 'expenses_cash_disbursement_allowed',
'config_value' => '0',
'config_type' => 'boolean',
'group_name' => 'accounting',
'description_ar' => 'السماح بصرف المصروفات نقدًا — النادي مؤسسة حكومية والقاعدة إنه ممنوع.',
]);
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
/** Grants for the branch-fees, notes-payable and expense-tax-profile screens. */
return static function (Database $db): void {
$grants = [
'accountant' => [
'accounting.branch_fees.view', 'accounting.branch_fees.manage',
'accounting.notes_payable.view', 'accounting.notes_payable.manage',
],
'auditor' => [
'accounting.branch_fees.view', 'accounting.notes_payable.view',
],
];
$now = date('Y-m-d H:i:s');
foreach ($grants as $roleCode => $permissions) {
$role = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", [$roleCode]);
if (!$role) {
continue;
}
foreach ($permissions as $key) {
$exists = $db->selectOne(
"SELECT id FROM role_permissions WHERE role_id = ? AND permission_key = ?",
[(int) $role['id'], $key]
);
if (!$exists) {
$db->insert('role_permissions', [
'role_id' => (int) $role['id'],
'permission_key' => $key,
'granted_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