Commit e45bd6d7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(settlements): settle a member's account instead of editing around it

A club that ran on paper for years does not arrive in the system as a
clean ledger. On the first academy live, 33 players were registered with
an invoice raised and the "pay now" toggle left off — 25 of them in two
data-entry evenings — and 27 of those are now carrying an unpaid
registration month plus an unpaid September renewal. Nine paid for the
federation card in instalments typed into free-text lines. Eight invoices
were issued at zero because no price existed yet. Three people exist
twice. None of that is a bug in one screen; it is a whole class of file
that reality got ahead of.

The desk had four tools that each did a slice: collect a payment, correct
one invoice's amount, back-fill missing invoices, register someone who
started months ago. None of them answers the question an operator has in
front of a parent — this file is wrong in several ways at once, what do
we do about all of it — so corrections were made wherever a screen
allowed them and the ledger drifted further.

SettlementService applies a reviewed set of corrections as one
transaction and one record: money taken and never entered (on the day it
was actually taken), a month closed for less than it was billed because
the player joined halfway through, a month dropped entirely, a month
nobody billed, a card or kit sold outside the system, a free-text line
linked to the product it was really paying for, an agreed instalment
plan, a payment sitting on the wrong month, and an overpayment held as
wallet credit. Money moves through PaymentService so the ledger, the
balance and the receipt all happen; stock through InventoryService; a
waiver is written as the admin_override the roster already knows how to
explain, leaving subtotal_amount alone so "650 of 900, discounted" still
reads. Nothing calls auth() or session(): actor, branch and amounts are
parameters.

AccountAnomalyScanner finds the files rather than waiting for an argument
at the desk — seven cases, worst first, each with the sentence that says
what to check. SettlementWorklist lists them with a CSV export;
AccountSettlementWizard puts one account on a page, proposes the
corrections that fit what it found, shows exactly what will be collected,
waived and billed, and demands a written reason before it writes
anything.

Both screens are gated on a new settlements.manage permission — waiving a
month is the academy's call, and an owner should not need a platform
administrator to make it — delivered by migration as well as seeder,
since db:seed only runs on a first deploy.

Two things the tests caught rather than production: Postgres refuses FOR
UPDATE on an aggregate, so numbering settlements from max(id) would have
rolled back a whole settlement the operator had already confirmed; and
payment_plans_status_check has no 'partial', so a part-paid plan is
active with the count saying how far along it is.

