Commit b43c5268 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): payment and receipt vouchers, and fix the instrument seed

VOUCHERS (سندات الصرف والقبض)
Pay an expense without thinking in debits and credits. The clerk says "صرف ٥٬٠٠٠
دعاية نقدي" — picks a type, the cash account, and what it was for — and the double
entry is derived and previewed live before saving.

  outflow (صرف)   Dr each expense line   Cr cash / bank
  inflow  (قبض)   Dr cash / bank         Cr each revenue line

Voucher TYPES are rows, not code: a club adds "سند صرف كهرباء" with its account
pre-selected from the screen. Seeded with general, advertising, maintenance,
utilities, bank, and two receipt types.

Handled deliberately:
- Input VAT splits out per line, so a supplier invoice with 14% recoverable tax
  records the expense net and the tax in its own asset account without the clerk
  doing the arithmetic. Inclusive and exclusive are both correct.
- Every account is checked postable before saving; a header or inactive account is
  named in the error rather than failing at post time.
- A line pointing at the cash account itself is refused — the entry would cancel
  to nothing.
- Posting is idempotent: a voucher that already carries a journal entry is refused.
- Cancelling a POSTED voucher reverses its entry rather than deleting it; a posted
  entry is answered with an opposite entry, never erased.
- Voucher numbers retry on collision so two clerks saving at once cannot take the
  same number.
- A type in use deactivates instead of deleting, so its vouchers keep their type.
- Approval is optional per type and blocks posting until granted.

INSTRUMENT SEED FIX
Phase_104_003 died on a duplicate key and never recorded: its existence check
filtered on is_header = 0, so it missed 230602 أوراق الدفع قصيرة الأجل — which
exists as a header — and tried to insert it. Existence is now checked by code
alone, and notes payable hangs at 23060201 underneath it.

