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
This diff is collapsed.
...@@ -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'],
......
This diff is collapsed.
This diff is collapsed.
<?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