Verified against a restored oc-sport tenant: 20 settlement cases and 7
render/permission cases pass, including cross-participant access, a
future date, an oversized payment, and a failing second action rolling
the first one back. Full suite 298 tests, no failures.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent fa06830c
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Settlement;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* A participant's account was settled.
*
* Dispatched after commit so a listener never sees a settlement that was rolled
* back. Nothing listens yet — notifying the guardian, or telling the branch
* manager how much was written off this week, belongs on a listener rather than
* inside the service that moved the money.
*/
class SettlementApplied implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Settlement $settlement,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Models;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One operator's decision about one participant's account, and everything it
* did.
*
* Treated as immutable, like transactions and audit_logs: a settlement that was
* wrong is answered with another settlement, never with an edit. Nothing in the
* application updates a row after SettlementService writes it.
*/
class Settlement extends Model
{
use HasUuid, BelongsToAcademy, BelongsToBranch;
protected $fillable = [
'academy_id',
'branch_id',
'participant_id',
'reference',
'reason',
'actions',
'collected_amount',
'waived_amount',
'billed_amount',
'applied_by',
'applied_at',
];
protected function casts(): array
{
return [
'actions' => 'array',
'collected_amount' => 'integer',
'waived_amount' => 'integer',
'billed_amount' => 'integer',
'applied_at' => 'datetime',
];
}
public function participant(): BelongsTo
{
return $this->belongsTo(Participant::class);
}
public function appliedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'applied_by');
}
/** Net effect on the ledger: what came in, less what was given up. */
public function netCollected(): int
{
return $this->collected_amount;
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -92,6 +92,22 @@ public function isAnnual(): bool
return $this->billing_cycle === 'annual';
}
/**
* Programmes that bundle this product — the other side of
* TrainingProgram::bundledProducts(). Needed to ask "which products does
* this player's programme require" from the product's side, which is how
* the settlement wizard offers the right ones.
*/
public function programs()
{
return $this->belongsToMany(
\App\Domain\Training\Models\TrainingProgram::class,
'program_products',
'product_id',
'training_program_id'
)->withPivot(['is_required', 'quantity'])->withTimestamps();
}
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'category_id');
......
This diff is collapsed.
<?php
namespace App\Livewire\Admin;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Every account that needs a human decision, worst first.
*
* The alternative — and what actually happened on the first academy to go live
* — is that nobody knows a file is wrong until a parent argues at the desk. By
* then the money is months old and the person who took it has gone home. This
* screen reads the same books the roster reads and says: these are the players
* whose file does not add up, and this is why.
*
* Read-only. Every row links into the settlement wizard, where a person decides
* what actually happened before anything is written.
*/
#[Layout('layouts.app')]
#[Title('حالات تحتاج تسوية')]
class SettlementWorklist extends Component
{
#[Url(as: 'case')]
public string $caseFilter = '';
#[Url(as: 'q')]
public string $search = '';
public function mount(): void
{
$this->authorize('settlements.manage');
}
public function clearFilter(): void
{
$this->caseFilter = '';
$this->search = '';
}
/**
* The scan is a handful of grouped queries over the branch's own rows, not
* a query per participant — an academy of a few hundred players is one
* page-load, and the branch scope on Participant is what keeps it to this
* branch.
*/
private function rows(): array
{
$rows = app(AccountAnomalyScanner::class)->scan(
branchId: null,
only: $this->caseFilter ?: null,
);
$term = trim($this->search);
if ($term === '') {
return $rows;
}
return array_values(array_filter($rows, function ($row) use ($term) {
$person = $row['participant']->person;
return str_contains((string) $person?->name_ar, $term)
|| str_contains((string) $person?->name, $term)
|| str_contains((string) $person?->phone, $term);
}));
}
/** The worklist as a spreadsheet, for the people who work off paper. */
public function export()
{
$this->authorize('settlements.manage');
$rows = $this->rows();
$cases = AccountAnomalyScanner::CASES;
return response()->streamDownload(function () use ($rows, $cases) {
$out = fopen('php://output', 'w');
// BOM so Excel opens Arabic correctly instead of as mojibake.
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['رقم المشترك', 'الاسم', 'الهاتف', 'البرنامج', 'الحالات', 'فواتير غير مسددة', 'المستحق (ج.م)', 'المحصَّل (ج.م)']);
foreach ($rows as $row) {
$participant = $row['participant'];
fputcsv($out, [
$participant->id,
$participant->person?->name_ar,
$participant->person?->phone,
$participant->enrollments->first()?->program?->name_ar,
implode(' / ', array_map(fn ($c) => $cases[$c]['label'], $row['cases'])),
$row['unpaid_invoices'],
number_format($row['owed'] / 100, 2, '.', ''),
number_format($row['paid'] / 100, 2, '.', ''),
]);
}
fclose($out);
}, 'settlement-worklist-' . now()->format('Y-m-d') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function render()
{
$rows = $this->rows();
$counts = [];
foreach ($rows as $row) {
foreach ($row['cases'] as $case) {
$counts[$case] = ($counts[$case] ?? 0) + 1;
}
}
return view('livewire.admin.settlement-worklist', [
'rows' => $rows,
'counts' => $counts,
'cases' => AccountAnomalyScanner::CASES,
'totalOwed' => array_sum(array_column($rows, 'owed')),
]);
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* One record of an operator settling a participant's account.
*
* A settlement is not one invoice and not one payment: it is the set of
* corrections an admin decided a player's file needed, applied together. A
* player who paid two months in cash that were never entered, was over-billed
* for the month he joined halfway through, and bought a registration card that
* nobody put through the till, is one settlement carrying four actions — not
* four unrelated edits that nobody can later tie to the same decision.
*
* Why a table and not just the audit log: money moved on somebody's judgement,
* and the question asked afterwards is always "who decided this, what did they
* see, and what did it cost us". `actions` keeps each step and its result
* (invoice and payment ids included), and the three amount columns make the
* cost readable without parsing JSON — how much was collected, how much was
* written off, how much was newly billed.
*
* Immutable by convention, like transactions and audit_logs: a settlement that
* turns out to be wrong is corrected by a second settlement, never by editing
* the first.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('settlements')) {
return;
}
Schema::create('settlements', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies')->cascadeOnDelete();
$table->foreignId('branch_id')->nullable()->constrained('branches')->nullOnDelete();
$table->foreignId('participant_id')->constrained('participants')->cascadeOnDelete();
// Human-facing handle, printed on the summary the operator hands over.
$table->string('reference');
// Why the account needed settling. Required — a settlement with no
// stated reason is indistinguishable from a mistake six months on.
$table->text('reason');
// Every action and what it produced, in the order applied.
$table->json('actions');
// The cost of the decision, split so it can be reported on directly.
$table->bigInteger('collected_amount')->default(0);
$table->bigInteger('waived_amount')->default(0);
$table->bigInteger('billed_amount')->default(0);
$table->foreignId('applied_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('applied_at');
$table->timestamps();
// Tenant-scoped uniqueness, per the academy_id rule: two academies
// may both hold SET-000001.
$table->unique(['academy_id', 'reference'], 'settlements_reference_unique');
$table->index(['academy_id', 'participant_id']);
$table->index(['branch_id', 'applied_at']);
});
}
public function down(): void
{
Schema::dropIfExists('settlements');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Add `settlements.manage` and grant it to the roles that answer for the money.
*
* Settling an account writes payments, waives balances and bills months that
* were never billed. That is the academy's own decision to make, not the front
* desk's — hence owner and admin, not receptionist.
*
* PermissionSeeder lists it too, but db:seed only runs on a first deploy
* (docker/entrypoint.sh), while migrations always run. Idempotent: every insert
* checks for the row first, so repeated container starts are harmless.
*/
return new class extends Migration
{
private const PERMISSION = 'settlements.manage';
private const ROLES = ['super_admin', 'academy_owner', 'academy_admin'];
public function up(): void
{
if (! Schema::hasTable('permissions') || ! Schema::hasTable('permission_role')) {
return;
}
$permissionId = DB::table('permissions')
->where('name', self::PERMISSION)
->value('id');
if (! $permissionId) {
$permissionId = DB::table('permissions')->insertGetId([
'name' => self::PERMISSION,
'module' => 'settlements',
'action' => 'manage',
'description' => 'Settle a participant account: back-dated payments, waivers, retroactive sales',
'description_ar' => 'تسوية حساب مشترك: دفعات سابقة، إسقاط مستحقات، مبيعات بأثر رجعي',
'created_at' => now(),
]);
}
if (! Schema::hasTable('roles')) {
return;
}
// Roles are per-academy, so this covers every tenant in the database.
$roleIds = DB::table('roles')
->whereIn('slug', self::ROLES)
->pluck('id');
foreach ($roleIds as $roleId) {
$exists = DB::table('permission_role')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->exists();
if (! $exists) {
DB::table('permission_role')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'scope' => 'all',
'created_at' => now(),
]);
}
}
}
public function down(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
$permissionId = DB::table('permissions')
->where('name', self::PERMISSION)
->value('id');
if (! $permissionId) {
return;
}
if (Schema::hasTable('permission_role')) {
DB::table('permission_role')->where('permission_id', $permissionId)->delete();
}
DB::table('permissions')->where('id', $permissionId)->delete();
}
};
......@@ -137,6 +137,9 @@ public static function getPermissionsList(): array
'refunds.initiate', 'refunds.approve',
'daily_closing.create', 'daily_closing.view',
'expenses.create', 'expenses.list', 'expenses.view',
// Settling an account writes payments, waives balances and bills
// months nobody billed — the academy's decision, not the desk's.
'settlements.manage',
// Pricing
'pricing.list', 'pricing.create', 'pricing.update', 'pricing.delete',
......@@ -397,6 +400,7 @@ private function accountantPermissions(): array
// cash; the maker-checker rule in PaymentProofService is what
// stops it being a one-person act.
'payments.approve_proof',
'settlements.manage',
'daily_closing.create', 'daily_closing.view',
'expenses.create', 'expenses.list', 'expenses.view',
'reports.financial', 'reports.view', 'reports.export_pdf', 'reports.export_excel',
......
{{-- What this settlement will do, before and after it is applied. --}}
@php $totals = $this->totals; @endphp
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-900">{{ __('الإجراءات المختارة') }}</h3>
<span class="text-xs text-gray-500">{{ count($cart) }} {{ __('إجراء') }}</span>
</div>
@if(empty($cart))
<p class="py-6 text-center text-sm text-gray-400">{{ __('لم تُضف أي إجراءات بعد — ابدأ من جدول الفواتير أعلاه.') }}</p>
@else
<ul class="divide-y divide-gray-100 mb-4">
@foreach($cart as $index => $item)
<li class="py-2.5 flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm text-gray-800">
@switch($item['type'])
@case('record_payment')
{{ __('تحصيل') }} <span dir="ltr" class="font-bold">{{ format_money($item['amount']) }}</span>
{{ __('على') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
<span class="text-gray-500">· {{ $item['date'] }}</span>
@break
@case('settle_short')
{{ __('إغلاق') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
{{ __('بمبلغ') }} <span dir="ltr" class="font-bold">{{ format_money($item['amount']) }}</span>
{{ __('وإسقاط الباقي') }}
@break
@case('waive_invoice')
{{ __('إسقاط') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
(<span dir="ltr">{{ format_money($item['amount']) }}</span>)
@break
@case('bill_month')
{{ __('فاتورة شهر') }} <span dir="ltr">{{ $item['month'] }}</span>
{{ __('بمبلغ') }} <span dir="ltr" class="font-bold">{{ format_money($item['amount']) }}</span>
@if($item['paid_amount'] > 0) · {{ __('محصَّل') }} <span dir="ltr">{{ format_money($item['paid_amount']) }}</span> @endif
@if($item['close_month']) · {{ __('مغلق') }} @endif
@break
@case('sell_product')
{{ __('بيع منتج بأثر رجعي') }} ×{{ $item['quantity'] }}
<span dir="ltr" class="font-bold">{{ format_money($item['unit_price'] * $item['quantity']) }}</span>
@if($item['adjust_stock']) · {{ __('مع خصم المخزون') }} @endif
@break
@case('link_line')
{{ __('ربط بند') }} «{{ $item['description'] }}» {{ __('بمنتج') }} {{ $item['product_name'] }}
@break
@case('move_payment')
{{ __('نقل دفعة') }} <span dir="ltr" class="font-bold">{{ $item['amount_display'] ?? '' }}</span>
{{ __('من') }} <span dir="ltr">{{ $item['from_invoice_number'] ?? '' }}</span> {{ __('إلى الفاتورة الصحيحة') }}
@break
@case('credit_wallet')
{{ __('إضافة') }} <span dir="ltr" class="font-bold">{{ format_money($item['amount']) }}</span> {{ __('لمحفظة المشترك') }}
@break
@case('plan_installments')
{{ __('خطة أقساط') }} {{ $item['paid_installments'] }}/{{ $item['total_installments'] }}
{{ __('على') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
@break
@endswitch
</p>
@if(!empty($item['note']))
<p class="text-[11px] text-gray-500 mt-0.5">{{ $item['note'] }}</p>
@endif
</div>
@if($editable)
<button type="button" wire:click="removeCartItem({{ $index }})"
class="text-xs text-red-600 hover:text-red-800 shrink-0">{{ __('حذف') }}</button>
@endif
</li>
@endforeach
</ul>
<div class="grid grid-cols-3 gap-3 pt-3 border-t border-gray-100">
<div class="p-2.5 bg-emerald-50 rounded-lg">
<p class="text-[11px] text-emerald-700">{{ __('سيُحصَّل') }}</p>
<p class="text-sm font-bold text-emerald-800 tabular-nums" dir="ltr">{{ format_money($totals['collected']) }}</p>
</div>
<div class="p-2.5 bg-red-50 rounded-lg">
<p class="text-[11px] text-red-700">{{ __('سيُسقَط') }}</p>
<p class="text-sm font-bold text-red-800 tabular-nums" dir="ltr">{{ format_money($totals['waived']) }}</p>
</div>
<div class="p-2.5 bg-gray-50 rounded-lg">
<p class="text-[11px] text-gray-600">{{ __('سيُفوتَر') }}</p>
<p class="text-sm font-bold text-gray-800 tabular-nums" dir="ltr">{{ format_money($totals['billed']) }}</p>
</div>
</div>
@endif
</div>
{{-- The accounts that need a decision. RTL: logical properties only. --}}
<div class="max-w-7xl mx-auto px-3 sm:px-4 py-4 sm:py-6" dir="rtl">
<div class="mb-4 sm:mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">{{ __('حالات تحتاج تسوية') }}</h1>
<p class="mt-1 text-sm text-gray-500">
{{ __('حسابات لا تتفق أرقامها مع الواقع: مال حُصِّل ولم يُسجَّل، شهور بلا فواتير، منتجات بيعت خارج السيستم.') }}
</p>
</div>
<button type="button" wire:click="export"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50">
{{ __('تصدير Excel') }}
</button>
</div>
{{-- Headline --}}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div class="p-3 bg-white border border-gray-200 rounded-xl">
<p class="text-[11px] text-gray-500">{{ __('حسابات مرصودة') }}</p>
<p class="text-xl font-bold text-gray-900">{{ count($rows) }}</p>
</div>
<div class="p-3 bg-white border border-gray-200 rounded-xl">
<p class="text-[11px] text-gray-500">{{ __('إجمالي المستحق عليها') }}</p>
<p class="text-xl font-bold text-red-700 tabular-nums" dir="ltr">{{ format_money($totalOwed) }}</p>
</div>
<div class="p-3 bg-white border border-gray-200 rounded-xl col-span-2">
<p class="text-[11px] text-gray-500 mb-1">{{ __('بحث') }}</p>
<input type="text" wire:model.live.debounce.400ms="search" placeholder="{{ __('اسم أو هاتف...') }}"
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
</div>
</div>
{{-- Case filters --}}
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" wire:click="clearFilter"
class="px-3 py-1.5 text-xs rounded-full border {{ $caseFilter === '' ? 'bg-gray-900 text-white border-gray-900' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50' }}">
{{ __('الكل') }} ({{ count($rows) }})
</button>
@foreach($cases as $code => $case)
@if(!empty($counts[$code]) || $caseFilter === $code)
<button type="button" wire:click="$set('caseFilter', '{{ $code }}')"
class="px-3 py-1.5 text-xs rounded-full border {{ $caseFilter === $code ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-600 border-gray-300 hover:bg-amber-50' }}">
{{ $case['label'] }} ({{ $counts[$code] ?? 0 }})
</button>
@endif
@endforeach
</div>
@if($caseFilter && isset($cases[$caseFilter]))
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-900">
{{ $cases[$caseFilter]['hint'] }}
</div>
@endif
{{-- The list --}}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-600">
<tr>
<th class="px-3 py-2.5 text-start font-medium">{{ __('المشترك') }}</th>
<th class="px-3 py-2.5 text-start font-medium">{{ __('البرنامج') }}</th>
<th class="px-3 py-2.5 text-start font-medium">{{ __('ما الذي رُصد') }}</th>
<th class="px-3 py-2.5 text-center font-medium">{{ __('غير مسددة') }}</th>
<th class="px-3 py-2.5 text-center font-medium">{{ __('المستحق') }}</th>
<th class="px-3 py-2.5 text-center font-medium">{{ __('المحصَّل') }}</th>
<th class="px-3 py-2.5 text-center font-medium"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($rows as $row)
@php $participant = $row['participant']; @endphp
<tr class="hover:bg-gray-50 transition">
<td class="px-3 py-2.5">
<span class="block font-medium text-gray-900">{{ $participant->person?->name_ar }}</span>
@if($participant->person?->phone)
<span class="block text-xs text-gray-500" dir="ltr">{{ $participant->person->phone }}</span>
@endif
</td>
<td class="px-3 py-2.5 text-xs text-gray-600">{{ $participant->enrollments->first()?->program?->name_ar ?? '—' }}</td>
<td class="px-3 py-2.5">
<div class="flex flex-wrap gap-1">
@foreach($row['cases'] as $case)
<span class="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-800">{{ $cases[$case]['label'] }}</span>
@endforeach
</div>
</td>
<td class="px-3 py-2.5 text-center tabular-nums">{{ $row['unpaid_invoices'] }}</td>
<td class="px-3 py-2.5 text-center tabular-nums font-bold {{ $row['owed'] > 0 ? 'text-red-700' : 'text-gray-400' }}" dir="ltr">
{{ format_money($row['owed']) }}
</td>
<td class="px-3 py-2.5 text-center tabular-nums text-emerald-700" dir="ltr">{{ format_money($row['paid']) }}</td>
<td class="px-3 py-2.5 text-center">
<a href="{{ route('admin.account-settlement', ['participant' => $participant->id]) }}" wire:navigate
class="inline-flex px-3 py-1.5 bg-amber-500 text-white text-xs font-medium rounded-lg hover:bg-amber-600">
{{ __('تسوية') }}
</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-3 py-10 text-center">
<p class="text-sm text-gray-500">{{ __('لا توجد حالات مرصودة') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('كل الحسابات في هذا الفرع متسقة — أو غيّر الفلتر.') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
......@@ -11,6 +11,10 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
{{ __('تسوية المشتركين') }}
</a>
<a href="{{ route('admin.settlement-worklist') }}" class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm font-medium rounded-lg hover:bg-emerald-100 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ __('حالات تحتاج تسوية') }}
</a>
<a href="{{ route('admin.invoice-correction') }}" class="inline-flex items-center gap-2 px-4 py-2 bg-red-50 border border-red-200 text-red-800 text-sm font-medium rounded-lg hover:bg-red-100 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
{{ __('تصحيح فاتورة') }}
......
......@@ -445,6 +445,20 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-emerald-50 border border-em
</a>
@endcan
@can('settlements.manage')
{{-- The accounts that do not add up. Deliberately beside the daily
actions rather than buried in the admin panel: a file nobody
looks at is a debt nobody collects. --}}
<a href="{{ route('admin.settlement-worklist') }}" wire:navigate class="flex flex-col items-center gap-3 bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-amber-300 transition-all group">
<div class="w-14 h-14 bg-amber-50 rounded-xl flex items-center justify-center group-hover:bg-amber-100 transition-colors">
<svg class="w-7 h-7 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 text-center">{{ __('تسوية الحسابات') }}</span>
</a>
@endcan
@can('attendance.mark')
<a href="{{ route('attendance.quick') }}" wire:navigate class="flex flex-col items-center gap-3 bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-green-300 transition-all group">
<div class="w-14 h-14 bg-green-50 rounded-xl flex items-center justify-center group-hover:bg-green-100 transition-colors">
......
......@@ -522,6 +522,15 @@
Route::get('/admin/invoice-correction', \App\Livewire\Admin\InvoiceCorrectionWizard::class)->name('admin.invoice-correction')
->middleware('permission:super_admin.access');
// Account settlement — the worklist of accounts that do not add up, and the
// wizard that settles one. Gated on settlements.manage rather than
// super_admin.access: waiving a month is the academy's call to make, and an
// owner should not need a platform administrator to make it.
Route::get('/admin/settlements', \App\Livewire\Admin\SettlementWorklist::class)->name('admin.settlement-worklist')
->middleware('permission:settlements.manage');
Route::get('/admin/settlements/account/{participant?}', \App\Livewire\Admin\AccountSettlementWizard::class)->name('admin.account-settlement')
->middleware('permission:settlements.manage');
// Exports
Route::get('/export/report', [\App\Http\Controllers\ExportController::class, 'report'])
->name('export.report')->middleware('permission:reports.view');
......
This diff is collapsed.
<?php
namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Models\User;
use Tests\TestCase;
/**
* The settlement screens are Blade, so a renamed method or a wrong variable is
* a 500 at request time and nothing earlier catches it.
*
* Runs against a restored Postgres tenant; skips on the default SQLite suite:
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter SettlementScreensRenderTest
*/
class SettlementScreensRenderTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
}
private function anAdmin(): User
{
$user = User::query()->get()->first(fn (User $u) => $u->can('settlements.manage'));
if (! $user) {
$this->markTestSkipped('No user in the restored tenant holds settlements.manage.');
}
return $user;
}
public function test_the_worklist_renders_and_finds_accounts(): void
{
$response = $this->actingAs($this->anAdmin())->get(route('admin.settlement-worklist'));
$response->assertOk();
$response->assertSee('حالات تحتاج تسوية', escape: false);
$response->assertSee('تسوية', escape: false);
}
public function test_the_worklist_can_be_filtered_to_one_case(): void
{
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'never_paid']));
$response->assertOk();
$response->assertSee('لم يُسجَّل له أي دفع', escape: false);
}
public function test_the_wizard_opens_on_a_participant_and_shows_the_account(): void
{
// A live participant: an invoice whose player was later deleted must
// not open the wizard, and the screen says so rather than pretending.
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->whereColumn('total_amount', '>', 'paid_amount')
->whereNull('deleted_at')
->whereIn('billable_id', Participant::withoutGlobalScopes()->whereNull('deleted_at')->select('id'))
->firstOrFail();
$response = $this->actingAs($this->anAdmin())
->get(route('admin.account-settlement', ['participant' => $invoice->billable_id]));
$response->assertOk();
$response->assertSee('الفواتير', escape: false);
$response->assertSee('دفعة سابقة', escape: false);
$response->assertSee($invoice->number);
}
public function test_the_wizard_opens_empty_without_a_participant(): void
{
$response = $this->actingAs($this->anAdmin())->get(route('admin.account-settlement'));
$response->assertOk();
$response->assertSee('ابحث بالاسم أو رقم الهاتف', escape: false);
}
public function test_a_user_without_the_permission_is_refused(): void
{
$user = User::query()->get()->first(fn (User $u) => ! $u->can('settlements.manage'));
if (! $user) {
$this->markTestSkipped('Every user in this tenant may settle accounts.');
}
$this->actingAs($user)->get(route('admin.settlement-worklist'))->assertForbidden();
$this->actingAs($user)->get(route('admin.account-settlement'))->assertForbidden();
}
public function test_the_screens_use_logical_properties_only(): void
{
// Arabic is the default locale; a physical margin flips the layout.
foreach ([
'livewire/admin/account-settlement-wizard.blade.php',
'livewire/admin/settlement-worklist.blade.php',
'livewire/admin/partials/settlement-cart.blade.php',
] as $view) {
$content = file_get_contents(resource_path('views/' . $view));
$offenders = [];
foreach (explode("\n", $content) as $i => $line) {
if (preg_match('/class="[^"]*\b(m[lr]|p[lr])-[0-9]/', $line)) {
$offenders[] = $view . ':' . ($i + 1) . ' ' . trim($line);
}
}
$this->assertSame([], $offenders, "Physical margins/paddings found:\n" . implode("\n", $offenders));
}
}
public function test_no_dead_links_in_the_settlement_screens(): void
{
// href="#" is forbidden: a link that goes nowhere is a bug report from
// a parent standing at the desk.
foreach ([
'livewire/admin/account-settlement-wizard.blade.php',
'livewire/admin/settlement-worklist.blade.php',
'livewire/admin/partials/settlement-cart.blade.php',
] as $view) {
$content = file_get_contents(resource_path('views/' . $view));
$this->assertStringNotContainsString('href="#"', $content, $view);
}
}
}
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