Account codes for the seeded voucher types were read off the live chart rather
than guessed: 3303/3304/3305 are all headers, and 3305 is stationery, not
advertising. They now point at 330702 دعاية و إعلان, 33061 صيانة مباني, and
330401 كهرباء.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 2780509a
<?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\VoucherService;
/**
* سندات الصرف والقبض — pay an expense or record an inflow without thinking in
* debits and credits.
*/
class VoucherController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.voucher.view');
$db = App::getInstance()->db();
$status = (string) $request->get('status', '');
$typeId = (int) $request->get('type', 0);
$page = max(1, (int) $request->get('page', 1));
$perPage = 40;
$where = ['1 = 1'];
$params = [];
if ($status !== '' && \in_array($status, ['draft', 'pending_approval', 'posted', 'cancelled'], true)) {
$where[] = 'v.status = ?';
$params[] = $status;
}
if ($typeId > 0) {
$where[] = 'v.voucher_type_id = ?';
$params[] = $typeId;
}
$whereSql = implode(' AND ', $where);
$totalRow = $db->selectOne("SELECT COUNT(*) AS n FROM vouchers v WHERE {$whereSql}", $params);
$total = (int) ($totalRow['n'] ?? 0);
$vouchers = $db->select(
"SELECT v.*, t.name_ar AS type_name, coa.account_code AS counter_code, coa.name_ar AS counter_name
FROM vouchers v
JOIN voucher_types t ON t.id = v.voucher_type_id
LEFT JOIN chart_of_accounts coa ON coa.id = v.counter_account_id
WHERE {$whereSql}
ORDER BY v.voucher_date DESC, v.id DESC
LIMIT " . $perPage . " OFFSET " . (($page - 1) * $perPage),
$params
);
return $this->view('Accounting.Views.vouchers.index', [
'vouchers' => $vouchers,
'types' => $db->select("SELECT * FROM voucher_types WHERE is_active = 1 ORDER BY sort_order, name_ar"),
'status' => $status,
'typeId' => $typeId,
'page' => $page,
'perPage' => $perPage,
'total' => $total,
'summary' => $db->select(
"SELECT status, COUNT(*) AS n, COALESCE(SUM(total_amount), 0) AS total
FROM vouchers GROUP BY status"
),
]);
}
public function create(Request $request): Response
{
$this->authorize('accounting.voucher.create');
$db = App::getInstance()->db();
return $this->view('Accounting.Views.vouchers.form', [
'types' => $db->select("SELECT * FROM voucher_types WHERE is_active = 1 ORDER BY sort_order, name_ar"),
'taxProfiles' => $db->select("SELECT * FROM revenue_tax_profiles WHERE is_active = 1 ORDER BY tax_code"),
'costCenters' => $db->select("SELECT id, code, name_ar FROM cost_centers WHERE is_active = 1 ORDER BY code"),
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
'preselect' => (int) $request->get('type', 0),
]);
}
public function store(Request $request): Response
{
$this->authorize('accounting.voucher.create');
$lines = [];
$accounts = (array) $request->post('line_account', []);
$amounts = (array) $request->post('line_amount', []);
$descs = (array) $request->post('line_desc', []);
$centers = (array) $request->post('line_cost_center', []);
$taxes = (array) $request->post('line_tax', []);
foreach ($accounts as $i => $accountId) {
$lines[] = [
'account_id' => $accountId,
'amount' => $amounts[$i] ?? 0,
'description_ar' => $descs[$i] ?? null,
'cost_center_id' => $centers[$i] ?? null,
'tax_profile_id' => $taxes[$i] ?? null,
];
}
$result = VoucherService::create([
'voucher_type_id' => $request->post('voucher_type_id'),
'counter_account_id' => $request->post('counter_account_id'),
'voucher_date' => $request->post('voucher_date', date('Y-m-d')),
'payment_method' => $request->post('payment_method', 'cash'),
'beneficiary_name' => $request->post('beneficiary_name'),
'supplier_id' => $request->post('supplier_id'),
'member_id' => $request->post('member_id'),
'check_number' => $request->post('check_number'),
'check_bank' => $request->post('check_bank'),
'check_date' => $request->post('check_date'),
'reference' => $request->post('reference'),
'description_ar' => $request->post('description_ar'),
'notes' => $request->post('notes'),
], $lines);
if (!$result['success']) {
return $this->redirect('/accounting/vouchers/create')->withError($result['error'] ?? 'فشل الحفظ');
}
// Save-and-post in one click when asked, so the common case is one action.
if ($request->post('post_now') === '1') {
$posted = VoucherService::post((int) $result['voucher_id']);
if (!$posted['success']) {
return $this->redirect('/accounting/vouchers/' . $result['voucher_id'])
->withWarning('تم حفظ السند ' . $result['voucher_number'] . ' لكن تعذر ترحيله: ' . ($posted['error'] ?? ''));
}
return $this->redirect('/accounting/vouchers/' . $result['voucher_id'])
->withSuccess('تم حفظ وترحيل السند ' . $result['voucher_number']);
}
return $this->redirect('/accounting/vouchers/' . $result['voucher_id'])
->withSuccess('تم حفظ السند ' . $result['voucher_number']);
}
public function show(Request $request, string $id): Response
{
$this->authorize('accounting.voucher.view');
$db = App::getInstance()->db();
$voucher = $db->selectOne(
"SELECT v.*, t.name_ar AS type_name, t.requires_approval,
coa.account_code AS counter_code, coa.name_ar AS counter_name,
e.full_name_ar AS created_by_name
FROM vouchers v
JOIN voucher_types t ON t.id = v.voucher_type_id
LEFT JOIN chart_of_accounts coa ON coa.id = v.counter_account_id
LEFT JOIN employees e ON e.id = v.created_by
WHERE v.id = ?",
[(int) $id]
);
if (!$voucher) {
return $this->redirect('/accounting/vouchers')->withError('السند غير موجود');
}
return $this->view('Accounting.Views.vouchers.show', [
'voucher' => $voucher,
'lines' => $db->select(
"SELECT l.*, coa.account_code, coa.name_ar AS account_name,
cc.name_ar AS cost_center_name, tp.name_ar AS tax_name
FROM voucher_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id
LEFT JOIN cost_centers cc ON cc.id = l.cost_center_id
LEFT JOIN revenue_tax_profiles tp ON tp.id = l.tax_profile_id
WHERE l.voucher_id = ? ORDER BY l.line_number",
[(int) $id]
),
'entryLines' => !empty($voucher['journal_entry_id']) ? $db->select(
"SELECT jel.*, coa.account_code, coa.name_ar AS account_name
FROM journal_entry_lines jel
JOIN chart_of_accounts coa ON coa.id = jel.account_id
WHERE jel.journal_entry_id = ? ORDER BY jel.line_number",
[(int) $voucher['journal_entry_id']]
) : [],
]);
}
public function post(Request $request, string $id): Response
{
$this->authorize('accounting.voucher.post');
$result = VoucherService::post((int) $id);
return $result['success']
? $this->redirect('/accounting/vouchers/' . $id)->withSuccess('تم ترحيل السند إلى الدفاتر')
: $this->redirect('/accounting/vouchers/' . $id)->withError($result['error'] ?? 'فشل الترحيل');
}
public function approve(Request $request, string $id): Response
{
$this->authorize('accounting.voucher.approve');
$result = VoucherService::approve((int) $id);
return $result['success']
? $this->redirect('/accounting/vouchers/' . $id)->withSuccess('تم اعتماد السند — يمكن ترحيله الآن')
: $this->redirect('/accounting/vouchers/' . $id)->withError($result['error'] ?? 'فشل الاعتماد');
}
public function cancel(Request $request, string $id): Response
{
$this->authorize('accounting.voucher.post');
$reason = trim((string) $request->post('reason', ''));
if ($reason === '') {
return $this->redirect('/accounting/vouchers/' . $id)->withError('اكتب سبب الإلغاء');
}
$result = VoucherService::cancel((int) $id, $reason);
return $result['success']
? $this->redirect('/accounting/vouchers/' . $id)->withSuccess('تم إلغاء السند وعكس قيده')
: $this->redirect('/accounting/vouchers/' . $id)->withError($result['error'] ?? 'فشل الإلغاء');
}
// ────────────────────────────────────────────────────────────
// Voucher types — configurable from the screen
// ────────────────────────────────────────────────────────────
public function types(Request $request): Response
{
$this->authorize('accounting.voucher.manage');
$db = App::getInstance()->db();
return $this->view('Accounting.Views.vouchers.types', [
'types' => $db->select(
"SELECT t.*,
c.account_code AS counter_code, c.name_ar AS counter_name,
l.account_code AS line_code, l.name_ar AS line_name,
(SELECT COUNT(*) FROM vouchers v WHERE v.voucher_type_id = t.id) AS usage_count
FROM voucher_types t
LEFT JOIN chart_of_accounts c ON c.id = t.default_counter_account_id
LEFT JOIN chart_of_accounts l ON l.id = t.default_line_account_id
ORDER BY t.sort_order, t.name_ar"
),
]);
}
public function saveType(Request $request, string $id): Response
{
$this->authorize('accounting.voucher.manage');
$db = App::getInstance()->db();
$typeId = (int) $id;
$code = strtoupper(preg_replace('/[^A-Za-z0-9_]/', '', (string) $request->post('code', '')) ?? '');
$name = trim((string) $request->post('name_ar', ''));
if ($code === '' || $name === '') {
return $this->redirect('/accounting/vouchers/types')->withError('الكود والاسم مطلوبان');
}
$dup = $db->selectOne("SELECT id FROM voucher_types WHERE code = ? AND id <> ?", [$code, $typeId]);
if ($dup) {
return $this->redirect('/accounting/vouchers/types')->withError('الكود مستخدم بالفعل');
}
$nullableAccount = static function ($v) use ($db) {
$v = (int) $v;
if ($v <= 0) {
return null;
}
// Never let a type default to an account that cannot be posted to.
$acc = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE id = ? AND is_header = 0 AND is_active = 1 AND is_archived = 0",
[$v]
);
return $acc ? $v : null;
};
$data = [
'code' => $code,
'name_ar' => $name,
'name_en' => $request->post('name_en') ?: null,
'direction' => $request->post('direction') === 'inflow' ? 'inflow' : 'outflow',
'number_prefix' => strtoupper(substr((string) $request->post('number_prefix', 'PV'), 0, 10)) ?: 'PV',
'default_counter_account_id' => $nullableAccount($request->post('default_counter_account_id')),
'default_line_account_id' => $nullableAccount($request->post('default_line_account_id')),
'requires_approval' => (int) $request->post('requires_approval', 0),
'requires_supplier' => (int) $request->post('requires_supplier', 0),
'is_active' => (int) $request->post('is_active', 1),
'sort_order' => (int) $request->post('sort_order', 100),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($typeId > 0) {
$db->update('voucher_types', $data, '`id` = ?', [$typeId]);
} else {
$data['created_at'] = date('Y-m-d H:i:s');
$db->insert('voucher_types', $data);
}
return $this->redirect('/accounting/vouchers/types')->withSuccess('تم حفظ نوع السند');
}
public function deleteType(Request $request, string $id): Response
{
$this->authorize('accounting.voucher.manage');
$db = App::getInstance()->db();
$typeId = (int) $id;
$used = $db->selectOne("SELECT COUNT(*) AS n FROM vouchers WHERE voucher_type_id = ?", [$typeId]);
// Vouchers reference the type; deactivate rather than orphan them.
if ((int) ($used['n'] ?? 0) > 0) {
$db->update('voucher_types', ['is_active' => 0], '`id` = ?', [$typeId]);
return $this->redirect('/accounting/vouchers/types')
->withWarning('النوع مستخدم في ' . (int) $used['n'] . ' سند — تم إيقافه بدل حذفه');
}
$db->delete('voucher_types', '`id` = ?', [$typeId]);
return $this->redirect('/accounting/vouchers/types')->withSuccess('تم حذف النوع');
}
}
...@@ -171,6 +171,18 @@ return [ ...@@ -171,6 +171,18 @@ return [
['POST', '/accounting/billing/sources/{id:\d+}', 'Accounting\Controllers\BillingController@saveSource', ['auth', 'csrf'], 'accounting.billing.manage'], ['POST', '/accounting/billing/sources/{id:\d+}', 'Accounting\Controllers\BillingController@saveSource', ['auth', 'csrf'], 'accounting.billing.manage'],
['POST', '/accounting/billing/sources/{id:\d+}/delete', 'Accounting\Controllers\BillingController@deleteSource', ['auth', 'csrf'], 'accounting.billing.manage'], ['POST', '/accounting/billing/sources/{id:\d+}/delete', 'Accounting\Controllers\BillingController@deleteSource', ['auth', 'csrf'], 'accounting.billing.manage'],
// ── Vouchers (payment / receipt) ────────────────────────
['GET', '/accounting/vouchers', 'Accounting\\Controllers\\VoucherController@index', ['auth'], 'accounting.voucher.view'],
['GET', '/accounting/vouchers/create', 'Accounting\\Controllers\\VoucherController@create', ['auth'], 'accounting.voucher.create'],
['POST', '/accounting/vouchers', 'Accounting\\Controllers\\VoucherController@store', ['auth', 'csrf'], 'accounting.voucher.create'],
['GET', '/accounting/vouchers/types', 'Accounting\\Controllers\\VoucherController@types', ['auth'], 'accounting.voucher.manage'],
['POST', '/accounting/vouchers/types/{id:\\d+}', 'Accounting\\Controllers\\VoucherController@saveType', ['auth', 'csrf'], 'accounting.voucher.manage'],
['POST', '/accounting/vouchers/types/{id:\\d+}/delete', 'Accounting\\Controllers\\VoucherController@deleteType', ['auth', 'csrf'], 'accounting.voucher.manage'],
['GET', '/accounting/vouchers/{id:\\d+}', 'Accounting\\Controllers\\VoucherController@show', ['auth'], 'accounting.voucher.view'],
['POST', '/accounting/vouchers/{id:\\d+}/post', 'Accounting\\Controllers\\VoucherController@post', ['auth', 'csrf'], 'accounting.voucher.post'],
['POST', '/accounting/vouchers/{id:\\d+}/approve', 'Accounting\\Controllers\\VoucherController@approve', ['auth', 'csrf'], 'accounting.voucher.approve'],
['POST', '/accounting/vouchers/{id:\\d+}/cancel', 'Accounting\\Controllers\\VoucherController@cancel', ['auth', 'csrf'], 'accounting.voucher.post'],
// ── Letters of Guarantee ──────────────────────────────── // ── Letters of Guarantee ────────────────────────────────
['GET', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@index', ['auth'], 'accounting.guarantee.view'], ['GET', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@index', ['auth'], 'accounting.guarantee.view'],
['GET', '/accounting/guarantees/create', 'Accounting\Controllers\LetterOfGuaranteeController@create', ['auth'], 'accounting.guarantee.manage'], ['GET', '/accounting/guarantees/create', 'Accounting\Controllers\LetterOfGuaranteeController@create', ['auth'], 'accounting.guarantee.manage'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
/**
* Payment and receipt vouchers.
*
* The user says what the money was for; the double entry is derived:
*
* OUTFLOW (صرف) Dr each expense line Cr cash / bank
* INFLOW (قبض) Dr cash / bank Cr each revenue line
*
* Input VAT on an outflow line is split out the same way the posting engine splits
* output VAT, so a supplier invoice with 14% recoverable tax posts correctly
* without the clerk doing the arithmetic.
*/
final class VoucherService
{
private const SCALE = 2;
/**
* @return array{success:bool, voucher_id?:int, voucher_number?:string, error?:string}
*/
public static function create(array $data, array $lines): array
{
$db = App::getInstance()->db();
$typeId = (int) ($data['voucher_type_id'] ?? 0);
$type = $db->selectOne("SELECT * FROM voucher_types WHERE id = ? AND is_active = 1", [$typeId]);
if (!$type) {
return ['success' => false, 'error' => 'نوع السند غير موجود أو موقوف'];
}
$counterId = (int) ($data['counter_account_id'] ?? 0);
if ($counterId <= 0) {
$counterId = (int) ($type['default_counter_account_id'] ?? 0);
}
if ($counterId <= 0) {
return ['success' => false, 'error' => 'حدد حساب النقدية أو البنك'];
}
$counterCheck = self::assertPostable($counterId);
if ($counterCheck !== null) {
return ['success' => false, 'error' => 'حساب النقدية: ' . $counterCheck];
}
// Normalise and validate the lines before anything is written.
$clean = [];
$total = '0.00';
foreach ($lines as $i => $l) {
$accountId = (int) ($l['account_id'] ?? 0);
$amount = number_format((float) ($l['amount'] ?? 0), self::SCALE, '.', '');
if ($accountId <= 0 || bccomp($amount, '0.01', self::SCALE) < 0) {
continue; // blank row from the form
}
$err = self::assertPostable($accountId);
if ($err !== null) {
return ['success' => false, 'error' => 'البند ' . ($i + 1) . ': ' . $err];
}
if ($accountId === $counterId) {
return ['success' => false, 'error' => 'البند ' . ($i + 1) . ': نفس حساب النقدية — القيد سيلغي نفسه'];
}
$clean[] = [
'account_id' => $accountId,
'amount' => $amount,
'description_ar' => $l['description_ar'] ?? null,
'cost_center_id' => !empty($l['cost_center_id']) ? (int) $l['cost_center_id'] : null,
'branch_id' => !empty($l['branch_id']) ? (int) $l['branch_id'] : null,
'tax_profile_id' => !empty($l['tax_profile_id']) ? (int) $l['tax_profile_id'] : null,
];
$total = bcadd($total, $amount, self::SCALE);
}
if (empty($clean)) {
return ['success' => false, 'error' => 'أضف بندًا واحدًا على الأقل بمبلغ أكبر من صفر'];
}
$direction = $type['direction'];
$employee = App::getInstance()->currentEmployee();
$date = $data['voucher_date'] ?? date('Y-m-d');
$db->beginTransaction();
try {
$number = self::generateNumber((string) $type['number_prefix'], $date);
$voucherId = $db->insert('vouchers', [
'voucher_number' => $number,
'voucher_type_id' => $typeId,
'direction' => $direction,
'voucher_date' => $date,
'counter_account_id' => $counterId,
'payment_method' => $data['payment_method'] ?? 'cash',
'total_amount' => $total,
'beneficiary_name' => $data['beneficiary_name'] ?? null,
'supplier_id' => !empty($data['supplier_id']) ? (int) $data['supplier_id'] : null,
'member_id' => !empty($data['member_id']) ? (int) $data['member_id'] : null,
'employee_id' => !empty($data['employee_id']) ? (int) $data['employee_id'] : null,
'check_number' => $data['check_number'] ?? null,
'check_bank' => $data['check_bank'] ?? null,
'check_date' => $data['check_date'] ?? null,
'reference' => $data['reference'] ?? null,
'description_ar' => $data['description_ar'] ?? ($type['name_ar'] . ' ' . $number),
'notes' => $data['notes'] ?? null,
'status' => (int) $type['requires_approval'] === 1 ? 'pending_approval' : 'draft',
'created_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
foreach ($clean as $i => $l) {
$db->insert('voucher_lines', $l + [
'voucher_id' => $voucherId,
'line_number' => $i + 1,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل حفظ السند: ' . $e->getMessage()];
}
return ['success' => true, 'voucher_id' => $voucherId, 'voucher_number' => $number];
}
/**
* Post a voucher to the ledger.
*
* Idempotent: a voucher that already carries a journal entry is refused rather
* than posted twice.
*/
public static function post(int $voucherId): array
{
$db = App::getInstance()->db();
$v = $db->selectOne("SELECT * FROM vouchers WHERE id = ?", [$voucherId]);
if (!$v) {
return ['success' => false, 'error' => 'السند غير موجود'];
}
if ($v['status'] === 'posted' || !empty($v['journal_entry_id'])) {
return ['success' => false, 'error' => 'السند مُرحَّل بالفعل'];
}
if ($v['status'] === 'cancelled') {
return ['success' => false, 'error' => 'السند ملغي'];
}
if ($v['status'] === 'pending_approval') {
return ['success' => false, 'error' => 'السند يحتاج اعتمادًا قبل الترحيل'];
}
$lines = $db->select(
"SELECT * FROM voucher_lines WHERE voucher_id = ? ORDER BY line_number",
[$voucherId]
);
if (empty($lines)) {
return ['success' => false, 'error' => 'السند بلا بنود'];
}
$isOutflow = $v['direction'] === 'outflow';
$jLines = [];
$counterTotal = '0.00';
foreach ($lines as $l) {
$amount = number_format((float) $l['amount'], self::SCALE, '.', '');
$net = $amount;
$tax = '0.00';
$taxAccountId = null;
// Recoverable input tax on a purchase: split it out so the expense is
// recorded net and the tax sits in its own asset account.
if (!empty($l['tax_profile_id'])) {
$profile = $db->selectOne(
"SELECT * FROM revenue_tax_profiles WHERE id = ? AND is_active = 1",
[(int) $l['tax_profile_id']]
);
if ($profile && \in_array($profile['treatment'], ['standard', 'table'], true)) {
$rate = bcdiv((string) $profile['rate'], '100', 8);
if (bccomp($rate, '0', 8) > 0) {
if ((int) $profile['is_price_inclusive'] === 1) {
$net = number_format((float) bcdiv($amount, bcadd('1', $rate, 8), 8), self::SCALE, '.', '');
$tax = bcsub($amount, $net, self::SCALE);
} else {
$tax = number_format((float) bcmul($amount, $rate, 8), self::SCALE, '.', '');
}
$taxAccountId = $isOutflow
? ($profile['input_tax_account_id'] ?? null)
: ($profile['output_tax_account_id'] ?? null);
}
}
}
$jLines[] = [
'account_id' => (int) $l['account_id'],
'debit' => $isOutflow ? $net : '0.00',
'credit' => $isOutflow ? '0.00' : $net,
'description_ar' => $l['description_ar'] ?: $v['description_ar'],
'cost_center_id' => $l['cost_center_id'],
'branch_id' => $l['branch_id'],
'supplier_id' => $v['supplier_id'] ?? null,
'member_id' => $v['member_id'] ?? null,
'employee_id' => $v['employee_id'] ?? null,
];
$counterTotal = bcadd($counterTotal, $net, self::SCALE);
if (bccomp($tax, '0.00', self::SCALE) > 0) {
if ($taxAccountId === null) {
return ['success' => false, 'error' => 'الملف الضريبي بلا حساب ضريبة — راجع الملفات الضريبية'];
}
$jLines[] = [
'account_id' => (int) $taxAccountId,
'debit' => $isOutflow ? $tax : '0.00',
'credit' => $isOutflow ? '0.00' : $tax,
'description_ar' => ($isOutflow ? 'ضريبة مدخلات — ' : 'ضريبة مخرجات — ') . $v['description_ar'],
];
$counterTotal = bcadd($counterTotal, $tax, self::SCALE);
}
}
// The cash side carries the whole voucher, tax included.
$jLines[] = [
'account_id' => (int) $v['counter_account_id'],
'debit' => $isOutflow ? '0.00' : $counterTotal,
'credit' => $isOutflow ? $counterTotal : '0.00',
'description_ar' => $v['description_ar'],
];
$result = JournalService::createEntry([
'entry_date' => $v['voucher_date'],
'description_ar' => $v['description_ar'],
'reference_type' => 'voucher',
'reference_id' => $voucherId,
'reference_number' => $v['voucher_number'],
'source_module' => 'accounting',
'is_auto_generated' => 0,
'notes' => $v['notes'] ?? null,
], $jLines, true);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error'] ?? 'فشل ترحيل السند'];
}
$employee = App::getInstance()->currentEmployee();
$db->update('vouchers', [
'status' => 'posted',
'journal_entry_id' => (int) $result['journal_entry_id'],
'posted_at' => date('Y-m-d H:i:s'),
'posted_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$voucherId]);
EventBus::dispatch('voucher.posted', [
'voucher_id' => $voucherId,
'voucher_number' => $v['voucher_number'],
'direction' => $v['direction'],
'amount' => $v['total_amount'],
'journal_entry_id' => (int) $result['journal_entry_id'],
]);
return ['success' => true, 'journal_entry_id' => (int) $result['journal_entry_id']];
}
public static function approve(int $voucherId): array
{
$db = App::getInstance()->db();
$v = $db->selectOne("SELECT status FROM vouchers WHERE id = ?", [$voucherId]);
if (!$v) {
return ['success' => false, 'error' => 'السند غير موجود'];
}
if ($v['status'] !== 'pending_approval') {
return ['success' => false, 'error' => 'السند ليس في انتظار الاعتماد'];
}
$employee = App::getInstance()->currentEmployee();
$db->update('vouchers', [
'status' => 'draft',
'approved_at' => date('Y-m-d H:i:s'),
'approved_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$voucherId]);
return ['success' => true];
}
/**
* Cancel a voucher. A posted one is reversed in the ledger rather than deleted —
* a posted entry is never erased, it is answered with an opposite entry.
*/
public static function cancel(int $voucherId, string $reason): array
{
$db = App::getInstance()->db();
$v = $db->selectOne("SELECT * FROM vouchers WHERE id = ?", [$voucherId]);
if (!$v) {
return ['success' => false, 'error' => 'السند غير موجود'];
}
if ($v['status'] === 'cancelled') {
return ['success' => false, 'error' => 'السند ملغي بالفعل'];
}
$reversalId = null;
if ($v['status'] === 'posted' && !empty($v['journal_entry_id'])) {
$rev = JournalService::reverseEntry((int) $v['journal_entry_id'], $reason);
if (!$rev['success']) {
return ['success' => false, 'error' => 'تعذر عكس القيد: ' . ($rev['error'] ?? '')];
}
$reversalId = (int) $rev['reversal_entry_id'];
}
$employee = App::getInstance()->currentEmployee();
$db->update('vouchers', [
'status' => 'cancelled',
'reversal_entry_id' => $reversalId,
'cancelled_at' => date('Y-m-d H:i:s'),
'cancelled_by' => $employee ? (int) $employee->id : null,
'cancel_reason' => $reason,
], '`id` = ?', [$voucherId]);
return ['success' => true, 'reversal_entry_id' => $reversalId];
}
// ────────────────────────────────────────────────────────────────────
private static function assertPostable(int $accountId): ?string
{
$db = App::getInstance()->db();
$acc = $db->selectOne(
"SELECT account_code, name_ar, is_header, is_active FROM chart_of_accounts WHERE id = ? AND is_archived = 0",
[$accountId]
);
if (!$acc) {
return 'الحساب غير موجود';
}
if ((int) $acc['is_header'] === 1) {
return 'الحساب «' . $acc['account_code'] . ' ' . $acc['name_ar'] . '» رئيسي ولا يقبل الترحيل';
}
if ((int) $acc['is_active'] === 0) {
return 'الحساب «' . $acc['account_code'] . '» غير نشط';
}
return null;
}
/**
* Sequential number per prefix and year. Retries on collision so two clerks
* saving at the same moment cannot both take the same number.
*/
private static function generateNumber(string $prefix, string $date): string
{
$db = App::getInstance()->db();
$year = substr($date, 0, 4);
$like = $prefix . '-' . $year . '-%';
for ($attempt = 0; $attempt < 5; $attempt++) {
$last = $db->selectOne(
"SELECT voucher_number FROM vouchers WHERE voucher_number LIKE ? ORDER BY id DESC LIMIT 1",
[$like]
);
$next = 1;
if ($last) {
$parts = explode('-', (string) $last['voucher_number']);
$next = ((int) end($parts)) + 1 + $attempt;
}
$candidate = $prefix . '-' . $year . '-' . str_pad((string) $next, 5, '0', STR_PAD_LEFT);
if (!$db->selectOne("SELECT id FROM vouchers WHERE voucher_number = ?", [$candidate])) {
return $candidate;
}
}
return $prefix . '-' . $year . '-' . substr((string) microtime(true), -8);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سند جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/vouchers" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى السندات</a>
<h2 style="margin:6px 0 4px;">سند جديد</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:700px;">
اكتب الصرف أو القبض بلغة عادية — النظام بيبني القيد المزدوج لوحده.
سند صرف يعني فلوس خرجت، وسند قبض يعني فلوس دخلت.
</p>
</div>
<form method="POST" action="/accounting/vouchers" id="v-form">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:16px;align-items:start;">
<div>
<div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">بيانات السند</h3></div>
<div style="padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="form-label">نوع السند <span style="color:#DC2626;">*</span></label>
<select name="voucher_type_id" id="v-type" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($types as $t): ?>
<option value="<?= (int) $t['id'] ?>"
data-direction="<?= e($t['direction']) ?>"
data-counter="<?= (int) ($t['default_counter_account_id'] ?? 0) ?>"
data-line="<?= (int) ($t['default_line_account_id'] ?? 0) ?>"
data-supplier="<?= (int) $t['requires_supplier'] ?>"
<?= $preselect === (int) $t['id'] ? 'selected' : '' ?>>
<?= e($t['name_ar']) ?><?= $t['direction'] === 'outflow' ? 'صرف' : 'قبض' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">التاريخ <span style="color:#DC2626;">*</span></label>
<input type="date" name="voucher_date" class="form-input" required value="<?= e(date('Y-m-d')) ?>">
</div>
<div style="grid-column:1/-1;">
<label class="form-label">البيان <span style="color:#DC2626;">*</span></label>
<input type="text" name="description_ar" class="form-input" required placeholder="مثال: مصاريف دعاية — حملة الموسم الصيفي">
</div>
<div>
<label class="form-label" id="lbl-beneficiary">المستفيد</label>
<input type="text" name="beneficiary_name" class="form-input" placeholder="اسم الجهة أو الشخص">
</div>
<div>
<label class="form-label">رقم المرجع</label>
<input type="text" name="reference" class="form-input" dir="ltr" placeholder="رقم الفاتورة أو العقد">
</div>
</div>
</div>
<div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;" id="counter-title">من أي حساب خرجت الفلوس</h3></div>
<div style="padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="form-label">الحساب <span style="color:#DC2626;">*</span></label>
<input type="text" class="form-input acct-search" id="counter-search" placeholder="ابحث: الصندوق، البنك…">
<input type="hidden" name="counter_account_id" id="counter-id" required>
<div class="acct-results" id="counter-results"></div>
</div>
<div>
<label class="form-label">طريقة الدفع</label>
<select name="payment_method" id="v-method" class="form-select">
<option value="cash">نقدي</option>
<option value="bank_transfer">تحويل بنكي</option>
<option value="check">شيك</option>
<option value="visa">فيزا</option>
</select>
</div>
<div id="v-check" style="display:none;grid-column:1/-1;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label class="form-label">رقم الشيك</label><input type="text" name="check_number" class="form-input" dir="ltr"></div>
<div><label class="form-label">البنك</label><input type="text" name="check_bank" class="form-input"></div>
<div><label class="form-label">تاريخ الشيك</label><input type="date" name="check_date" class="form-input"></div>
</div>
</div>
</div>
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:14px;" id="lines-title">الفلوس دي راحت على إيه</h3>
<button type="button" id="add-line" class="btn btn-sm btn-secondary">+ بند</button>
</div>
<div id="lines" style="padding:14px 18px;"></div>
<div style="padding:12px 18px;border-top:1px solid #E5E7EB;background:#F9FAFB;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:13px;">الإجمالي</strong>
<strong id="v-total" style="font-size:18px;">0.00</strong>
</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap;">
<button type="submit" name="post_now" value="1" class="btn btn-primary btn-lg">حفظ وترحيل</button>
<button type="submit" name="post_now" value="0" class="btn btn-outline">حفظ كمسودة</button>
<a href="/accounting/vouchers" class="btn btn-ghost">إلغاء</a>
</div>
</div>
<div style="position:sticky;top:14px;">
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">القيد الناتج</h3></div>
<div style="padding:16px 18px;" id="preview">
<div style="font-size:12px;color:#6B7280;">اختر النوع والحساب وأضف البنود.</div>
</div>
</div>
</div>
</div>
</form>
<template id="line-tpl">
<div class="v-line" style="border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;">
<div style="display:grid;grid-template-columns:1fr 140px auto;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:11px;">الحساب</label>
<input type="text" class="form-input acct-search l-search" placeholder="مثال: دعاية، صيانة، كهرباء">
<input type="hidden" name="line_account[]" class="l-acct">
<div class="acct-results"></div>
</div>
<div>
<label class="form-label" style="font-size:11px;">المبلغ</label>
<input type="number" name="line_amount[]" class="form-input l-amount" step="0.01" min="0" dir="ltr" style="text-align:right;">
</div>
<button type="button" class="btn btn-sm btn-ghost l-del" style="color:#DC2626;">حذف</button>
</div>
<div style="display:grid;grid-template-columns:1fr 180px 180px;gap:10px;margin-top:10px;">
<div>
<label class="form-label" style="font-size:11px;">بيان البند</label>
<input type="text" name="line_desc[]" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:11px;">مركز التكلفة</label>
<select name="line_cost_center[]" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($costCenters as $cc): ?>
<option value="<?= (int) $cc['id'] ?>"><?= e($cc['code'] . ' — ' . $cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:11px;">ضريبة</label>
<select name="line_tax[]" class="form-select l-tax">
<option value="">— بدون —</option>
<?php foreach ($taxProfiles as $tp): ?>
<option value="<?= (int) $tp['id'] ?>"><?= e($tp['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
</template>
<script>
(function () {
var linesBox = document.getElementById('lines');
var tpl = document.getElementById('line-tpl');
var typeSel = document.getElementById('v-type');
var method = document.getElementById('v-method');
function wireSearch(input, hidden, results) {
var timer = null;
input.addEventListener('input', function () {
clearTimeout(timer);
var q = input.value.trim();
if (q.length < 2) { results.innerHTML = ''; return; }
timer = setTimeout(function () {
fetch('/accounting/revenue-mapping/search-accounts?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
results.innerHTML = '';
var box = document.createElement('div');
box.style.cssText = 'border:1px solid #E5E7EB;border-radius:6px;margin-top:4px;max-height:200px;overflow:auto;background:#fff;position:relative;z-index:20;';
(d.accounts || []).forEach(function (a) {
var row = document.createElement('div');
row.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid #F3F4F6;';
row.innerHTML = '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:70px;">' + a.account_code + '</span> ' + a.name_ar;
row.addEventListener('click', function () {
hidden.value = a.id;
input.value = a.account_code + ' — ' + a.name_ar;
results.innerHTML = '';
render();
});
box.appendChild(row);
});
results.appendChild(box);
});
}, 220);
});
}
wireSearch(document.getElementById('counter-search'), document.getElementById('counter-id'), document.getElementById('counter-results'));
function addLine() {
var node = tpl.content.cloneNode(true);
var el = node.querySelector('.v-line');
linesBox.appendChild(node);
wireSearch(el.querySelector('.l-search'), el.querySelector('.l-acct'), el.querySelector('.acct-results'));
el.querySelector('.l-amount').addEventListener('input', render);
el.querySelector('.l-tax').addEventListener('change', render);
el.querySelector('.l-del').addEventListener('click', function () { el.remove(); render(); });
render();
}
function outflow() {
var o = typeSel.options[typeSel.selectedIndex];
return !o || o.dataset.direction !== 'inflow';
}
function render() {
var isOut = outflow();
document.getElementById('counter-title').textContent = isOut ? 'من أي حساب خرجت الفلوس' : 'في أي حساب دخلت الفلوس';
document.getElementById('lines-title').textContent = isOut ? 'الفلوس دي راحت على إيه' : 'الفلوس دي جاية من إيه';
document.getElementById('lbl-beneficiary').textContent = isOut ? 'المستفيد (المدفوع له)' : 'الدافع';
var total = 0, rows = [];
linesBox.querySelectorAll('.v-line').forEach(function (el) {
var amt = parseFloat(el.querySelector('.l-amount').value || '0');
if (!amt) return;
total += amt;
rows.push({ label: el.querySelector('.l-search').value || 'بند', amount: amt });
});
document.getElementById('v-total').textContent = total.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
var counter = document.getElementById('counter-search').value || 'النقدية / البنك';
var h = '<table style="width:100%;border-collapse:collapse;font-size:11.5px;">'
+ '<thead><tr style="background:#F9FAFB;">'
+ '<th style="text-align:right;padding:6px;border-bottom:1px solid #E5E7EB;">الحساب</th>'
+ '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:70px;">مدين</th>'
+ '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:70px;">دائن</th>'
+ '</tr></thead><tbody>';
function fmt(n){ return n.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); }
rows.forEach(function (r) {
h += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;">' + r.label + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + (isOut ? fmt(r.amount) : '') + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + (isOut ? '' : fmt(r.amount)) + '</td></tr>';
});
h += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;color:#6B7280;">' + counter + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + (isOut ? '' : fmt(total)) + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + (isOut ? fmt(total) : '') + '</td></tr>';
h += '<tr style="background:#F9FAFB;font-weight:700;"><td style="padding:6px;">الإجمالي</td>'
+ '<td style="padding:6px;text-align:left;color:#059669;">' + fmt(total) + '</td>'
+ '<td style="padding:6px;text-align:left;color:#059669;">' + fmt(total) + '</td></tr>';
h += '</tbody></table>';
h += '<div style="margin-top:8px;font-size:11.5px;color:#059669;font-weight:600;">✓ القيد متوازن</div>';
h += '<div style="margin-top:6px;font-size:11px;color:#6B7280;">الضريبة على أي بند بتتفصل تلقائيًا عند الترحيل.</div>';
document.getElementById('preview').innerHTML = rows.length ? h : '<div style="font-size:12px;color:#6B7280;">أضف بندًا واحدًا على الأقل.</div>';
}
method.addEventListener('change', function () {
document.getElementById('v-check').style.display = method.value === 'check' ? 'grid' : 'none';
});
typeSel.addEventListener('change', function () {
var o = typeSel.options[typeSel.selectedIndex];
if (o && o.dataset.counter && o.dataset.counter !== '0') {
document.getElementById('counter-id').value = o.dataset.counter;
document.getElementById('counter-search').value = 'الحساب الافتراضي للنوع';
}
render();
});
document.getElementById('add-line').addEventListener('click', addLine);
addLine();
})();
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سندات الصرف والقبض<?php $__template->endSection(); ?>
<?php
$statusLabels = [
'draft' => ['مسودة', 'badge-neutral'],
'pending_approval' => ['بانتظار الاعتماد', 'badge-warning'],
'posted' => ['مُرحَّل', 'badge-success'],
'cancelled' => ['ملغي', 'badge-danger'],
];
?>
<?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:15px;margin-bottom:18px;flex-wrap:wrap;">
<div>
<h2 style="margin:0 0 4px;">سندات الصرف والقبض</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:640px;">
صرف مصروف أو تسجيل قبض من غير ما تفكّر في مدين ودائن — النظام بيبني القيد.
</p>
</div>
<div style="display:flex;gap:8px;">
<?php if (can('accounting.voucher.manage')): ?>
<a href="/accounting/vouchers/types" class="btn btn-outline">أنواع السندات</a>
<?php endif; ?>
<?php if (can('accounting.voucher.create')): ?>
<a href="/accounting/vouchers/create" class="btn btn-primary">+ سند جديد</a>
<?php endif; ?>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:18px;">
<?php foreach ($summary as $s): ?>
<?php [$lbl, $cls] = $statusLabels[$s['status']] ?? [$s['status'], 'badge-neutral']; ?>
<a href="/accounting/vouchers?status=<?= e($s['status']) ?>" style="text-decoration:none;">
<div class="card" style="padding:14px 16px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;"><?= e($lbl) ?></div>
<div style="font-size:20px;font-weight:700;"><?= money($s['total']) ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= number_format((int) $s['n']) ?> سند</div>
</div>
</a>
<?php endforeach; ?>
</div>
<div class="card" style="padding:14px 16px;margin-bottom:15px;">
<form method="GET" action="/accounting/vouchers" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:180px;">
<label class="form-label">النوع</label>
<select name="type" class="form-select">
<option value="0">الكل</option>
<?php foreach ($types as $t): ?>
<option value="<?= (int) $t['id'] ?>" <?= $typeId === (int) $t['id'] ? 'selected' : '' ?>><?= e($t['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:170px;">
<label class="form-label">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<?php foreach ($statusLabels as $k => $v): ?>
<option value="<?= e($k) ?>" <?= $status === $k ? 'selected' : '' ?>><?= e($v[0]) ?></option>
<?php endforeach; ?>
</select>
</div>
<div><button type="submit" class="btn btn-primary">تصفية</button></div>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم السند</th><th>النوع</th><th>التاريخ</th><th>البيان</th>
<th>المستفيد</th><th>المبلغ</th><th>الحالة</th><th></th>
</tr>
</thead>
<tbody>
<?php foreach ($vouchers as $v): ?>
<?php [$lbl, $cls] = $statusLabels[$v['status']] ?? [$v['status'], 'badge-neutral']; ?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($v['voucher_number']) ?></td>
<td style="font-size:12px;">
<?= e($v['type_name']) ?>
<div style="font-size:10px;color:#9CA3AF;"><?= $v['direction'] === 'outflow' ? 'صرف' : 'قبض' ?></div>
</td>
<td style="font-size:12px;color:#6B7280;"><?= e($v['voucher_date']) ?></td>
<td style="font-size:12px;"><?= e(mb_substr((string) $v['description_ar'], 0, 50)) ?></td>
<td style="font-size:12px;"><?= e((string) ($v['beneficiary_name'] ?? '—')) ?></td>
<td style="font-weight:600;"><?= money($v['total_amount']) ?></td>
<td><span class="badge <?= $cls ?>"><?= e($lbl) ?></span></td>
<td><a href="/accounting/vouchers/<?= (int) $v['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($vouchers)): ?>
<tr><td colspan="8" style="text-align:center;color:#6B7280;padding:30px;">لا توجد سندات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سند <?= e($voucher['voucher_number']) ?><?php $__template->endSection(); ?>
<?php
$statusLabels = [
'draft' => ['مسودة','badge-neutral'], 'pending_approval' => ['بانتظار الاعتماد','badge-warning'],
'posted' => ['مُرحَّل','badge-success'], 'cancelled' => ['ملغي','badge-danger'],
];
[$lbl, $cls] = $statusLabels[$voucher['status']] ?? [$voucher['status'],'badge-neutral'];
$isOut = $voucher['direction'] === 'outflow';
?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/vouchers" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى السندات</a>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-top:6px;">
<div>
<h2 style="margin:0 0 4px;"><?= e($voucher['type_name']) ?><span style="direction:ltr;"><?= e($voucher['voucher_number']) ?></span></h2>
<div style="font-size:13px;color:#6B7280;"><?= e($voucher['description_ar']) ?></div>
</div>
<div style="text-align:left;">
<span class="badge <?= $cls ?>" style="font-size:12px;"><?= e($lbl) ?></span>
<div style="font-size:24px;font-weight:700;margin-top:6px;color:<?= $isOut ? '#B45309' : '#059669' ?>;"><?= money($voucher['total_amount']) ?></div>
</div>
</div>
</div>
<?php if (!empty($voucher['cancel_reason'])): ?>
<div class="card" style="margin-bottom:14px;border-right:3px solid #DC2626;padding:12px 18px;">
<strong style="color:#991B1B;font-size:13px;">ملغي:</strong>
<span style="font-size:13px;"><?= e($voucher['cancel_reason']) ?></span>
</div>
<?php endif; ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;margin-bottom:14px;">
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">البيانات</h3></div>
<div style="padding:14px 18px;font-size:13px;line-height:2;">
<div><span style="color:#6B7280;">التاريخ:</span> <?= e($voucher['voucher_date']) ?></div>
<div><span style="color:#6B7280;"><?= $isOut ? 'المدفوع له' : 'الدافع' ?>:</span> <?= e((string)($voucher['beneficiary_name'] ?? '—')) ?></div>
<div><span style="color:#6B7280;">طريقة الدفع:</span> <?= e($voucher['payment_method']) ?></div>
<div><span style="color:#6B7280;"><?= $isOut ? 'صُرف من' : 'حُصّل في' ?>:</span>
<span style="direction:ltr;"><?= e((string)$voucher['counter_code']) ?></span> <?= e((string)$voucher['counter_name']) ?></div>
<?php if (!empty($voucher['check_number'])): ?>
<div><span style="color:#6B7280;">شيك رقم:</span> <span style="direction:ltr;"><?= e($voucher['check_number']) ?></span><?= e((string)$voucher['check_bank']) ?></div>
<?php endif; ?>
<?php if (!empty($voucher['reference'])): ?>
<div><span style="color:#6B7280;">المرجع:</span> <span style="direction:ltr;"><?= e($voucher['reference']) ?></span></div>
<?php endif; ?>
<div><span style="color:#6B7280;">أنشأه:</span> <?= e((string)($voucher['created_by_name'] ?? '—')) ?></div>
</div>
</div>
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">البنود</h3></div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الحساب</th><th>البيان</th><th>المبلغ</th></tr></thead>
<tbody>
<?php foreach ($lines as $l): ?>
<tr>
<td style="font-size:12px;"><span style="direction:ltr;color:#6B7280;"><?= e($l['account_code']) ?></span> <?= e($l['account_name']) ?>
<?php if (!empty($l['tax_name'])): ?><div style="font-size:10px;color:#92400E;"><?= e($l['tax_name']) ?></div><?php endif; ?>
</td>
<td style="font-size:12px;"><?= e((string)($l['description_ar'] ?? '—')) ?></td>
<td style="font-weight:600;"><?= money($l['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php if (!empty($entryLines)): ?>
<div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;">
<h3 style="margin:0;font-size:14px;">القيد المُرحَّل</h3>
<a href="/accounting/journal-entries/<?= (int) $voucher['journal_entry_id'] ?>" class="btn btn-sm btn-ghost">فتح القيد</a>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الحساب</th><th>البيان</th><th>مدين</th><th>دائن</th></tr></thead>
<tbody>
<?php $td='0.00'; $tc='0.00'; foreach ($entryLines as $e): $td=bcadd($td,(string)$e['debit'],2); $tc=bcadd($tc,(string)$e['credit'],2); ?>
<tr>
<td style="font-size:12px;"><span style="direction:ltr;color:#6B7280;"><?= e($e['account_code']) ?></span> <?= e($e['account_name']) ?></td>
<td style="font-size:12px;"><?= e((string)($e['description_ar'] ?? '')) ?></td>
<td style="font-weight:600;"><?= bccomp((string)$e['debit'],'0.00',2)>0 ? money($e['debit']) : '' ?></td>
<td style="font-weight:600;color:#A22A2A;"><?= bccomp((string)$e['credit'],'0.00',2)>0 ? money($e['credit']) : '' ?></td>
</tr>
<?php endforeach; ?>
<tr style="background:#F9FAFB;font-weight:700;">
<td colspan="2">الإجمالي</td><td><?= money($td) ?></td><td><?= money($tc) ?></td>
</tr>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<?php if ($voucher['status'] === 'pending_approval' && can('accounting.voucher.approve')): ?>
<form method="POST" action="/accounting/vouchers/<?= (int)$voucher['id'] ?>/approve"><?= csrf_field() ?>
<button type="submit" class="btn btn-primary">اعتماد السند</button></form>
<?php endif; ?>
<?php if ($voucher['status'] === 'draft' && can('accounting.voucher.post')): ?>
<form method="POST" action="/accounting/vouchers/<?= (int)$voucher['id'] ?>/post"><?= csrf_field() ?>
<button type="submit" class="btn btn-primary">ترحيل إلى الدفاتر</button></form>
<?php endif; ?>
<?php if ($voucher['status'] !== 'cancelled' && can('accounting.voucher.post')): ?>
<form method="POST" action="/accounting/vouchers/<?= (int)$voucher['id'] ?>/cancel"
onsubmit="var r=prompt('سبب الإلغاء؟'); if(!r) return false; this.reason.value=r; return true;">
<?= csrf_field() ?><input type="hidden" name="reason">
<button type="submit" class="btn btn-outline" style="color:#DC2626;">إلغاء السند<?= $voucher['status']==='posted' ? ' وعكس قيده' : '' ?></button>
</form>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>أنواع السندات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/vouchers" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى السندات</a>
<h2 style="margin:6px 0 4px;">أنواع السندات</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:700px;">
عرّف نوع سند لكل مصروف متكرر — دعاية، صيانة، كهرباء — واختار له حسابه الافتراضي،
فبيبقى الصرف بضغطة. <strong>ما بيحتاجش مبرمج.</strong>
</p>
</div>
<div class="card" style="margin-bottom:16px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">إضافة نوع</h3></div>
<div style="padding:16px 18px;">
<form method="POST" action="/accounting/vouchers/types/0">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;">
<div><label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" class="form-input" required dir="ltr" placeholder="PV_ADS" style="text-transform:uppercase;"></div>
<div><label class="form-label">الاسم <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" required placeholder="سند صرف دعاية"></div>
<div><label class="form-label">الاتجاه</label>
<select name="direction" class="form-select">
<option value="outflow">صرف — فلوس خرجت</option>
<option value="inflow">قبض — فلوس دخلت</option>
</select></div>
<div><label class="form-label">بادئة الترقيم</label>
<input type="text" name="number_prefix" class="form-input" dir="ltr" value="PV" maxlength="10"></div>
<div><label class="form-label">يحتاج اعتماد؟</label>
<select name="requires_approval" class="form-select">
<option value="0">لا</option><option value="1">نعم</option>
</select></div>
<div><label class="form-label">ترتيب العرض</label>
<input type="number" name="sort_order" class="form-input" dir="ltr" value="100"></div>
</div>
<div style="margin-top:14px;"><button type="submit" class="btn btn-primary">حفظ النوع</button></div>
</form>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الكود</th><th>الاسم</th><th>الاتجاه</th><th>الحساب الافتراضي</th><th>الاستخدام</th><th>الحالة</th><th></th></tr></thead>
<tbody>
<?php foreach ($types as $t): ?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($t['code']) ?></td>
<td><?= e($t['name_ar']) ?></td>
<td><?= $t['direction']==='outflow' ? 'صرف' : 'قبض' ?></td>
<td style="font-size:12px;">
<?php if (!empty($t['line_code'])): ?>
<span style="direction:ltr;color:#6B7280;"><?= e($t['line_code']) ?></span> <?= e($t['line_name']) ?>
<?php else: ?><span style="color:#9CA3AF;"></span><?php endif; ?>
</td>
<td><?= number_format((int)$t['usage_count']) ?> سند</td>
<td><span class="badge <?= (int)$t['is_active'] ? 'badge-success' : 'badge-neutral' ?>"><?= (int)$t['is_active'] ? 'نشط' : 'موقوف' ?></span></td>
<td>
<form method="POST" action="/accounting/vouchers/types/<?= (int)$t['id'] ?>/delete" style="display:inline;"
onsubmit="return confirm('حذف أو إيقاف النوع؟');">
<?= csrf_field() ?><button type="submit" class="btn btn-sm btn-ghost" style="color:#DC2626;">حذف</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
...@@ -107,6 +107,13 @@ PermissionRegistry::register('accounting', [ ...@@ -107,6 +107,13 @@ PermissionRegistry::register('accounting', [
'accounting.revenue_mapping.view' => ['ar' => 'عرض توزيع الإيرادات', 'en' => 'View Revenue Mapping'], 'accounting.revenue_mapping.view' => ['ar' => 'عرض توزيع الإيرادات', 'en' => 'View Revenue Mapping'],
'accounting.revenue_mapping.manage' => ['ar' => 'إدارة توزيع الإيرادات', 'en' => 'Manage Revenue Mapping'], 'accounting.revenue_mapping.manage' => ['ar' => 'إدارة توزيع الإيرادات', 'en' => 'Manage Revenue Mapping'],
// Vouchers
'accounting.voucher.view' => ['ar' => 'عرض السندات', 'en' => 'View Vouchers'],
'accounting.voucher.create' => ['ar' => 'إنشاء سند', 'en' => 'Create Voucher'],
'accounting.voucher.post' => ['ar' => 'ترحيل وإلغاء السندات', 'en' => 'Post/Cancel Vouchers'],
'accounting.voucher.approve' => ['ar' => 'اعتماد السندات', 'en' => 'Approve Vouchers'],
'accounting.voucher.manage' => ['ar' => 'إدارة أنواع السندات', 'en' => 'Manage Voucher Types'],
// Billing (universal collection) // Billing (universal collection)
'accounting.billing.view' => ['ar' => 'عرض المطالبات', 'en' => 'View Billing'], 'accounting.billing.view' => ['ar' => 'عرض المطالبات', 'en' => 'View Billing'],
'accounting.billing.collect' => ['ar' => 'تحصيل المطالبات', 'en' => 'Collect Billing'], 'accounting.billing.collect' => ['ar' => 'تحصيل المطالبات', 'en' => 'Collect Billing'],
...@@ -131,6 +138,7 @@ MenuRegistry::register('accounting', [ ...@@ -131,6 +138,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'توزيع الإيرادات', 'label_en' => 'Revenue Mapping', 'route' => '/accounting/revenue-mapping', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2], ['label_ar' => 'توزيع الإيرادات', 'label_en' => 'Revenue Mapping', 'route' => '/accounting/revenue-mapping', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'مركز التوصيل', 'label_en' => 'Connection Centre', 'route' => '/accounting/revenue-mapping/connections', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2], ['label_ar' => 'مركز التوصيل', 'label_en' => 'Connection Centre', 'route' => '/accounting/revenue-mapping/connections', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'المطالبات والتحصيل', 'label_en' => 'Billing & Collection', 'route' => '/accounting/billing', 'permission' => 'accounting.billing.view', 'order' => 2], ['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], ['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'قيود اليومية', 'label_en' => 'Journal Entries', 'route' => '/accounting/journal-entries', 'permission' => 'accounting.journal.view', 'order' => 3], ['label_ar' => 'قيود اليومية', 'label_en' => 'Journal Entries', 'route' => '/accounting/journal-entries', 'permission' => 'accounting.journal.view', 'order' => 3],
['label_ar' => 'أنواع اليومية', 'label_en' => 'Journal Types', 'route' => '/accounting/journal-types', 'permission' => 'accounting.journal_type.view', 'order' => 4], ['label_ar' => 'أنواع اليومية', 'label_en' => 'Journal Types', 'route' => '/accounting/journal-types', 'permission' => 'accounting.journal_type.view', 'order' => 4],
......
<?php
declare(strict_types=1);
/**
* سندات الصرف والقبض — payment and receipt vouchers.
*
* The manual journal entry screen can already express any transaction, but it asks
* the user to think in debits and credits. A clerk paying an advertising invoice
* should be able to say "صرف ٥٬٠٠٠ دعاية نقدي" and have the entry built for them.
*
* A voucher is that: pick a type, pick where the money came from or went to, list
* what it was for, post. The double entry is derived, never typed.
*
* Voucher TYPES are configurable, so a club can add "سند صرف صيانة" with the
* maintenance account pre-selected without touching code.
*/
return function (\App\Core\Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS `voucher_types` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(40) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`direction` ENUM('outflow','inflow') NOT NULL DEFAULT 'outflow'
COMMENT 'outflow = صرف (money leaves), inflow = قبض (money arrives)',
`number_prefix` VARCHAR(10) NOT NULL DEFAULT 'PV',
`default_counter_account_id` BIGINT UNSIGNED NULL
COMMENT 'cash or bank side; null = chosen per voucher',
`default_line_account_id` BIGINT UNSIGNED NULL
COMMENT 'the expense or revenue account this type usually hits',
`requires_approval` TINYINT(1) NOT NULL DEFAULT 0,
`requires_supplier` TINYINT(1) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`is_system` TINYINT(1) NOT NULL DEFAULT 0,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_voucher_type_code` (`code`),
INDEX `idx_voucher_type_active` (`is_active`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$db->raw("
CREATE TABLE IF NOT EXISTS `vouchers` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`voucher_number` VARCHAR(30) NOT NULL,
`voucher_type_id` BIGINT UNSIGNED NOT NULL,
`direction` ENUM('outflow','inflow') NOT NULL,
`voucher_date` DATE NOT NULL,
`counter_account_id` BIGINT UNSIGNED NOT NULL COMMENT 'the cash / bank side',
`payment_method` VARCHAR(30) NOT NULL DEFAULT 'cash',
`total_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`beneficiary_name` VARCHAR(300) NULL COMMENT 'who was paid, or who paid',
`supplier_id` BIGINT UNSIGNED NULL,
`member_id` BIGINT UNSIGNED NULL,
`employee_id` BIGINT UNSIGNED NULL,
`check_number` VARCHAR(50) NULL,
`check_bank` VARCHAR(100) NULL,
`check_date` DATE NULL,
`reference` VARCHAR(100) NULL,
`description_ar` VARCHAR(500) NOT NULL,
`notes` TEXT NULL,
`attachment_path` VARCHAR(500) NULL,
`status` ENUM('draft','pending_approval','posted','cancelled') NOT NULL DEFAULT 'draft',
`journal_entry_id` BIGINT UNSIGNED NULL,
`reversal_entry_id` BIGINT UNSIGNED NULL,
`approved_by` BIGINT UNSIGNED NULL,
`approved_at` DATETIME NULL,
`posted_by` BIGINT UNSIGNED NULL,
`posted_at` DATETIME NULL,
`cancelled_by` BIGINT UNSIGNED NULL,
`cancelled_at` DATETIME NULL,
`cancel_reason` VARCHAR(300) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_voucher_number` (`voucher_number`),
INDEX `idx_voucher_status` (`status`, `voucher_date`),
INDEX `idx_voucher_type` (`voucher_type_id`, `status`),
CONSTRAINT `fk_voucher_type` FOREIGN KEY (`voucher_type_id`)
REFERENCES `voucher_types`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$db->raw("
CREATE TABLE IF NOT EXISTS `voucher_lines` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`voucher_id` BIGINT UNSIGNED NOT NULL,
`line_number` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`account_id` BIGINT UNSIGNED NOT NULL,
`amount` DECIMAL(18,2) NOT NULL,
`description_ar` VARCHAR(300) NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
`tax_profile_id` BIGINT UNSIGNED NULL COMMENT 'input VAT on this line, if any',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_voucher_line` (`voucher_id`, `line_number`),
CONSTRAINT `fk_voucher_line_voucher` FOREIGN KEY (`voucher_id`)
REFERENCES `vouchers`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
};
...@@ -35,9 +35,18 @@ return function (\App\Core\Database $db): void { ...@@ -35,9 +35,18 @@ return function (\App\Core\Database $db): void {
$ensureLeaf = function (string $code, string $nameAr, string $nameEn, string $parentCode) $ensureLeaf = function (string $code, string $nameAr, string $nameEn, string $parentCode)
use ($db, $now, $accId): ?int { use ($db, $now, $accId): ?int {
$existing = $accId($code); // Check existence by CODE ALONE. Filtering on is_header here would miss an
if ($existing !== null) { // account that exists as a header and then try to insert it — which is
return $existing; // exactly how this failed on 230602 أوراق الدفع قصيرة الأجل.
$any = $db->selectOne(
"SELECT id, is_header, is_active FROM chart_of_accounts WHERE account_code = ?",
[$code]
);
if ($any) {
// It exists but cannot be posted to; the caller must pick another.
return ((int) $any['is_header'] === 0 && (int) $any['is_active'] === 1)
? (int) $any['id']
: null;
} }
$parent = $db->selectOne( $parent = $db->selectOne(
...@@ -78,7 +87,8 @@ return function (\App\Core\Database $db): void { ...@@ -78,7 +87,8 @@ return function (\App\Core\Database $db): void {
$bounceFeeRev = $accId('410511'); // مصاريف رفض شيكات — already exists $bounceFeeRev = $accId('410511'); // مصاريف رفض شيكات — already exists
$bankAccount = $accId('12060201'); // البنك الأهلي $bankAccount = $accId('12060201'); // البنك الأهلي
$cashAccount = $accId('12060101'); // الصندوق بالجنيه المصري $cashAccount = $accId('12060101'); // الصندوق بالجنيه المصري
$notesPayable = $ensureLeaf('230602', 'أوراق دفع — موردون', 'Notes Payable — Suppliers', '2306'); // 230602 أوراق الدفع قصيرة الأجل already exists as a header — the leaf goes under it.
$notesPayable = $ensureLeaf('23060201', 'أوراق دفع — موردون', 'Notes Payable — Suppliers', '230602');
// Endorsement hands the cheque to a creditor; default to trade payables. // Endorsement hands the cheque to a creditor; default to trade payables.
$endorsement = $accId('230601002') ?? $notesPayable; $endorsement = $accId('230601002') ?? $notesPayable;
......
<?php
declare(strict_types=1);
/**
* Starter voucher types, including the ones a finance review actually asks about.
*
* Each is just a row — the club adds "سند صرف كهرباء" from the screen the same way.
* The default expense account is attached where the chart already has a sensible
* one, and left null otherwise so the clerk picks it rather than posting somewhere
* wrong by default.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$accId = function (?string $code) use ($db): ?int {
if ($code === null) { return null; }
$r = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_header = 0 AND is_active = 1",
[$code]
);
return $r ? (int) $r['id'] : null;
};
$cash = $accId('12060101'); // الصندوق بالجنيه المصري
$bank = $accId('12060201'); // البنك
// [code, name, direction, prefix, counter, default line account]
$types = [
['PV_GENERAL', 'سند صرف عام', 'outflow', 'PV', $cash, null],
// Codes verified as postable leaves on the live chart — 3303/3304/3305 are
// headers and 3305 is stationery, not advertising.
['PV_ADS', 'سند صرف دعاية وإعلان', 'outflow', 'PV', $cash, '330702'],
['PV_MAINT', 'سند صرف صيانة', 'outflow', 'PV', $cash, '33061'],
['PV_UTILITIES', 'سند صرف كهرباء ومياه', 'outflow', 'PV', $cash, '330401'],
['PV_BANK', 'سند صرف بنكي', 'outflow', 'BP', $bank, null],
['RV_GENERAL', 'سند قبض عام', 'inflow', 'RV', $cash, null],
['RV_BANK', 'سند قبض بنكي', 'inflow', 'BR', $bank, null],
];
foreach ($types as [$code, $name, $direction, $prefix, $counter, $lineCode]) {
if ($db->selectOne("SELECT id FROM voucher_types WHERE code = ?", [$code])) {
continue;
}
$db->insert('voucher_types', [
'code' => $code,
'name_ar' => $name,
'direction' => $direction,
'number_prefix' => $prefix,
'default_counter_account_id' => $counter,
'default_line_account_id' => $accId($lineCode),
'requires_approval' => 0,
'requires_supplier' => 0,
'is_active' => 1,
'is_system' => 1,
'sort_order' => 100,
'created_at' => $now,
'updated_at' => $now,
]);
}
};
<?php
declare(strict_types=1);
/**
* Voucher permissions follow the journal-entry permissions, since a voucher is a
* friendlier way of writing the same entry.
*/
return function (\App\Core\Database $db): void {
$grants = [
'accounting.journal.view' => ['accounting.voucher.view'],
'accounting.journal.create' => ['accounting.voucher.view', 'accounting.voucher.create'],
'accounting.journal.post' => ['accounting.voucher.post', 'accounting.voucher.approve'],
'accounting.coa.manage' => ['accounting.voucher.manage'],
];
foreach ($grants as $sourceKey => $newKeys) {
foreach ($db->select("SELECT DISTINCT role_id FROM role_permissions WHERE permission_key = ?", [$sourceKey]) as $row) {
$roleId = (int) $row['role_id'];
foreach ($newKeys as $key) {
if ($db->selectOne("SELECT id FROM role_permissions WHERE role_id = ? AND permission_key = ?", [$roleId, $key])) {
continue;
}
$db->insert('role_permissions', [
'role_id' => $roleId,
'permission_key' => $key,
'granted_at' => date('Y-m-d H:i:s'),
]);
}
}
}
};
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