Commit 5e36f062 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): billing sources — a new revenue path needs a row, not a developer

Removes the "this module needs code" category rather than labelling it.

Most unbilled money in the ERP has one shape: a module writes a priced row into
its own table and never tells accounting. Wiring each module by hand means a
developer for every revenue path, forever — which is what I handed over last
time instead of solving it.

A billing source declares that shape as data: which table holds the money, which
column is the amount, which rows are still outstanding, who owes it, and how it
posts. One screen then lists every outstanding charge across every source and
collects it through PaymentService — the same funnel a member payment uses, so
it gets a receipt, treasury custody and a journal entry.

Seeded and working immediately: hourly court bookings, sports subscriptions,
lockers, facility reservations, private matches, rental invoices, annual member
subscriptions.

Edge cases handled deliberately:

- No free-text SQL anywhere. Filters are structured (column / operator / value)
  rendered into prepared statements; a settings screen that accepted a WHERE
  clause would be an injection hole. Identifiers are matched against
  information_schema and a strict pattern before interpolation.
- Every source is re-validated on save AND before every listing, because a
  migration can drop a column underneath a source that was fine yesterday. An
  invalid source is shown as broken instead of silently returning nothing.
- The amount is re-read from the source row at collection time, never trusted
  from the form, so a stale list or a tampered field cannot set the charge.
- Double-collection is blocked by our own billing_source_collections table
  rather than the module's paid flag — some sources have no write-back column at
  all, and a module can overwrite its own flag. The check is repeated at collect
  time to cover the gap between listing and click.
- Partial collection only where the source allows it, never above the row total.
- A player is not a member: a member_id that members does not have is dropped
  rather than tripping the payment foreign key.
- Write-back is best-effort and isolated — a missing column must not undo a real
  payment, so the failure is logged and the receipt stands.
- Zero and negative rows are excluded; an empty IN () renders as a false
  predicate rather than a syntax error.
- Payer names resolve in two queries, not two per row.
- A source with collections against it deactivates instead of deleting, because
  those rows are the audit trail for real money.

Permission keys were read off role_permissions rather than assumed —
payment.create does not exist in this install.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 14e892ff
<?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\Revenue\BillingSourceService as B;
/**
* المطالبات — one screen that collects any outstanding charge in the ERP,
* and a settings screen that declares new ones without a developer.
*/
class BillingController extends Controller
{
// ────────────────────────────────────────────────────────────
// Collection
// ────────────────────────────────────────────────────────────
public function index(Request $request): Response
{
$this->authorize('accounting.billing.view');
$db = App::getInstance()->db();
$sources = $db->select(
"SELECT * FROM billing_sources WHERE is_active = 1 ORDER BY sort_order, name_ar"
);
$selected = (int) $request->get('source', 0);
$search = trim((string) $request->get('q', ''));
$page = max(1, (int) $request->get('page', 1));
$perPage = 50;
// Headline per source, so the tabs carry the number without a click.
$summary = [];
foreach ($sources as $s) {
$out = B::outstanding($s, 1, 0);
$summary[(int) $s['id']] = [
'count' => $out['count'],
'total' => $out['total'],
'error' => $out['error'],
];
}
if ($selected === 0 && !empty($sources)) {
// Open on whichever source actually has money waiting.
$best = null;
foreach ($sources as $s) {
$id = (int) $s['id'];
if ($summary[$id]['count'] > 0 && ($best === null || $summary[$id]['count'] > $summary[$best]['count'])) {
$best = $id;
}
}
$selected = $best ?? (int) $sources[0]['id'];
}
$current = null;
foreach ($sources as $s) {
if ((int) $s['id'] === $selected) {
$current = $s;
}
}
$rows = [];
$total = '0.00';
$count = 0;
$error = null;
if ($current) {
$out = B::outstanding($current, $perPage, ($page - 1) * $perPage, $search);
$rows = $out['rows'];
$total = $out['total'];
$count = $out['count'];
$error = $out['error'];
}
return $this->view('Accounting.Views.billing.index', [
'sources' => $sources,
'summary' => $summary,
'current' => $current,
'rows' => $rows,
'total' => $total,
'count' => $count,
'error' => $error,
'search' => $search,
'page' => $page,
'perPage' => $perPage,
'treasuries'=> $db->select("SELECT id, name_ar FROM treasuries WHERE is_active = 1 ORDER BY name_ar"),
]);
}
public function collect(Request $request): Response
{
$this->authorize('accounting.billing.collect');
$sourceId = (int) $request->post('source_id', 0);
$rowId = (int) $request->post('row_id', 0);
$back = '/accounting/billing?source=' . $sourceId;
if ($sourceId <= 0 || $rowId <= 0) {
return $this->redirect($back)->withError('بيانات غير مكتملة');
}
$result = B::collect($sourceId, $rowId, [
'amount' => $request->post('amount'),
'payment_method' => $request->post('payment_method', 'cash'),
'treasury_id' => $request->post('treasury_id') ?: null,
'check_number' => $request->post('check_number'),
'check_bank' => $request->post('check_bank'),
'check_date' => $request->post('check_date'),
'visa_reference' => $request->post('visa_reference'),
'transfer_reference' => $request->post('transfer_reference'),
]);
if (!$result['success']) {
return $this->redirect($back)->withError($result['error'] ?? 'فشل التحصيل');
}
return $this->redirect($back)->withSuccess(
'تم التحصيل — إيصال ' . ($result['receipt_number'] ?? '') . ' بمبلغ ' . money($result['amount'])
);
}
// ────────────────────────────────────────────────────────────
// Settings — declare a new source
// ────────────────────────────────────────────────────────────
public function sources(Request $request): Response
{
$this->authorize('accounting.billing.manage');
$db = App::getInstance()->db();
$sources = $db->select("SELECT * FROM billing_sources ORDER BY sort_order, name_ar");
// Health is cheap to recheck and expensive to get wrong.
foreach ($sources as &$s) {
$check = B::validate($s);
$s['live_ok'] = $check['ok'];
$s['live_error'] = $check['error'];
}
unset($s);
return $this->view('Accounting.Views.billing.sources', [
'sources' => $sources,
'tables' => B::candidateTables(),
]);
}
public function editSource(Request $request, string $id): Response
{
$this->authorize('accounting.billing.manage');
$db = App::getInstance()->db();
$source = (int) $id > 0
? $db->selectOne("SELECT * FROM billing_sources WHERE id = ?", [(int) $id])
: null;
if ((int) $id > 0 && !$source) {
return $this->redirect('/accounting/billing/sources')->withError('المصدر غير موجود');
}
$columns = $source ? B::tableColumns($source['source_table']) : [];
return $this->view('Accounting.Views.billing.source_form', [
'source' => $source,
'columns' => $columns,
'tables' => B::candidateTables(),
'streams' => $db->select("SELECT stream_code, name_ar FROM revenue_streams WHERE is_active = 1 ORDER BY name_ar"),
'paymentTypes' => $db->select("SELECT DISTINCT payment_type FROM payments ORDER BY payment_type"),
'operators' => B::OPERATORS,
]);
}
public function saveSource(Request $request, string $id): Response
{
$this->authorize('accounting.billing.manage');
$db = App::getInstance()->db();
$sourceId = (int) $id;
$isNew = $sourceId === 0;
$code = strtolower(trim((string) $request->post('code', '')));
$code = preg_replace('/[^a-z0-9_]/', '_', $code) ?? '';
if ($code === '') {
return $this->redirect('/accounting/billing/sources/' . $sourceId . '/edit')
->withError('الكود مطلوب');
}
$dup = $db->selectOne(
"SELECT id FROM billing_sources WHERE code = ? AND id <> ?",
[$code, $sourceId]
);
if ($dup) {
return $this->redirect('/accounting/billing/sources/' . $sourceId . '/edit')
->withError('الكود مستخدم بالفعل');
}
// Structured conditions only — never a WHERE clause from a form.
$conditions = [];
$condCols = (array) $request->post('cond_column', []);
$condOps = (array) $request->post('cond_op', []);
$condVals = (array) $request->post('cond_value', []);
foreach ($condCols as $i => $col) {
$col = trim((string) $col);
$op = (string) ($condOps[$i] ?? '=');
if ($col === '' || !\in_array($op, B::OPERATORS, true)) {
continue;
}
$raw = trim((string) ($condVals[$i] ?? ''));
$value = null;
if (\in_array($op, ['in', 'not_in'], true)) {
$value = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn($v) => $v !== ''));
} elseif (!\in_array($op, ['is_null', 'is_not_null'], true)) {
$value = $raw;
}
$conditions[] = ['column' => $col, 'op' => $op, 'value' => $value];
}
$nullable = static function ($v) {
$v = trim((string) $v);
return $v === '' ? null : $v;
};
$data = [
'code' => $code,
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => $nullable($request->post('name_en')),
'description_ar' => $nullable($request->post('description_ar')),
'source_table' => trim((string) $request->post('source_table', '')),
'id_column' => trim((string) $request->post('id_column', 'id')),
'amount_column' => trim((string) $request->post('amount_column', '')),
'date_column' => $nullable($request->post('date_column')),
'reference_column' => $nullable($request->post('reference_column')),
'member_column' => $nullable($request->post('member_column')),
'player_column' => $nullable($request->post('player_column')),
'name_column' => $nullable($request->post('name_column')),
'conditions' => json_encode($conditions, JSON_UNESCAPED_UNICODE),
'writeback_payment_column' => $nullable($request->post('writeback_payment_column')),
'writeback_receipt_column' => $nullable($request->post('writeback_receipt_column')),
'writeback_status_column' => $nullable($request->post('writeback_status_column')),
'writeback_status_value' => $nullable($request->post('writeback_status_value')),
'writeback_paid_at_column' => $nullable($request->post('writeback_paid_at_column')),
'stream_code' => $nullable($request->post('stream_code')),
'payment_type' => trim((string) $request->post('payment_type', 'other')),
'allow_partial' => (int) $request->post('allow_partial', 0),
'sort_order' => (int) $request->post('sort_order', 100),
'is_active' => (int) $request->post('is_active', 1),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($data['name_ar'] === '') {
return $this->redirect('/accounting/billing/sources/' . $sourceId . '/edit')->withError('الاسم مطلوب');
}
// Prove it works against the live schema before it is allowed to exist.
$check = B::validate($data);
if (!$check['ok']) {
return $this->redirect('/accounting/billing/sources/' . $sourceId . '/edit')
->withError('الإعداد غير صالح: ' . $check['error']);
}
$data['validation_status'] = 'ok';
$data['validation_error'] = null;
$data['last_validated_at'] = date('Y-m-d H:i:s');
$employee = App::getInstance()->currentEmployee();
if ($isNew) {
$data['created_at'] = date('Y-m-d H:i:s');
$data['created_by'] = $employee ? (int) $employee->id : null;
$sourceId = $db->insert('billing_sources', $data);
} else {
$data['updated_by'] = $employee ? (int) $employee->id : null;
$db->update('billing_sources', $data, '`id` = ?', [$sourceId]);
}
return $this->redirect('/accounting/billing/sources')
->withSuccess('تم حفظ مصدر المطالبة — جرّبه من شاشة المطالبات');
}
public function deleteSource(Request $request, string $id): Response
{
$this->authorize('accounting.billing.manage');
$db = App::getInstance()->db();
$sourceId = (int) $id;
$src = $db->selectOne("SELECT * FROM billing_sources WHERE id = ?", [$sourceId]);
if (!$src) {
return $this->redirect('/accounting/billing/sources')->withError('المصدر غير موجود');
}
// Collections are the audit trail for real money. Deactivate, never delete.
$used = $db->selectOne(
"SELECT COUNT(*) AS n FROM billing_source_collections WHERE billing_source_id = ?",
[$sourceId]
);
if ((int) ($used['n'] ?? 0) > 0) {
$db->update('billing_sources', ['is_active' => 0], '`id` = ?', [$sourceId]);
return $this->redirect('/accounting/billing/sources')
->withWarning('المصدر عليه ' . (int) $used['n'] . ' عملية تحصيل — تم إيقافه بدل حذفه للحفاظ على السجل');
}
$db->delete('billing_sources', '`id` = ?', [$sourceId]);
return $this->redirect('/accounting/billing/sources')->withSuccess('تم حذف المصدر');
}
/** Column list for the settings form, fetched as the table is picked. */
public function tableColumns(Request $request): Response
{
$this->authorize('accounting.billing.manage');
return $this->json(['columns' => B::tableColumns((string) $request->get('table', ''))]);
}
/** Dry-run a configuration before saving it. */
public function previewSource(Request $request): Response
{
$this->authorize('accounting.billing.manage');
$nullable = static function ($v) {
$v = trim((string) $v);
return $v === '' ? null : $v;
};
$conditions = [];
foreach ((array) $request->input('cond_column', []) as $i => $col) {
$col = trim((string) $col);
$op = (string) (((array) $request->input('cond_op', []))[$i] ?? '=');
if ($col === '' || !\in_array($op, B::OPERATORS, true)) {
continue;
}
$raw = trim((string) (((array) $request->input('cond_value', []))[$i] ?? ''));
$value = \in_array($op, ['in', 'not_in'], true)
? array_values(array_filter(array_map('trim', explode(',', $raw)), static fn($v) => $v !== ''))
: (\in_array($op, ['is_null', 'is_not_null'], true) ? null : $raw);
$conditions[] = ['column' => $col, 'op' => $op, 'value' => $value];
}
$draft = [
'id' => 0,
'source_table' => trim((string) $request->input('source_table', '')),
'id_column' => trim((string) $request->input('id_column', 'id')),
'amount_column' => trim((string) $request->input('amount_column', '')),
'date_column' => $nullable($request->input('date_column')),
'reference_column' => $nullable($request->input('reference_column')),
'member_column' => $nullable($request->input('member_column')),
'player_column' => $nullable($request->input('player_column')),
'name_column' => $nullable($request->input('name_column')),
'conditions' => $conditions,
];
$check = B::validate($draft);
if (!$check['ok']) {
return $this->json(['success' => false, 'error' => $check['error']]);
}
$out = B::outstanding($draft, 10, 0);
return $this->json([
'success' => $out['error'] === null,
'error' => $out['error'],
'count' => $out['count'],
'total' => $out['total'],
'rows' => array_slice($out['rows'], 0, 10),
]);
}
}
...@@ -161,6 +161,16 @@ return [ ...@@ -161,6 +161,16 @@ return [
['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'], ['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@update', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'], ['POST', '/accounting/revenue-mapping/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@update', ['auth', 'csrf'], 'accounting.revenue_mapping.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'],
['GET', '/accounting/billing/sources', 'Accounting\Controllers\BillingController@sources', ['auth'], 'accounting.billing.manage'],
['GET', '/accounting/billing/table-columns', 'Accounting\Controllers\BillingController@tableColumns', ['auth'], 'accounting.billing.manage'],
['POST', '/accounting/billing/sources/preview', 'Accounting\Controllers\BillingController@previewSource', ['auth', 'csrf'], 'accounting.billing.manage'],
['GET', '/accounting/billing/sources/{id:\d+}/edit', 'Accounting\Controllers\BillingController@editSource', ['auth'], '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'],
// ── 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\Revenue;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Payments\Services\PaymentService;
/**
* Reads outstanding charges out of any module's own table and collects them through
* the normal payment funnel — so a new revenue path needs a configuration row, not
* a developer.
*
* Everything here is defensive on purpose: the configuration is user-supplied and
* names database objects, so every identifier is checked against information_schema
* before it is ever interpolated, and every value goes through a prepared statement.
*/
final class BillingSourceService
{
/** Conditions the settings screen may express. Anything else is rejected. */
public const OPERATORS = ['=', '!=', '>', '<', '>=', '<=', 'in', 'not_in', 'is_null', 'is_not_null'];
private const IDENT = '/^[A-Za-z_][A-Za-z0-9_]{0,63}$/';
// ────────────────────────────────────────────────────────────────────
// Validation
// ────────────────────────────────────────────────────────────────────
/**
* Confirm every table and column a source names actually exists, with the right
* shape. Run on save and before every listing — a source can become invalid
* without anyone touching it, because a migration can drop a column underneath it.
*
* @return array{ok:bool, error:?string}
*/
public static function validate(array $src): array
{
$db = App::getInstance()->db();
$table = (string) ($src['source_table'] ?? '');
if (!preg_match(self::IDENT, $table)) {
return ['ok' => false, 'error' => 'اسم الجدول غير صالح'];
}
$cols = $db->select(
"SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ?",
[$table]
);
if (empty($cols)) {
return ['ok' => false, 'error' => 'الجدول «' . $table . '» غير موجود'];
}
$types = [];
foreach ($cols as $c) {
$types[strtolower((string) $c['column_name'])] = strtolower((string) $c['data_type']);
}
$required = ['id_column', 'amount_column'];
$optional = [
'date_column', 'reference_column', 'member_column', 'player_column', 'name_column',
'writeback_payment_column', 'writeback_receipt_column', 'writeback_status_column',
'writeback_paid_at_column',
];
foreach ($required as $key) {
$col = (string) ($src[$key] ?? '');
if (!preg_match(self::IDENT, $col) || !isset($types[strtolower($col)])) {
return ['ok' => false, 'error' => 'العمود المطلوب «' . $col . '» غير موجود في ' . $table];
}
}
foreach ($optional as $key) {
$col = trim((string) ($src[$key] ?? ''));
if ($col === '') {
continue;
}
if (!preg_match(self::IDENT, $col) || !isset($types[strtolower($col)])) {
return ['ok' => false, 'error' => 'العمود «' . $col . '» غير موجود في ' . $table];
}
}
// The amount has to be arithmetic, or SUM and comparisons lie.
$amountType = $types[strtolower((string) $src['amount_column'])] ?? '';
if (!\in_array($amountType, ['decimal', 'int', 'bigint', 'float', 'double', 'smallint', 'mediumint', 'numeric'], true)) {
return ['ok' => false, 'error' => 'عمود المبلغ من نوع ' . $amountType . ' وليس رقميًا'];
}
foreach (self::decodeConditions($src['conditions'] ?? null) as $i => $cond) {
$col = (string) ($cond['column'] ?? '');
$op = (string) ($cond['op'] ?? '');
if (!preg_match(self::IDENT, $col) || !isset($types[strtolower($col)])) {
return ['ok' => false, 'error' => 'شرط رقم ' . ($i + 1) . ': العمود «' . $col . '» غير موجود'];
}
if (!\in_array($op, self::OPERATORS, true)) {
return ['ok' => false, 'error' => 'شرط رقم ' . ($i + 1) . ': عامل مقارنة غير مدعوم'];
}
if (\in_array($op, ['in', 'not_in'], true) && !\is_array($cond['value'] ?? null)) {
return ['ok' => false, 'error' => 'شرط رقم ' . ($i + 1) . ': «ضمن» يحتاج قائمة قيم'];
}
}
// A charge nobody owes cannot be collected against anyone.
$hasPayer = !empty($src['member_column']) || !empty($src['player_column']) || !empty($src['name_column']);
if (!$hasPayer) {
return ['ok' => false, 'error' => 'حدد عمودًا واحدًا على الأقل يعرّف الدافع (عضو / لاعب / اسم)'];
}
return ['ok' => true, 'error' => null];
}
/** Re-validate and persist the health flag. */
public static function revalidate(int $sourceId): array
{
$db = App::getInstance()->db();
$src = $db->selectOne("SELECT * FROM billing_sources WHERE id = ?", [$sourceId]);
if (!$src) {
return ['ok' => false, 'error' => 'المصدر غير موجود'];
}
$result = self::validate($src);
$db->update('billing_sources', [
'validation_status' => $result['ok'] ? 'ok' : 'invalid',
'validation_error' => $result['error'],
'last_validated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$sourceId]);
return $result;
}
// ────────────────────────────────────────────────────────────────────
// Reading outstanding charges
// ────────────────────────────────────────────────────────────────────
/**
* Outstanding rows for one source.
*
* Excludes anything already collected through this service, independently of the
* module's own paid flag — a source may have no write-back column at all, and a
* module can overwrite its own flag. billing_source_collections is the record we
* control, so it is the one that decides.
*
* @return array{rows:array, total:string, count:int, error:?string}
*/
public static function outstanding(array $src, int $limit = 200, int $offset = 0, string $search = ''): array
{
$db = App::getInstance()->db();
$check = self::validate($src);
if (!$check['ok']) {
return ['rows' => [], 'total' => '0.00', 'count' => 0, 'error' => $check['error']];
}
$t = $src['source_table'];
$id = $src['id_column'];
$amt = $src['amount_column'];
$select = ["s.`{$id}` AS __id", "s.`{$amt}` AS __amount"];
foreach ([
'date_column' => '__date',
'reference_column' => '__reference',
'member_column' => '__member_id',
'player_column' => '__player_id',
'name_column' => '__payer_name',
] as $key => $alias) {
$col = trim((string) ($src[$key] ?? ''));
$select[] = $col !== '' ? "s.`{$col}` AS {$alias}" : "NULL AS {$alias}";
}
[$where, $params] = self::buildConditions($src);
// Never offer a row that is already collected.
$where[] = "NOT EXISTS (
SELECT 1 FROM billing_source_collections c
WHERE c.billing_source_id = ? AND c.source_row_id = s.`{$id}` AND c.status = 'collected'
)";
$params[] = (int) $src['id'];
// A zero or negative charge is not a charge.
$where[] = "s.`{$amt}` > 0";
if ($search !== '') {
$searchable = [];
foreach (['reference_column', 'name_column'] as $key) {
$col = trim((string) ($src[$key] ?? ''));
if ($col !== '') {
$searchable[] = "s.`{$col}` LIKE ?";
}
}
if ($searchable) {
$where[] = '(' . implode(' OR ', $searchable) . ')';
foreach ($searchable as $_) {
$params[] = '%' . $search . '%';
}
}
}
$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$orderCol = trim((string) ($src['date_column'] ?? '')) !== '' ? $src['date_column'] : $id;
try {
$totals = $db->selectOne(
"SELECT COUNT(*) AS n, COALESCE(SUM(s.`{$amt}`), 0) AS total FROM `{$t}` s{$whereSql}",
$params
);
$rows = $db->select(
"SELECT " . implode(', ', $select) . "
FROM `{$t}` s{$whereSql}
ORDER BY s.`{$orderCol}` DESC
LIMIT {$limit} OFFSET {$offset}",
$params
);
} catch (\Throwable $e) {
return ['rows' => [], 'total' => '0.00', 'count' => 0, 'error' => $e->getMessage()];
}
// Attach payer names without N+1 on the common case.
self::attachPayerNames($rows);
return [
'rows' => $rows,
'total' => (string) ($totals['total'] ?? '0.00'),
'count' => (int) ($totals['n'] ?? 0),
'error' => null,
];
}
// ────────────────────────────────────────────────────────────────────
// Collecting
// ────────────────────────────────────────────────────────────────────
/**
* Collect one outstanding row.
*
* The amount is re-read from the source row inside the call rather than trusted
* from the screen, so a stale list or a tampered form cannot decide what gets
* charged. The already-collected check is repeated here too, because the listing
* and the click are separated by however long the cashier took.
*
* @return array{success:bool, error?:string, payment_id?:int, receipt_number?:string}
*/
public static function collect(int $sourceId, int $rowId, array $options = []): array
{
$db = App::getInstance()->db();
$src = $db->selectOne("SELECT * FROM billing_sources WHERE id = ? AND is_active = 1", [$sourceId]);
if (!$src) {
return ['success' => false, 'error' => 'مصدر المطالبة غير موجود أو موقوف'];
}
$check = self::validate($src);
if (!$check['ok']) {
return ['success' => false, 'error' => 'إعداد المصدر غير صالح: ' . $check['error']];
}
$t = $src['source_table'];
$id = $src['id_column'];
$amt = $src['amount_column'];
$row = $db->selectOne("SELECT * FROM `{$t}` WHERE `{$id}` = ?", [$rowId]);
if (!$row) {
return ['success' => false, 'error' => 'السجل غير موجود'];
}
// Race guard: someone else may have collected this while the list was open.
$already = $db->selectOne(
"SELECT id, receipt_number FROM billing_source_collections
WHERE billing_source_id = ? AND source_row_id = ? AND status = 'collected'",
[$sourceId, $rowId]
);
if ($already) {
return [
'success' => false,
'error' => 'تم تحصيل هذا السجل بالفعل — إيصال ' . ($already['receipt_number'] ?? '#' . $already['id']),
];
}
$fullAmount = number_format((float) ($row[$amt] ?? 0), 2, '.', '');
if (bccomp($fullAmount, '0.01', 2) < 0) {
return ['success' => false, 'error' => 'المبلغ في السجل صفر أو أقل'];
}
// Partial collection only where the source allows it, never above the total.
$amount = $fullAmount;
if (isset($options['amount']) && $options['amount'] !== '' && $options['amount'] !== null) {
$requested = number_format((float) $options['amount'], 2, '.', '');
if (bccomp($requested, $fullAmount, 2) > 0) {
return ['success' => false, 'error' => 'المبلغ المطلوب أكبر من قيمة السجل (' . $fullAmount . ')'];
}
if (bccomp($requested, $fullAmount, 2) !== 0 && (int) $src['allow_partial'] !== 1) {
return ['success' => false, 'error' => 'هذا المصدر لا يسمح بالتحصيل الجزئي'];
}
if (bccomp($requested, '0.01', 2) < 0) {
return ['success' => false, 'error' => 'المبلغ يجب أن يكون أكبر من صفر'];
}
$amount = $requested;
}
$memberId = null;
if (!empty($src['member_column']) && !empty($row[$src['member_column']])) {
$memberId = (int) $row[$src['member_column']];
}
// A player is not a member. Only pass a member_id the members table knows,
// otherwise the payment insert trips its foreign key.
if ($memberId !== null) {
$exists = $db->selectOne("SELECT id FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$exists) {
$memberId = null;
}
}
$payerName = null;
if (!empty($src['name_column']) && !empty($row[$src['name_column']])) {
$payerName = (string) $row[$src['name_column']];
}
if ($payerName === null && $memberId === null && !empty($src['player_column']) && !empty($row[$src['player_column']])) {
$player = $db->selectOne(
"SELECT full_name_ar FROM sa_players WHERE id = ?",
[(int) $row[$src['player_column']]]
);
$payerName = $player['full_name_ar'] ?? null;
}
$reference = !empty($src['reference_column']) ? (string) ($row[$src['reference_column']] ?? '') : '';
$description = $src['name_ar'] . ($reference !== '' ? ' — ' . $reference : ' #' . $rowId);
$result = PaymentService::processPayment([
'member_id' => $memberId,
'amount' => $amount,
'payment_type' => $src['payment_type'],
'payment_method' => $options['payment_method'] ?? 'cash',
'description' => $description,
'guest_name' => $memberId === null ? $payerName : null,
'treasury_id' => $options['treasury_id'] ?? null,
'session_id' => $options['session_id'] ?? null,
'related_entity_type' => $src['source_table'],
'related_entity_id' => $rowId,
'check_number' => $options['check_number'] ?? null,
'check_bank' => $options['check_bank'] ?? null,
'check_date' => $options['check_date'] ?? null,
'visa_reference' => $options['visa_reference'] ?? null,
'transfer_reference' => $options['transfer_reference'] ?? null,
]);
if (!($result['success'] ?? false)) {
return ['success' => false, 'error' => $result['error'] ?? 'فشل تسجيل الدفع'];
}
$paymentId = (int) $result['payment_id'];
$employee = App::getInstance()->currentEmployee();
// Record OUR trail first — if the module write-back fails we must still know
// the money was taken.
$db->insert('billing_source_collections', [
'billing_source_id' => $sourceId,
'source_row_id' => $rowId,
'amount' => $amount,
'payment_id' => $paymentId,
'receipt_id' => $result['receipt_id'] ?? null,
'receipt_number' => $result['receipt_number'] ?? null,
'member_id' => $memberId,
'payer_name' => $payerName,
'status' => 'collected',
'collected_at' => date('Y-m-d H:i:s'),
'collected_by' => $employee ? (int) $employee->id : null,
]);
// Write back into the module's own row so its screens agree with accounting.
// Best-effort and isolated: a missing column here must not undo a real payment.
try {
$writeback = [];
if (!empty($src['writeback_payment_column'])) {
$writeback[$src['writeback_payment_column']] = $paymentId;
}
if (!empty($src['writeback_receipt_column']) && !empty($result['receipt_id'])) {
$writeback[$src['writeback_receipt_column']] = (int) $result['receipt_id'];
}
if (!empty($src['writeback_status_column']) && $src['writeback_status_value'] !== null) {
$writeback[$src['writeback_status_column']] = $src['writeback_status_value'];
}
if (!empty($src['writeback_paid_at_column'])) {
$writeback[$src['writeback_paid_at_column']] = date('Y-m-d H:i:s');
}
if ($writeback) {
$db->update($t, $writeback, "`{$id}` = ?", [$rowId]);
}
} catch (\Throwable $e) {
Logger::error('Billing source write-back failed (payment stands)', [
'source' => $src['code'], 'row' => $rowId, 'payment_id' => $paymentId,
'error' => $e->getMessage(),
]);
}
return [
'success' => true,
'payment_id' => $paymentId,
'receipt_number' => $result['receipt_number'] ?? '',
'amount' => $amount,
];
}
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
/** @return array{0:array<int,string>, 1:array<int,mixed>} */
private static function buildConditions(array $src): array
{
$where = [];
$params = [];
foreach (self::decodeConditions($src['conditions'] ?? null) as $cond) {
$col = (string) ($cond['column'] ?? '');
$op = (string) ($cond['op'] ?? '');
if (!preg_match(self::IDENT, $col) || !\in_array($op, self::OPERATORS, true)) {
continue; // validate() already reported it; do not silently widen the set
}
switch ($op) {
case 'is_null':
$where[] = "s.`{$col}` IS NULL";
break;
case 'is_not_null':
$where[] = "s.`{$col}` IS NOT NULL";
break;
case 'in':
case 'not_in':
$vals = array_values((array) ($cond['value'] ?? []));
if (empty($vals)) {
// An empty IN () is a syntax error; an empty NOT IN () matches all.
$where[] = $op === 'in' ? '1 = 0' : '1 = 1';
break;
}
$ph = implode(',', array_fill(0, count($vals), '?'));
$where[] = "s.`{$col}` " . ($op === 'in' ? 'IN' : 'NOT IN') . " ({$ph})";
foreach ($vals as $v) {
$params[] = $v;
}
break;
default:
$where[] = "s.`{$col}` {$op} ?";
$params[] = $cond['value'] ?? null;
}
}
return [$where, $params];
}
private static function decodeConditions($raw): array
{
if (is_array($raw)) {
return $raw;
}
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
/** Resolve member / player ids to names in two queries, not two per row. */
private static function attachPayerNames(array &$rows): void
{
if (empty($rows)) {
return;
}
$db = App::getInstance()->db();
$memberIds = array_values(array_unique(array_filter(array_column($rows, '__member_id'))));
$playerIds = array_values(array_unique(array_filter(array_column($rows, '__player_id'))));
$members = [];
if ($memberIds) {
$ph = implode(',', array_fill(0, count($memberIds), '?'));
foreach ($db->select("SELECT id, full_name_ar FROM members WHERE id IN ({$ph})", $memberIds) as $m) {
$members[(int) $m['id']] = $m['full_name_ar'];
}
}
$players = [];
if ($playerIds) {
$ph = implode(',', array_fill(0, count($playerIds), '?'));
try {
foreach ($db->select("SELECT id, full_name_ar FROM sa_players WHERE id IN ({$ph})", $playerIds) as $p) {
$players[(int) $p['id']] = $p['full_name_ar'];
}
} catch (\Throwable $e) {
// sa_players may not exist on a trimmed install — names stay blank.
}
}
foreach ($rows as &$r) {
$r['__payer'] = $r['__payer_name']
?? ($members[(int) ($r['__member_id'] ?? 0)] ?? null)
?? ($players[(int) ($r['__player_id'] ?? 0)] ?? null)
?? '—';
}
unset($r);
}
/** Tables a source could reasonably be built on — for the settings picker. */
public static function candidateTables(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT DISTINCT t.table_name AS name, t.table_rows AS rows_estimate
FROM information_schema.tables t
JOIN information_schema.columns c
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
WHERE t.table_schema = DATABASE()
AND t.table_type = 'BASE TABLE'
AND c.data_type IN ('decimal','float','double','numeric')
ORDER BY t.table_name"
);
}
/** Columns of a table, for the settings picker. */
public static function tableColumns(string $table): array
{
if (!preg_match(self::IDENT, $table)) {
return [];
}
$db = App::getInstance()->db();
return $db->select(
"SELECT column_name AS name, data_type AS type, is_nullable AS nullable
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ?
ORDER BY ordinal_position",
[$table]
);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المطالبات والتحصيل<?php $__template->endSection(); ?>
<?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:680px;">
كل مبلغ مستحق في أي وحدة في النظام، في مكان واحد. التحصيل من هنا بيمرّ على
نفس مسار دفعة العضو — إيصال، وعهدة خزنة، وقيد في الدفاتر.
</p>
</div>
<?php if (can('accounting.billing.manage')): ?>
<a href="/accounting/billing/sources" class="btn btn-outline">إعداد مصادر المطالبة</a>
<?php endif; ?>
</div>
<?php if (empty($sources)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
لا توجد مصادر مطالبة معرّفة.
<?php if (can('accounting.billing.manage')): ?>
<div style="margin-top:12px;"><a href="/accounting/billing/sources/0/edit" class="btn btn-primary">عرّف مصدرًا جديدًا</a></div>
<?php endif; ?>
</div>
<?php else: ?>
<!-- Source tabs, each carrying its own outstanding total -->
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:18px;">
<?php foreach ($sources as $s): ?>
<?php
$sid = (int) $s['id'];
$sum = $summary[$sid] ?? ['count' => 0, 'total' => '0.00', 'error' => null];
$active = $current && (int) $current['id'] === $sid;
$broken = $sum['error'] !== null;
?>
<a href="/accounting/billing?source=<?= $sid ?>" style="text-decoration:none;">
<div class="card" style="padding:12px 14px;<?= $active ? 'border:2px solid #1F5FA8;' : '' ?><?= $broken ? 'opacity:.6;' : '' ?>">
<div style="font-size:12.5px;font-weight:600;color:<?= $active ? '#1F5FA8' : '#374151' ?>;margin-bottom:6px;">
<?= e($s['name_ar']) ?>
</div>
<?php if ($broken): ?>
<div style="font-size:11px;color:#DC2626;">إعداد غير صالح</div>
<?php else: ?>
<div style="font-size:18px;font-weight:700;color:<?= $sum['count'] > 0 ? '#B45309' : '#059669' ?>;">
<?= money($sum['total']) ?>
</div>
<div style="font-size:11px;color:#6B7280;"><?= number_format($sum['count']) ?> مستحق</div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($current): ?>
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
<div>
<h3 style="margin:0;font-size:15px;"><?= e($current['name_ar']) ?></h3>
<?php if (!empty($current['description_ar'])): ?>
<div style="font-size:12px;color:#6B7280;margin-top:3px;"><?= e($current['description_ar']) ?></div>
<?php endif; ?>
<div style="font-size:11px;color:#9CA3AF;margin-top:3px;direction:ltr;text-align:right;">
<?= e($current['source_table']) ?>.<?= e($current['amount_column']) ?>
</div>
</div>
<form method="GET" action="/accounting/billing" style="display:flex;gap:8px;align-items:center;">
<input type="hidden" name="source" value="<?= (int) $current['id'] ?>">
<input type="text" name="q" class="form-input" value="<?= e($search) ?>" placeholder="بحث" style="width:180px;">
<button type="submit" class="btn btn-sm btn-outline">بحث</button>
</form>
</div>
<?php if ($error !== null): ?>
<div style="padding:20px;background:#FEF2F2;color:#991B1B;font-size:13px;">
تعذّر قراءة هذا المصدر: <?= e($error) ?>
<?php if (can('accounting.billing.manage')): ?>
<a href="/accounting/billing/sources/<?= (int) $current['id'] ?>/edit" style="margin-inline-start:8px;">تصحيح الإعداد</a>
<?php endif; ?>
</div>
<?php elseif (empty($rows)): ?>
<div style="padding:40px;text-align:center;color:#059669;">لا توجد مستحقات غير محصّلة ✓</div>
<?php else: ?>
<div style="padding:10px 18px;background:#FFFBEB;border-bottom:1px solid #FDE68A;font-size:12.5px;color:#92400E;">
<strong><?= number_format($count) ?></strong> مستحق بإجمالي <strong><?= money($total) ?></strong>
<?php if ($count > count($rows)): ?>
— معروض <?= count($rows) ?>
<?php endif; ?>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:14%;">المرجع</th>
<th style="width:26%;">الدافع</th>
<th style="width:14%;">التاريخ</th>
<th style="width:16%;">المبلغ</th>
<th style="width:30%;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td style="direction:ltr;text-align:right;font-size:12px;">
<?= e((string) ($r['__reference'] ?? ('#' . $r['__id']))) ?>
</td>
<td><?= e((string) ($r['__payer'] ?? '—')) ?></td>
<td style="font-size:12px;color:#6B7280;">
<?= e($r['__date'] !== null ? substr((string) $r['__date'], 0, 10) : '—') ?>
</td>
<td style="font-weight:600;"><?= money($r['__amount']) ?></td>
<td style="text-align:left;">
<?php if (can('accounting.billing.collect')): ?>
<button type="button" class="btn btn-sm btn-primary collect-btn"
data-row="<?= (int) $r['__id'] ?>"
data-amount="<?= e(number_format((float) $r['__amount'], 2, '.', '')) ?>"
data-payer="<?= e((string) ($r['__payer'] ?? '')) ?>"
data-ref="<?= e((string) ($r['__reference'] ?? ('#' . $r['__id']))) ?>">
تحصيل
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($count > $perPage): ?>
<div style="padding:12px 18px;display:flex;gap:8px;justify-content:center;">
<?php $pages = (int) ceil($count / $perPage); ?>
<?php if ($page > 1): ?>
<a href="/accounting/billing?source=<?= (int) $current['id'] ?>&q=<?= e($search) ?>&page=<?= $page - 1 ?>" class="btn btn-sm btn-outline">السابق</a>
<?php endif; ?>
<span style="font-size:12px;color:#6B7280;align-self:center;">صفحة <?= $page ?> من <?= $pages ?></span>
<?php if ($page < $pages): ?>
<a href="/accounting/billing?source=<?= (int) $current['id'] ?>&q=<?= e($search) ?>&page=<?= $page + 1 ?>" class="btn btn-sm btn-outline">التالي</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- ══════════ Collect modal ══════════ -->
<?php if (can('accounting.billing.collect') && $current): ?>
<div id="collect-modal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:900;align-items:center;justify-content:center;padding:16px;">
<div class="card" style="max-width:480px;width:100%;">
<form method="POST" action="/accounting/billing/collect">
<?= csrf_field() ?>
<input type="hidden" name="source_id" value="<?= (int) $current['id'] ?>">
<input type="hidden" name="row_id" id="m-row">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;">تحصيل مستحق</h3>
<div id="m-subtitle" style="font-size:12px;color:#6B7280;margin-top:3px;"></div>
</div>
<div style="padding:18px;display:flex;flex-direction:column;gap:12px;">
<div>
<label class="form-label">المبلغ</label>
<input type="number" name="amount" id="m-amount" class="form-input" step="0.01" min="0.01" dir="ltr" style="text-align:right;font-size:16px;font-weight:600;"
<?= (int) $current['allow_partial'] === 1 ? '' : 'readonly' ?>>
<div class="form-help">
<?= (int) $current['allow_partial'] === 1 ? 'يسمح هذا المصدر بالتحصيل الجزئي.' : 'هذا المصدر يتطلب تحصيل المبلغ كاملًا.' ?>
</div>
</div>
<div>
<label class="form-label">طريقة الدفع</label>
<select name="payment_method" id="m-method" class="form-select">
<option value="cash">نقدي</option>
<option value="visa">فيزا</option>
<option value="check">شيك</option>
<option value="bank_transfer">تحويل بنكي</option>
</select>
</div>
<?php if (!empty($treasuries)): ?>
<div>
<label class="form-label">الخزنة</label>
<select name="treasury_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($treasuries as $t): ?>
<option value="<?= (int) $t['id'] ?>"><?= e($t['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div id="m-check" style="display:none;flex-direction:column;gap:10px;">
<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 id="m-visa" style="display:none;">
<label class="form-label">مرجع العملية</label>
<input type="text" name="visa_reference" class="form-input" dir="ltr">
</div>
</div>
<div style="padding:14px 18px;border-top:1px solid #E5E7EB;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">تأكيد التحصيل</button>
<button type="button" id="m-cancel" class="btn btn-ghost">إلغاء</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('collect-modal');
var method = document.getElementById('m-method');
function refreshMethodFields() {
document.getElementById('m-check').style.display = method.value === 'check' ? 'flex' : 'none';
document.getElementById('m-visa').style.display = (method.value === 'visa' || method.value === 'bank_transfer') ? 'block' : 'none';
}
method.addEventListener('change', refreshMethodFields);
document.querySelectorAll('.collect-btn').forEach(function (b) {
b.addEventListener('click', function () {
document.getElementById('m-row').value = b.dataset.row;
document.getElementById('m-amount').value = b.dataset.amount;
document.getElementById('m-amount').max = b.dataset.amount;
document.getElementById('m-subtitle').textContent = b.dataset.ref + ' — ' + (b.dataset.payer || '');
refreshMethodFields();
modal.style.display = 'flex';
});
});
document.getElementById('m-cancel').addEventListener('click', function () { modal.style.display = 'none'; });
modal.addEventListener('click', function (e) { if (e.target === modal) modal.style.display = 'none'; });
})();
</script>
<?php endif; ?>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $source ? 'تعديل مصدر مطالبة' : 'مصدر مطالبة جديد' ?><?php $__template->endSection(); ?>
<?php
$conds = [];
if ($source && !empty($source['conditions'])) {
$decoded = json_decode((string) $source['conditions'], true);
$conds = is_array($decoded) ? $decoded : [];
}
$opLabels = [
'=' => 'يساوي',
'!=' => 'لا يساوي',
'>' => 'أكبر من',
'<' => 'أصغر من',
'>=' => 'أكبر أو يساوي',
'<=' => 'أصغر أو يساوي',
'in' => 'ضمن (افصل بفاصلة)',
'not_in' => 'ليس ضمن (افصل بفاصلة)',
'is_null' => 'فارغ',
'is_not_null' => 'غير فارغ',
];
?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/billing/sources" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى المصادر</a>
<h2 style="margin:6px 0 4px;"><?= $source ? e($source['name_ar']) : 'مصدر مطالبة جديد' ?></h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:700px;">
عرّف من أين يقرأ النظام المبالغ المستحقة. كل الأعمدة بتتأكد من وجودها فعليًا في
قاعدة البيانات قبل الحفظ، ومش هينحفظ إعداد غلط.
</p>
</div>
<form method="POST" action="/accounting/billing/sources/<?= $source ? (int) $source['id'] : 0 ?>" id="src-form">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:minmax(0,1.5fr) 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>
<input type="text" name="name_ar" class="form-input" required value="<?= e($source['name_ar'] ?? '') ?>" placeholder="مثال: فواتير الإيجار">
</div>
<div>
<label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" class="form-input" required dir="ltr" value="<?= e($source['code'] ?? '') ?>" placeholder="rental_invoice">
</div>
<div style="grid-column:1/-1;">
<label class="form-label">وصف مختصر</label>
<input type="text" name="description_ar" class="form-input" value="<?= e($source['description_ar'] ?? '') ?>">
</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;">من أين يقرأ المبالغ</h3></div>
<div style="padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div style="grid-column:1/-1;">
<label class="form-label">الجدول <span style="color:#DC2626;">*</span></label>
<select name="source_table" id="f-table" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($tables as $t): ?>
<option value="<?= e($t['name']) ?>" <?= ($source['source_table'] ?? '') === $t['name'] ? 'selected' : '' ?>>
<?= e($t['name']) ?><?= $t['rows_estimate'] !== null ? ' (~' . number_format((int) $t['rows_estimate']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-help">تظهر الجداول التي تحتوي على عمود رقمي عشري فقط.</div>
</div>
<?php
$colFields = [
['id_column', 'عمود المعرّف', true, 'id'],
['amount_column', 'عمود المبلغ', true, ''],
['date_column', 'عمود التاريخ', false, ''],
['reference_column', 'عمود الرقم المرجعي', false, ''],
['member_column', 'عمود العضو', false, ''],
['player_column', 'عمود اللاعب', false, ''],
['name_column', 'عمود اسم الدافع', false, ''],
];
foreach ($colFields as [$name, $label, $req, $default]):
?>
<div>
<label class="form-label"><?= e($label) ?><?= $req ? ' <span style="color:#DC2626;">*</span>' : '' ?></label>
<select name="<?= e($name) ?>" class="form-select col-picker" data-selected="<?= e($source[$name] ?? $default) ?>" <?= $req ? 'required' : '' ?>>
<option value=""><?= $req ? '— اختر —' : '— بدون —' ?></option>
</select>
</div>
<?php endforeach; ?>
</div>
<div style="padding:0 18px 16px;font-size:11.5px;color:#6B7280;">
لازم تحدد عمودًا واحدًا على الأقل يعرّف الدافع (عضو أو لاعب أو اسم)، وإلا مش هينفع نعرف المطالبة على مين.
</div>
</div>
<div class="card" style="margin-bottom:14px;">
<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;">شروط «لسه مستحق»</h3>
<button type="button" id="add-cond" class="btn btn-sm btn-secondary">+ شرط</button>
</div>
<div style="padding:8px 18px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;font-size:11.5px;color:#6B7280;">
الصفوف اللي بتحقق كل الشروط دي هي اللي هتظهر كمستحقات. الصفوف اللي اتحصّلت من
النظام بتتشال تلقائيًا حتى لو ما حددتش شرط.
</div>
<div id="conds" style="padding:14px 18px;"></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;">بعد التحصيل</h3></div>
<div style="padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<?php
$wb = [
['writeback_payment_column', 'عمود رقم الدفعة'],
['writeback_receipt_column', 'عمود رقم الإيصال'],
['writeback_status_column', 'عمود الحالة'],
['writeback_paid_at_column', 'عمود تاريخ السداد'],
];
foreach ($wb as [$name, $label]):
?>
<div>
<label class="form-label"><?= e($label) ?></label>
<select name="<?= e($name) ?>" class="form-select col-picker" data-selected="<?= e($source[$name] ?? '') ?>">
<option value="">— بدون —</option>
</select>
</div>
<?php endforeach; ?>
<div>
<label class="form-label">قيمة الحالة بعد السداد</label>
<input type="text" name="writeback_status_value" class="form-input" dir="ltr" value="<?= e($source['writeback_status_value'] ?? '') ?>" placeholder="paid">
</div>
</div>
<div style="padding:0 18px 16px;font-size:11.5px;color:#6B7280;">
اختياري بالكامل. لو الجدول مفيهوش أعمدة دي، التحصيل بيتسجّل عندنا وبيشتغل عادي.
</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 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" name="payment_type" class="form-input" required dir="ltr" value="<?= e($source['payment_type'] ?? 'other') ?>" list="ptypes">
<datalist id="ptypes">
<?php foreach ($paymentTypes as $p): ?><option value="<?= e($p['payment_type']) ?>"><?php endforeach; ?>
</datalist>
<div class="form-help">يحدد قاعدة التوزيع المستخدمة في الدفاتر.</div>
</div>
<div>
<label class="form-label">مصدر القيد (اختياري)</label>
<select name="stream_code" class="form-select">
<option value="">— حسب نوع الدفعة —</option>
<?php foreach ($streams as $st): ?>
<option value="<?= e($st['stream_code']) ?>" <?= ($source['stream_code'] ?? '') === $st['stream_code'] ? 'selected' : '' ?>>
<?= e($st['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">التحصيل الجزئي</label>
<select name="allow_partial" class="form-select">
<option value="0" <?= (int) ($source['allow_partial'] ?? 0) === 0 ? 'selected' : '' ?>>المبلغ كاملًا فقط</option>
<option value="1" <?= (int) ($source['allow_partial'] ?? 0) === 1 ? 'selected' : '' ?>>يسمح بالتحصيل الجزئي</option>
</select>
</div>
<div>
<label class="form-label">الحالة</label>
<select name="is_active" class="form-select">
<option value="1" <?= (int) ($source['is_active'] ?? 1) === 1 ? 'selected' : '' ?>>نشط</option>
<option value="0" <?= (int) ($source['is_active'] ?? 1) === 0 ? 'selected' : '' ?>>موقوف</option>
</select>
</div>
<div>
<label class="form-label">ترتيب العرض</label>
<input type="number" name="sort_order" class="form-input" dir="ltr" value="<?= (int) ($source['sort_order'] ?? 100) ?>">
</div>
</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary btn-lg">حفظ المصدر</button>
<a href="/accounting/billing/sources" class="btn btn-ghost">إلغاء</a>
</div>
</div>
<!-- Live preview -->
<div style="position:sticky;top:14px;">
<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;">معاينة</h3>
<button type="button" id="btn-preview" class="btn btn-sm btn-secondary">جرّب</button>
</div>
<div style="padding:16px 18px;" id="preview-out">
<div style="font-size:12px;color:#6B7280;">اضغط «جرّب» عشان تشوف الصفوف اللي هتظهر كمستحقات قبل ما تحفظ.</div>
</div>
</div>
</div>
</div>
</form>
<template id="cond-tpl">
<div class="cond-row" style="display:grid;grid-template-columns:1fr 150px 1fr auto;gap:8px;margin-bottom:8px;align-items:end;">
<div>
<label class="form-label" style="font-size:11px;">العمود</label>
<select name="cond_column[]" class="form-select col-picker"><option value=""></option></select>
</div>
<div>
<label class="form-label" style="font-size:11px;">الشرط</label>
<select name="cond_op[]" class="form-select c-op">
<?php foreach ($opLabels as $op => $lbl): ?>
<option value="<?= e($op) ?>"><?= e($lbl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="c-val-wrap">
<label class="form-label" style="font-size:11px;">القيمة</label>
<input type="text" name="cond_value[]" class="form-input c-val">
</div>
<button type="button" class="btn btn-sm btn-ghost c-del" style="color:#DC2626;">حذف</button>
</div>
</template>
<script>
(function () {
var tableSel = document.getElementById('f-table');
var condsBox = document.getElementById('conds');
var tpl = document.getElementById('cond-tpl');
var csrf = document.querySelector('input[name="_csrf_token"]');
var columns = <?= json_encode(array_column($columns, 'name'), JSON_UNESCAPED_UNICODE) ?>;
var existing = <?= json_encode($conds, JSON_UNESCAPED_UNICODE) ?>;
function fillPickers(scope) {
(scope || document).querySelectorAll('.col-picker').forEach(function (sel) {
var want = sel.dataset.selected || sel.value || '';
var first = sel.querySelector('option');
sel.innerHTML = '';
if (first) sel.appendChild(first);
columns.forEach(function (c) {
var o = document.createElement('option');
o.value = c; o.textContent = c;
if (c === want) o.selected = true;
sel.appendChild(o);
});
});
}
function loadColumns(table, cb) {
if (!table) { columns = []; fillPickers(); if (cb) cb(); return; }
fetch('/accounting/billing/table-columns?table=' + encodeURIComponent(table))
.then(function (r) { return r.json(); })
.then(function (d) {
columns = (d.columns || []).map(function (c) { return c.name; });
fillPickers();
if (cb) cb();
});
}
tableSel.addEventListener('change', function () {
document.querySelectorAll('.col-picker').forEach(function (s) { s.dataset.selected = ''; });
loadColumns(tableSel.value);
});
function addCond(data) {
var node = tpl.content.cloneNode(true);
var row = node.querySelector('.cond-row');
condsBox.appendChild(node);
var op = row.querySelector('.c-op');
var valWrap = row.querySelector('.c-val-wrap');
function refresh() {
var noValue = (op.value === 'is_null' || op.value === 'is_not_null');
valWrap.style.visibility = noValue ? 'hidden' : 'visible';
}
op.addEventListener('change', refresh);
row.querySelector('.c-del').addEventListener('click', function () { row.remove(); });
if (data) {
row.querySelector('.col-picker').dataset.selected = data.column || '';
op.value = data.op || '=';
var v = data.value;
row.querySelector('.c-val').value = Array.isArray(v) ? v.join(', ') : (v === null || v === undefined ? '' : v);
}
fillPickers(row);
refresh();
}
document.getElementById('add-cond').addEventListener('click', function () { addCond(null); });
document.getElementById('btn-preview').addEventListener('click', function () {
var out = document.getElementById('preview-out');
out.innerHTML = '<div style="font-size:12px;color:#6B7280;">جارٍ التجربة…</div>';
var f = document.getElementById('src-form');
var body = new FormData(f);
if (csrf) body.append('_csrf_token', csrf.value);
fetch('/accounting/billing/sources/preview', {
method: 'POST', body: body,
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf ? csrf.value : '' }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) {
out.innerHTML = '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:10px;color:#991B1B;font-size:12px;">' + (d.error || 'خطأ') + '</div>';
return;
}
var h = '<div style="background:#ECFDF5;border-radius:6px;padding:10px;margin-bottom:10px;">'
+ '<div style="font-size:11px;color:#065F46;">سيظهر</div>'
+ '<div style="font-size:20px;font-weight:700;color:#065F46;">' + Number(d.count).toLocaleString() + ' مستحق</div>'
+ '<div style="font-size:12px;color:#065F46;">بإجمالي ' + Number(d.total).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}) + '</div></div>';
if ((d.rows || []).length) {
h += '<table style="width:100%;border-collapse:collapse;font-size:11.5px;">';
d.rows.forEach(function (r) {
h += '<tr><td style="padding:4px 0;border-bottom:1px solid #F3F4F6;">' + (r.__reference || ('#' + r.__id)) + '</td>'
+ '<td style="padding:4px 0;border-bottom:1px solid #F3F4F6;">' + (r.__payer || '—') + '</td>'
+ '<td style="padding:4px 0;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">'
+ Number(r.__amount).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}) + '</td></tr>';
});
h += '</table>';
}
out.innerHTML = h;
})
.catch(function () {
out.innerHTML = '<div style="color:#DC2626;font-size:12px;">تعذر الاتصال</div>';
});
});
// Boot
if (tableSel.value) {
loadColumns(tableSel.value, function () { existing.forEach(addCond); });
} else {
fillPickers();
}
})();
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مصادر المطالبة<?php $__template->endSection(); ?>
<?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>
<a href="/accounting/billing" 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:720px;">
كل مصدر بيقول للنظام: الجدول ده فيه مبالغ مستحقة، والصفوف اللي شرطها كذا لسه
متحصّلتش، والمدين هو العمود ده. بعدها بتظهر في شاشة المطالبات وتتحصّل زي أي دفعة.
<strong>إضافة مصدر جديد ما بتحتاجش مبرمج.</strong>
</p>
</div>
<a href="/accounting/billing/sources/0/edit" class="btn btn-primary">+ مصدر جديد</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:22%;">المصدر</th>
<th style="width:24%;">من أين يقرأ</th>
<th style="width:16%;">نوع الدفعة</th>
<th style="width:14%;">الحالة</th>
<th style="width:24%;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($sources as $s): ?>
<tr>
<td>
<div style="font-weight:600;"><?= e($s['name_ar']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['code']) ?></div>
<?php if ((int) $s['is_system'] === 1): ?>
<span class="badge badge-neutral" style="font-size:10px;">مُعرَّف مسبقًا</span>
<?php endif; ?>
</td>
<td style="font-size:12px;direction:ltr;text-align:right;color:#4B5563;">
<?= e($s['source_table']) ?>.<?= e($s['amount_column']) ?>
<?php if ((int) $s['allow_partial'] === 1): ?>
<div style="font-size:10px;color:#6B7280;">يسمح بالتحصيل الجزئي</div>
<?php endif; ?>
</td>
<td style="font-size:12px;direction:ltr;text-align:right;"><?= e($s['payment_type']) ?></td>
<td>
<?php if (!$s['live_ok']): ?>
<span class="badge badge-danger">غير صالح</span>
<div style="font-size:11px;color:#991B1B;margin-top:3px;"><?= e((string) $s['live_error']) ?></div>
<?php elseif ((int) $s['is_active'] === 1): ?>
<span class="badge badge-success">نشط</span>
<?php else: ?>
<span class="badge badge-neutral">موقوف</span>
<?php endif; ?>
</td>
<td style="text-align:left;">
<a href="/accounting/billing/sources/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a>
<form method="POST" action="/accounting/billing/sources/<?= (int) $s['id'] ?>/delete" style="display:inline;"
onsubmit="return confirm('حذف أو إيقاف المصدر «<?= e($s['name_ar']) ?>»؟');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-ghost" style="color:#DC2626;">حذف</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($sources)): ?>
<tr><td colspan="5" style="text-align:center;color:#6B7280;padding:30px;">لا توجد مصادر — أضف واحدًا</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
...@@ -106,6 +106,11 @@ PermissionRegistry::register('accounting', [ ...@@ -106,6 +106,11 @@ PermissionRegistry::register('accounting', [
// Revenue Mapping (account determination) // Revenue Mapping (account determination)
'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'],
// Billing (universal collection)
'accounting.billing.view' => ['ar' => 'عرض المطالبات', 'en' => 'View Billing'],
'accounting.billing.collect' => ['ar' => 'تحصيل المطالبات', 'en' => 'Collect Billing'],
'accounting.billing.manage' => ['ar' => 'إدارة مصادر المطالبة', 'en' => 'Manage Billing Sources'],
]); ]);
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
...@@ -125,6 +130,7 @@ MenuRegistry::register('accounting', [ ...@@ -125,6 +130,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'دليل الحسابات', 'label_en' => 'Chart of Accounts', 'route' => '/accounting/chart-of-accounts', 'permission' => 'accounting.coa.view', 'order' => 2], ['label_ar' => 'دليل الحسابات', 'label_en' => 'Chart of Accounts', 'route' => '/accounting/chart-of-accounts', 'permission' => 'accounting.coa.view', 'order' => 2],
['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' => '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);
/**
* Billing sources — turn "this module needs code" into "this module needs a row".
*
* Most unbilled money in the ERP follows one shape: a module writes a priced row
* into its own table and never tells accounting. Wiring each module by hand means a
* developer for every new revenue path, forever.
*
* A billing source declares that shape as data: which table holds the money, which
* column is the amount, which rows are still unpaid, who owes it, and which posting
* stream collects it. A single generic screen then lists every outstanding row from
* every configured source and collects it through PaymentService — the same funnel
* a member payment uses, so it gets a receipt, a treasury entry and a journal entry.
*
* SAFETY: no free-text SQL. Table and column names are validated against
* information_schema before a source can be saved and again before every query, and
* filters are structured conditions (column / operator / value) rendered into
* prepared statements. A settings screen that accepted a WHERE clause would be a
* SQL injection hole wearing a nice hat.
*/
return function (\App\Core\Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS `billing_sources` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(60) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`description_ar` VARCHAR(500) NULL,
-- Where the money lives
`source_table` VARCHAR(64) NOT NULL,
`id_column` VARCHAR(64) NOT NULL DEFAULT 'id',
`amount_column` VARCHAR(64) NOT NULL,
`date_column` VARCHAR(64) NULL,
`reference_column` VARCHAR(64) NULL COMMENT 'a human-readable number on the row',
-- Who owes it. Any of these may be null; a source with none is a guest charge.
`member_column` VARCHAR(64) NULL,
`player_column` VARCHAR(64) NULL,
`name_column` VARCHAR(64) NULL COMMENT 'free-text payer name for guests',
-- Which rows are still outstanding: [{column, op, value}]
`conditions` JSON NULL,
-- Where to write the result back so the row stops appearing
`writeback_payment_column` VARCHAR(64) NULL,
`writeback_receipt_column` VARCHAR(64) NULL,
`writeback_status_column` VARCHAR(64) NULL,
`writeback_status_value` VARCHAR(50) NULL,
`writeback_paid_at_column` VARCHAR(64) NULL,
-- How it posts
`stream_code` VARCHAR(100) NULL COMMENT 'revenue_streams.stream_code — drives the split',
`payment_type` VARCHAR(50) NOT NULL COMMENT 'written to payments.payment_type',
`allow_partial` TINYINT(1) NOT NULL DEFAULT 0,
-- Health, revalidated on save and on every listing
`validation_status` ENUM('ok','invalid','unchecked') NOT NULL DEFAULT 'unchecked',
`validation_error` VARCHAR(500) NULL,
`last_validated_at` DATETIME NULL,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`is_system` TINYINT(1) NOT NULL DEFAULT 0,
`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,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_billing_source_code` (`code`),
INDEX `idx_billing_source_active` (`is_active`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
/**
* Every collection made through a billing source, so a row that was billed can
* never be silently billed twice and the trail back to the receipt survives even
* if the source row is later edited.
*/
$db->raw("
CREATE TABLE IF NOT EXISTS `billing_source_collections` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`billing_source_id` BIGINT UNSIGNED NOT NULL,
`source_row_id` BIGINT UNSIGNED NOT NULL,
`amount` DECIMAL(18,2) NOT NULL,
`payment_id` BIGINT UNSIGNED NULL,
`receipt_id` BIGINT UNSIGNED NULL,
`receipt_number` VARCHAR(50) NULL,
`journal_entry_id` BIGINT UNSIGNED NULL,
`member_id` BIGINT UNSIGNED NULL,
`payer_name` VARCHAR(200) NULL,
`status` ENUM('collected','voided') NOT NULL DEFAULT 'collected',
`collected_at` DATETIME NOT NULL,
`collected_by` BIGINT UNSIGNED NULL,
`voided_at` DATETIME NULL,
`void_reason` VARCHAR(300) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_bsc_source_row` (`billing_source_id`, `source_row_id`, `status`),
INDEX `idx_bsc_payment` (`payment_id`),
CONSTRAINT `fk_bsc_source` FOREIGN KEY (`billing_source_id`)
REFERENCES `billing_sources`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
};
<?php
declare(strict_types=1);
use App\Modules\Accounting\Services\Revenue\BillingSourceService;
/**
* Pre-configure the outstanding charges that previously "needed code".
*
* Each row here says: this table holds priced rows, these ones are unpaid, this
* column identifies who owes it, collect it as this payment type. The universal
* collection screen then bills any of them through PaymentService — receipt,
* treasury custody and journal entry included.
*
* Every source is validated against the live schema before it is written, and one
* that does not fit this database is skipped rather than saved broken.
*/
return function (\App\Core\Database $db): void {
\App\Core\App::getInstance()->setDb($db);
$now = date('Y-m-d H:i:s');
$sources = [
[
'code' => 'sa_hourly_booking', 'name_ar' => 'حجوزات الملاعب بالساعة',
'description_ar' => 'حجوزات مسعّرة في وحدة الأنشطة الرياضية لم تُحصَّل بعد',
'source_table' => 'sa_bookings', 'amount_column' => 'total_amount',
'date_column' => 'booking_date', 'reference_column' => 'booking_number',
'name_column' => 'booker_name',
'conditions' => [
['column' => 'payment_status', 'op' => 'in', 'value' => ['unpaid', 'overdue']],
['column' => 'status', 'op' => 'not_in', 'value' => ['cancelled']],
],
'writeback_payment_column' => 'payment_id',
'writeback_receipt_column' => 'receipt_id',
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'payment_type' => 'hourly_booking', 'stream_code' => 'payment:hourly_booking',
'sort_order' => 10,
],
[
'code' => 'sa_subscription', 'name_ar' => 'اشتراكات الأنشطة الرياضية',
'description_ar' => 'اشتراكات شهرية مولّدة ولم تُحصَّل',
'source_table' => 'sa_subscriptions', 'amount_column' => 'final_amount',
'date_column' => 'period_start', 'reference_column' => 'subscription_number',
'player_column' => 'player_id',
'conditions' => [
['column' => 'payment_status', 'op' => 'in', 'value' => ['unpaid', 'overdue']],
],
'writeback_payment_column' => 'payment_id',
'writeback_receipt_column' => 'receipt_id',
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'writeback_paid_at_column' => 'paid_at',
'payment_type' => 'sports_subscription', 'stream_code' => 'payment:sports_subscription',
'allow_partial' => 1, 'sort_order' => 20,
],
[
'code' => 'sa_locker', 'name_ar' => 'إيجارات اللوكرات',
'source_table' => 'sa_locker_rentals', 'amount_column' => 'amount',
'date_column' => 'start_date', 'reference_column' => 'rental_number',
'player_column' => 'player_id',
'conditions' => [
['column' => 'payment_status', 'op' => 'in', 'value' => ['unpaid', 'overdue']],
],
'writeback_payment_column' => 'payment_id',
'writeback_receipt_column' => 'receipt_id',
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'payment_type' => 'other', 'stream_code' => 'payment:other',
'sort_order' => 30,
],
[
'code' => 'facility_reservation', 'name_ar' => 'حجوزات المرافق',
'source_table' => 'reservations', 'amount_column' => 'total_amount',
'date_column' => 'reservation_date', 'reference_column' => 'reservation_number',
'member_column' => 'member_id', 'player_column' => 'player_id', 'name_column' => 'booker_name',
'conditions' => [
['column' => 'payment_id', 'op' => 'is_null', 'value' => null],
['column' => 'status', 'op' => 'not_in', 'value' => ['cancelled']],
],
'writeback_payment_column' => 'payment_id',
'payment_type' => 'hourly_booking', 'stream_code' => 'payment:hourly_booking',
'sort_order' => 40,
],
[
'code' => 'private_match', 'name_ar' => 'المباريات الخاصة',
'source_table' => 'private_match_bookings', 'amount_column' => 'total_cost',
'date_column' => 'booking_date', 'member_column' => 'booked_by_member_id',
'name_column' => 'booked_by_name',
'conditions' => [
['column' => 'payment_status', 'op' => '!=', 'value' => 'paid'],
['column' => 'status', 'op' => 'not_in', 'value' => ['cancelled']],
],
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'payment_type' => 'hourly_booking', 'stream_code' => 'payment:hourly_booking',
'allow_partial' => 1, 'sort_order' => 50,
],
[
'code' => 'rental_invoice', 'name_ar' => 'فواتير الإيجار',
'description_ar' => 'فواتير إيجار المحلات والوحدات المستحقة',
'source_table' => 'rental_invoices', 'amount_column' => 'total_amount',
'date_column' => 'due_date', 'reference_column' => 'invoice_number',
'name_column' => 'invoice_number',
'conditions' => [
['column' => 'status', 'op' => 'not_in', 'value' => ['paid', 'cancelled']],
],
'writeback_payment_column' => 'payment_id',
'writeback_status_column' => 'status', 'writeback_status_value' => 'paid',
'writeback_paid_at_column' => 'paid_at',
'payment_type' => 'other', 'stream_code' => 'rental:invoice',
'allow_partial' => 1, 'sort_order' => 60,
],
[
'code' => 'annual_subscription', 'name_ar' => 'الاشتراكات السنوية للأعضاء',
'source_table' => 'subscriptions', 'amount_column' => 'total_amount',
'reference_column' => 'financial_year', 'member_column' => 'member_id',
'name_column' => 'person_name',
'conditions' => [
['column' => 'status', 'op' => 'in', 'value' => ['pending', 'overdue']],
],
'writeback_payment_column' => 'payment_id',
'writeback_status_column' => 'status', 'writeback_status_value' => 'paid',
'writeback_paid_at_column' => 'paid_at',
'payment_type' => 'annual_subscription', 'stream_code' => 'payment:annual_subscription',
'allow_partial' => 1, 'sort_order' => 5,
],
];
foreach ($sources as $s) {
if ($db->selectOne("SELECT id FROM billing_sources WHERE code = ?", [$s['code']])) {
continue;
}
$row = array_merge([
'id_column' => 'id',
'date_column' => null,
'reference_column' => null,
'member_column' => null,
'player_column' => null,
'name_column' => null,
'writeback_payment_column' => null,
'writeback_receipt_column' => null,
'writeback_status_column' => null,
'writeback_status_value' => null,
'writeback_paid_at_column' => null,
'stream_code' => null,
'allow_partial' => 0,
'sort_order' => 100,
], $s);
// Skip anything that does not fit this database rather than store it broken.
$check = BillingSourceService::validate($row);
if (!$check['ok']) {
echo " [skip] {$s['code']}: {$check['error']}\n";
continue;
}
$db->insert('billing_sources', [
'code' => $row['code'],
'name_ar' => $row['name_ar'],
'name_en' => $row['name_en'] ?? null,
'description_ar' => $row['description_ar'] ?? null,
'source_table' => $row['source_table'],
'id_column' => $row['id_column'],
'amount_column' => $row['amount_column'],
'date_column' => $row['date_column'],
'reference_column' => $row['reference_column'],
'member_column' => $row['member_column'],
'player_column' => $row['player_column'],
'name_column' => $row['name_column'],
'conditions' => json_encode($row['conditions'] ?? [], JSON_UNESCAPED_UNICODE),
'writeback_payment_column' => $row['writeback_payment_column'],
'writeback_receipt_column' => $row['writeback_receipt_column'],
'writeback_status_column' => $row['writeback_status_column'],
'writeback_status_value' => $row['writeback_status_value'],
'writeback_paid_at_column' => $row['writeback_paid_at_column'],
'stream_code' => $row['stream_code'],
'payment_type' => $row['payment_type'],
'allow_partial' => $row['allow_partial'],
'validation_status' => 'ok',
'last_validated_at' => $now,
'sort_order' => $row['sort_order'],
'is_active' => 1,
'is_system' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
};
<?php
declare(strict_types=1);
/**
* Grant the billing permissions to the roles that already handle collection.
*
* Viewing and collecting follow whoever can already take a payment; declaring new
* billing sources follows whoever can already manage the chart of accounts, since
* it decides where money lands.
*/
return function (\App\Core\Database $db): void {
// Keys verified against role_permissions on the live database — payment.create
// does not exist here; cash collection is payment.process_cash (4 roles) and the
// cashier queue is cashier.process_payment (6 roles).
$grants = [
'payment.process_cash' => ['accounting.billing.view', 'accounting.billing.collect'],
'cashier.process_payment' => ['accounting.billing.view', 'accounting.billing.collect'],
'payment.view' => ['accounting.billing.view'],
'accounting.coa.manage' => ['accounting.billing.view', 'accounting.billing.manage'],
];
foreach ($grants as $sourceKey => $newKeys) {
$roles = $db->select(
"SELECT DISTINCT role_id FROM role_permissions WHERE permission_key = ?",
[$sourceKey]
);
foreach ($roles as $row) {
$roleId = (int) $row['role_id'];
foreach ($newKeys as $key) {
$exists = $db->selectOne(
"SELECT id FROM role_permissions WHERE role_id = ? AND permission_key = ?",
[$roleId, $key]
);
if ($exists) {
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