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;
}
}
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Support\BundledProductLine;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Finding the files that need settling, instead of waiting for a parent to
* complain at the desk.
*
* Every case below was read off a real academy's books before it was written
* here. They are not errors in the sense of broken code — they are what happens
* when a club that has been running on paper is entered into a system over two
* evenings: a squad registered in a batch with the "pay now" toggle left off, a
* registration card taken in three instalments and typed into a free-text line,
* a month that was billed at zero because no price existed yet.
*
* Nothing here writes. It reports what a human then decides about, which is why
* every case carries a suggestion rather than an action.
*/
class AccountAnomalyScanner
{
/**
* The catalogue of what can be wrong with an account.
*
* `severity` orders the worklist: money that was probably collected and
* never recorded outranks money nobody ever chased.
*/
public const CASES = [
'never_paid' => [
'label' => 'لم يُسجَّل له أي دفع',
'hint' => 'صدرت له فواتير ولم يُسجَّل عليها أي تحصيل — يُراجَع مع الاستقبال: هل حُصِّل نقداً؟',
'severity' => 1,
],
'handtyped_product' => [
'label' => 'منتج مكتوب كبند يدوي',
'hint' => 'دفع مالاً لمنتج (قيد/شنطة/زي) كُتب كنص حر ولم يُربط بالمنتج — المخزون والتقارير لا تراه.',
'severity' => 2,
],
'zero_invoice' => [
'label' => 'فاتورة بقيمة صفر',
'hint' => 'صدرت فاتورة بلا سعر — الشهر لم يُحاسَب أصلاً.',
'severity' => 3,
],
'stacked_unpaid' => [
'label' => 'متأخرات متراكمة',
'hint' => 'فاتورتان فأكثر غير مسددتين — تُراجَع شهراً شهراً.',
'severity' => 4,
],
'unbilled_month' => [
'label' => 'شهر بلا فاتورة',
'hint' => 'شهر بين بداية الاشتراك واليوم لم تصدر له فاتورة اشتراك.',
'severity' => 5,
],
'missing_bundle' => [
'label' => 'لم يُحاسَب على مستلزم البرنامج',
'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.',
'severity' => 6,
],
'duplicate_person' => [
'label' => 'سجل مكرر',
'hint' => 'يوجد مشترك آخر بنفس الاسم والهاتف — الفواتير موزعة على سجلين.',
'severity' => 7,
],
];
/** How far back a missing month is worth flagging. */
private const MONTHS_BACK = 6;
/**
* Every participant with at least one case, worst first.
*
* @return array<int, array{participant:Participant, cases:array<int,string>,
* owed:int, paid:int, unpaid_invoices:int, severity:int, detail:array}>
*/
public function scan(?int $branchId = null, ?string $only = null, int $limit = 300): array
{
$participants = Participant::query()
->with(['person', 'enrollments' => fn ($q) => $q->where('status', 'active')->with('program')])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get();
if ($participants->isEmpty()) {
return [];
}
$ids = $participants->pluck('id')->all();
$money = $this->moneyByParticipant($ids);
$handTyped = $this->handTypedProductLines($participants);
$duplicates = $this->duplicateGroups($participants);
$missingBundle = $this->participantsMissingBundle($participants);
$unbilled = $this->unbilledMonths($participants);
$rows = [];
foreach ($participants as $participant) {
$m = $money[$participant->id] ?? null;
$cases = [];
$detail = [];
if ($m) {
if ($m['paid'] === 0 && $m['billed'] > 0 && $m['unpaid'] > 0) {
$cases[] = 'never_paid';
}
if ($m['unpaid'] >= 2) {
$cases[] = 'stacked_unpaid';
}
if ($m['zero_invoices'] > 0) {
$cases[] = 'zero_invoice';
$detail['zero_invoices'] = $m['zero_invoices'];
}
}
if (! empty($handTyped[$participant->id])) {
$cases[] = 'handtyped_product';
$detail['handtyped'] = $handTyped[$participant->id];
}
if (! empty($missingBundle[$participant->id])) {
$cases[] = 'missing_bundle';
$detail['missing_bundle'] = $missingBundle[$participant->id];
}
if (! empty($unbilled[$participant->id])) {
$cases[] = 'unbilled_month';
$detail['unbilled_months'] = $unbilled[$participant->id];
}
if (! empty($duplicates[$participant->id])) {
$cases[] = 'duplicate_person';
$detail['duplicate_of'] = $duplicates[$participant->id];
}
if ($cases === []) {
continue;
}
if ($only && ! in_array($only, $cases, true)) {
continue;
}
$rows[] = [
'participant' => $participant,
'cases' => $cases,
'owed' => $m['owed'] ?? 0,
'paid' => $m['paid'] ?? 0,
'unpaid_invoices' => $m['unpaid'] ?? 0,
'severity' => min(array_map(fn ($c) => self::CASES[$c]['severity'], $cases)),
'detail' => $detail,
];
}
usort($rows, function ($a, $b) {
return [$a['severity'], -$a['owed']] <=> [$b['severity'], -$b['owed']];
});
return array_slice($rows, 0, $limit);
}
/**
* One participant's full picture, for the wizard's diagnosis step: every
* invoice with what is left on it, the free-text lines that look like a
* product, the months nobody billed, and the bundle they were never charged
* for.
*/
public function forParticipant(Participant $participant): array
{
$money = $this->moneyByParticipant([$participant->id])[$participant->id] ?? [
'billed' => 0, 'paid' => 0, 'owed' => 0, 'unpaid' => 0, 'zero_invoices' => 0,
];
$collection = collect([$participant->loadMissing(['person', 'enrollments.program'])]);
return [
'money' => $money,
'handtyped' => $this->handTypedProductLines($collection)[$participant->id] ?? [],
'missing_bundle' => $this->participantsMissingBundle($collection)[$participant->id] ?? [],
'unbilled_months' => $this->unbilledMonths($collection)[$participant->id] ?? [],
'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [],
];
}
// ---- the individual probes -------------------------------------------
/**
* @param array<int,int> $ids
* @return array<int, array{billed:int, paid:int, owed:int, unpaid:int, zero_invoices:int}>
*/
private function moneyByParticipant(array $ids): array
{
$rows = DB::table('invoices')
->where('billable_type', Participant::class)
->whereIn('billable_id', $ids)
->whereNull('deleted_at')
->where('status', '!=', 'cancelled')
->groupBy('billable_id')
->select(
'billable_id',
DB::raw('SUM(total_amount) as billed'),
DB::raw('SUM(paid_amount) as paid'),
DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN total_amount - paid_amount ELSE 0 END) as owed'),
DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN 1 ELSE 0 END) as unpaid'),
DB::raw('SUM(CASE WHEN total_amount = 0 THEN 1 ELSE 0 END) as zero_invoices'),
)
->get();
$out = [];
foreach ($rows as $r) {
$out[(int) $r->billable_id] = [
'billed' => (int) $r->billed,
'paid' => (int) $r->paid,
'owed' => (int) $r->owed,
'unpaid' => (int) $r->unpaid,
'zero_invoices' => (int) $r->zero_invoices,
];
}
return $out;
}
/**
* Free-text lines that name a product the academy sells.
*
* Same reading BundledProductLine gives the roster: whole normalised words,
* matched against the words that identify the product and are not also a
* programme's.
*
* @return array<int, array<int, array{invoice_item_id:int, invoice_number:string,
* description:string, amount:int, product_id:int, product_name:string}>>
*/
private function handTypedProductLines($participants): array
{
$ids = $participants->pluck('id')->all();
if ($ids === []) {
return [];
}
$academyIds = $participants->pluck('academy_id')->filter()->unique()->all();
$products = DB::table('products')
->whereIn('academy_id', $academyIds)
->whereNull('deleted_at')
->get(['id', 'academy_id', 'name_ar']);
if ($products->isEmpty()) {
return [];
}
$programNames = DB::table('training_programs')
->whereIn('academy_id', $academyIds)
->whereNull('deleted_at')
->pluck('name_ar')
->filter()
->all();
$identifying = [];
foreach ($products as $product) {
$identifying[$product->id] = BundledProductLine::identifyingWords(
(string) $product->name_ar,
$programNames
);
}
$lines = DB::table('invoice_items')
->join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id')
->where('invoices.billable_type', Participant::class)
->whereIn('invoices.billable_id', $ids)
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->whereNull('invoice_items.itemable_type')
->get([
'invoice_items.id as item_id',
'invoice_items.description',
'invoice_items.total_amount',
'invoices.id as invoice_id',
'invoices.number',
'invoices.billable_id',
]);
$out = [];
foreach ($lines as $line) {
foreach ($products as $product) {
if (! BundledProductLine::matches($line->description, (string) $product->name_ar, $identifying[$product->id])) {
continue;
}
$out[(int) $line->billable_id][] = [
'invoice_item_id' => (int) $line->item_id,
'invoice_id' => (int) $line->invoice_id,
'invoice_number' => (string) $line->number,
'description' => (string) $line->description,
'amount' => (int) $line->total_amount,
'product_id' => (int) $product->id,
'product_name' => (string) $product->name_ar,
];
break;
}
}
return $out;
}
/**
* Programmes that require a product the participant has no money against.
*
* Reads both sides the roster reads: a product line, or a free-text line
* naming it. Someone who paid for the card in cash and had it typed by hand
* is not missing it — he is case `handtyped_product`, not this one.
*
* @return array<int, array<int, array{product_id:int, product_name:string, price:int}>>
*/
private function participantsMissingBundle($participants): array
{
if (! DB::getSchemaBuilder()->hasTable('program_products')) {
return [];
}
$programIds = [];
foreach ($participants as $participant) {
foreach ($participant->enrollments as $enrollment) {
$programIds[] = $enrollment->training_program_id;
}
}
$programIds = array_values(array_unique(array_filter($programIds)));
if ($programIds === []) {
return [];
}
$bundles = DB::table('program_products')
->whereIn('training_program_id', $programIds)
->where('is_required', true)
->get(['training_program_id', 'product_id']);
if ($bundles->isEmpty()) {
return [];
}
$productIds = $bundles->pluck('product_id')->unique()->all();
$products = DB::table('products')
->whereIn('id', $productIds)
->get(['id', 'name_ar', 'selling_price', 'member_price', 'non_member_price'])
->keyBy('id');
$billing = app(ParticipantBillingService::class);
$ids = $participants->pluck('id')->all();
$covered = [];
foreach ($productIds as $productId) {
$product = $products[$productId] ?? null;
if (! $product) {
continue;
}
$facts = $billing->bundledProductForParticipants($ids, (int) $productId, (string) $product->name_ar);
foreach ($facts as $pid => $row) {
if (($row['billed'] ?? 0) > 0 || ($row['paid'] ?? 0) > 0) {
$covered[$pid][$productId] = true;
}
}
}
$byProgram = [];
foreach ($bundles as $bundle) {
$byProgram[$bundle->training_program_id][] = $bundle->product_id;
}
$out = [];
foreach ($participants as $participant) {
if ($participant->is_free) {
continue;
}
foreach ($participant->enrollments as $enrollment) {
foreach ($byProgram[$enrollment->training_program_id] ?? [] as $productId) {
if (! empty($covered[$participant->id][$productId])) {
continue;
}
$product = $products[$productId] ?? null;
if (! $product) {
continue;
}
$tier = $participant->membership_type?->value ?? 'non_member';
$price = $tier === 'member'
? ($product->member_price ?? $product->selling_price)
: ($product->non_member_price ?? $product->selling_price);
$out[$participant->id][$productId] = [
'product_id' => (int) $productId,
'product_name' => (string) $product->name_ar,
'price' => (int) $price,
];
}
}
}
return array_map('array_values', $out);
}
/**
* Months between the enrolment starting and today that carry no invoice.
*
* A month counts as billed when an invoice names it in metadata (the
* renewal job and the settlement wizard both write that) or was simply
* issued inside it. Only the last few months are looked at: a club that
* started on paper in 2019 does not need a hundred flags.
*
* @return array<int, array<int, array{month:string, label:string, enrollment_id:int, program:string}>>
*/
private function unbilledMonths($participants): array
{
$ids = $participants->pluck('id')->all();
if ($ids === []) {
return [];
}
$invoices = DB::table('invoices')
->where('billable_type', Participant::class)
->whereIn('billable_id', $ids)
->whereNull('deleted_at')
->where('status', '!=', 'cancelled')
->get(['billable_id', 'issue_date', 'metadata']);
$billedMonths = [];
foreach ($invoices as $invoice) {
$pid = (int) $invoice->billable_id;
$billedMonths[$pid][substr((string) $invoice->issue_date, 0, 7)] = true;
$meta = json_decode((string) $invoice->metadata, true);
if (is_array($meta) && ! empty($meta['month'])) {
$billedMonths[$pid][(string) $meta['month']] = true;
}
}
$floor = now()->copy()->startOfMonth()->subMonths(self::MONTHS_BACK);
$out = [];
foreach ($participants as $participant) {
if ($participant->is_free) {
continue;
}
foreach ($participant->enrollments as $enrollment) {
$start = $enrollment->start_date
? Carbon::parse($enrollment->start_date)->startOfMonth()
: Carbon::parse($enrollment->enrollment_date ?? now())->startOfMonth();
if ($start->lt($floor)) {
$start = $floor->copy();
}
// The current month is still being collected; it is not late.
$end = now()->copy()->startOfMonth()->subMonth();
for ($cursor = $start->copy(); $cursor->lte($end); $cursor->addMonth()) {
$key = $cursor->format('Y-m');
if (! empty($billedMonths[$participant->id][$key])) {
continue;
}
$out[$participant->id][] = [
'month' => $key,
'label' => $cursor->translatedFormat('F Y'),
'enrollment_id' => (int) $enrollment->id,
'program' => $enrollment->program?->name_ar ?? '—',
];
}
}
}
return $out;
}
/**
* Two participant records for one child: same name, same guardian phone.
*
* @return array<int, array<int, array{id:int, name:string}>>
*/
private function duplicateGroups($participants): array
{
$names = $participants->map(fn ($p) => $p->person?->name_ar)->filter()->unique()->values();
if ($names->isEmpty()) {
return [];
}
$rows = DB::table('participants')
->join('people', 'people.id', '=', 'participants.person_id')
->whereIn('people.name_ar', $names->all())
->whereNull('participants.deleted_at')
->get(['participants.id', 'people.name_ar', 'people.phone']);
$groups = [];
foreach ($rows as $row) {
$key = $row->name_ar . '|' . ($row->phone ?? '');
$groups[$key][] = ['id' => (int) $row->id, 'name' => (string) $row->name_ar];
}
$out = [];
foreach ($groups as $members) {
if (count($members) < 2) {
continue;
}
foreach ($members as $member) {
$out[$member['id']] = array_values(array_filter(
$members,
fn ($other) => $other['id'] !== $member['id']
));
}
}
return $out;
}
}
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Events\SettlementApplied;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Models\Settlement;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Settling one participant's account: everything an operator has to be able to
* do to a file that reality got ahead of.
*
* The books say a player owes two months. The truth, once someone asks at the
* desk, is usually one of a handful of things: he paid in cash and nobody
* entered it; he joined on the 20th and paid for the days that were left, which
* closed that month; the academy agreed to drop a month; he bought a
* registration card that was typed into a free-text line instead of sold as a
* product; he was never billed for a month he trained. Each of those is a
* different correction, and until now the only tools were a single-invoice
* amount override and a back-fill wizard — so operators reached for whichever
* one was closest and the ledger drifted further.
*
* Every action here is applied inside ONE transaction and recorded as ONE
* settlement. Money movements go through PaymentService (so the ledger, the
* invoice balance and the receipt all happen), stock goes through
* InventoryService (so nothing touches quantity_on_hand directly), and a waiver
* is written the way the rest of the system already reads one — as an
* admin_override on the invoice, which is what makes the roster show
* "خصم إداري" instead of an unexplained shortfall.
*
* Nothing in here calls auth(), request() or session(): the actor, the branch
* and every amount arrive as parameters, because this service is called from a
* wizard today and will be called from a command or an import tomorrow.
*/
class SettlementService
{
/** Every action the wizard may ask for. Anything else is refused. */
public const ACTIONS = [
'record_payment',
'settle_short',
'waive_invoice',
'bill_month',
'sell_product',
'link_line',
'plan_installments',
'move_payment',
'credit_wallet',
];
private const ARABIC_MONTHS = [
1 => 'يناير', 2 => 'فبراير', 3 => 'مارس', 4 => 'أبريل',
5 => 'مايو', 6 => 'يونيو', 7 => 'يوليو', 8 => 'أغسطس',
9 => 'سبتمبر', 10 => 'أكتوبر', 11 => 'نوفمبر', 12 => 'ديسمبر',
];
public function __construct(
private InvoiceService $invoices,
private PaymentService $payments,
private InventoryService $inventory,
private WalletService $wallets,
) {}
/**
* Apply a reviewed set of corrections to one participant's account.
*
* All or nothing: a settlement that fails halfway is a worse state than the
* one it started from, because the operator has already told the parent the
* account is clear.
*
* @param array<int, array<string, mixed>> $actions
*/
public function apply(
Participant $participant,
array $actions,
string $reason,
User $actor,
?int $branchId = null
): Settlement {
if ($actions === []) {
throw new DomainException('لا توجد إجراءات لتنفيذها');
}
if (mb_strlen(trim($reason)) < 10) {
throw new DomainException('سبب التسوية مطلوب (10 أحرف على الأقل)');
}
foreach ($actions as $action) {
if (! in_array($action['type'] ?? '', self::ACTIONS, true)) {
throw new DomainException('إجراء غير معروف: ' . ($action['type'] ?? '—'));
}
}
$branchId ??= $participant->branch_id;
return DB::transaction(function () use ($participant, $actions, $reason, $actor, $branchId) {
$results = [];
$collected = 0;
$waived = 0;
$billed = 0;
foreach ($actions as $action) {
$result = match ($action['type']) {
'record_payment' => $this->recordPayment($participant, $action, $actor, $branchId),
'settle_short' => $this->settleShort($participant, $action, $actor, $branchId),
'waive_invoice' => $this->waiveInvoice($participant, $action, $actor),
'bill_month' => $this->billMonth($participant, $action, $actor, $branchId),
'sell_product' => $this->sellProduct($participant, $action, $actor, $branchId),
'link_line' => $this->linkLine($participant, $action),
'plan_installments' => $this->planInstallments($participant, $action),
'move_payment' => $this->movePayment($participant, $action, $actor),
'credit_wallet' => $this->creditWallet($participant, $action, $actor),
};
$collected += $result['collected'] ?? 0;
$waived += $result['waived'] ?? 0;
$billed += $result['billed'] ?? 0;
$results[] = ['type' => $action['type']] + $result;
}
$settlement = Settlement::create([
'academy_id' => $participant->academy_id,
'branch_id' => $branchId,
'participant_id' => $participant->id,
// Placeholder: the readable reference is the row's own id, which
// does not exist until it is inserted. See below.
'reference' => 'SET-' . \Illuminate\Support\Str::uuid(),
'reason' => trim($reason),
'actions' => $results,
'collected_amount' => $collected,
'waived_amount' => $waived,
'billed_amount' => $billed,
'applied_by' => $actor->id,
'applied_at' => now(),
]);
// Numbered from the row's own id rather than from a counted
// max(id): counting needs a lock to be safe, and Postgres refuses
// FOR UPDATE on an aggregate — so two settlements saved in the same
// second would race for the same number and one whole settlement
// would be rolled back after the operator had already confirmed it.
$settlement->update([
'reference' => 'SET-' . str_pad((string) $settlement->id, 6, '0', STR_PAD_LEFT),
]);
Log::channel('audit')->info('settlement_applied', [
'settlement_id' => $settlement->id,
'reference' => $settlement->reference,
'participant_id' => $participant->id,
'actor_id' => $actor->id,
'actor_name' => $actor->name,
'branch_id' => $branchId,
'collected' => $collected,
'waived' => $waived,
'billed' => $billed,
'reason' => $settlement->reason,
'actions' => $results,
]);
SettlementApplied::dispatch($settlement, $actor);
return $settlement;
});
}
// ---- actions ----------------------------------------------------------
/**
* Money that was handed over and never entered. The date is the day it was
* actually taken, not today — a receipt dated three weeks late is still a
* receipt, but a payment filed on the wrong day makes every daily closing
* after it wrong.
*/
private function recordPayment(Participant $participant, array $a, User $actor, ?int $branchId): array
{
$invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
$amount = (int) ($a['amount'] ?? 0);
if ($amount <= 0) {
throw new DomainException('مبلغ الدفعة يجب أن يكون أكبر من صفر');
}
$payment = $this->payments->recordPayment([
'invoice_id' => $invoice->id,
'branch_id' => $invoice->branch_id ?: $branchId,
'amount' => $amount,
'method' => $a['method'] ?? 'cash',
'direction' => 'inbound',
'currency' => $invoice->currency ?? 'EGP',
'payment_date' => $this->date($a['date'] ?? null)->toDateString(),
'notes' => $this->note($a, 'تسوية: دفعة سابقة لم تُسجَّل'),
], $actor);
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'payment_id' => $payment->id,
'collected' => $amount,
'label' => 'تحصيل ' . format_money($amount) . ' على ' . $invoice->number,
];
}
/**
* The month closed for less than it was billed — he joined on the 20th and
* paid for the days that were left, or the academy agreed a number. What
* was collected is collected; the rest is written off with a reason, and
* the month stops being owed.
*/
private function settleShort(Participant $participant, array $a, User $actor, ?int $branchId): array
{
$invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
$amount = (int) ($a['amount'] ?? 0);
if ($amount < 0) {
throw new DomainException('المبلغ لا يمكن أن يكون سالباً');
}
$paymentId = null;
if ($amount > 0) {
$payment = $this->payments->recordPayment([
'invoice_id' => $invoice->id,
'branch_id' => $invoice->branch_id ?: $branchId,
'amount' => $amount,
'method' => $a['method'] ?? 'cash',
'direction' => 'inbound',
'currency' => $invoice->currency ?? 'EGP',
'payment_date' => $this->date($a['date'] ?? null)->toDateString(),
'notes' => $this->note($a, 'تسوية: سداد جزئي يُغلق الشهر'),
], $actor);
$paymentId = $payment->id;
}
$waived = $this->writeOffRemainder($invoice, $actor, $this->note($a, 'تسوية: إغلاق الشهر بالمبلغ المحصَّل'));
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'payment_id' => $paymentId,
'collected' => $amount,
'waived' => $waived,
'label' => 'إغلاق ' . $invoice->number . ' بـ ' . format_money($amount)
. ($waived > 0 ? ' وإسقاط ' . format_money($waived) : ''),
];
}
/** The month is dropped entirely: nothing was owed, or nothing will be collected. */
private function waiveInvoice(Participant $participant, array $a, User $actor): array
{
$invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
// An invoice raised at zero — no price existed when it was issued — has
// nothing to write off, and leaving it standing keeps a meaningless row
// in the member's file forever. Dropping it is the whole intent here.
if ((int) $invoice->total_amount === 0 && (int) $invoice->paid_amount === 0) {
$this->invoices->cancel($invoice, $actor);
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'waived' => 0,
'label' => 'إلغاء فاتورة صفرية ' . $invoice->number,
];
}
$waived = $this->writeOffRemainder($invoice, $actor, $this->note($a, 'تسوية: إسقاط الشهر'));
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'waived' => $waived,
'label' => 'إسقاط ' . $invoice->number . ' (' . format_money($waived) . ')',
];
}
/**
* A month he trained and nobody billed. Written with the month named in the
* line, not implied by the issue date, so the roster files it under the
* month it pays for however late it is entered (see SubscriptionLine).
*/
private function billMonth(Participant $participant, array $a, User $actor, ?int $branchId): array
{
$amount = (int) ($a['amount'] ?? 0);
if ($amount <= 0) {
throw new DomainException('قيمة الشهر يجب أن تكون أكبر من صفر');
}
$month = (string) ($a['month'] ?? '');
if (! preg_match('/^\d{4}-\d{2}$/', $month)) {
throw new DomainException('الشهر يجب أن يكون بصيغة YYYY-MM');
}
$enrollment = Enrollment::withoutGlobalScopes()
->with('program')
->where('id', (int) ($a['enrollment_id'] ?? 0))
->where('participant_id', $participant->id)
->first();
if (! $enrollment) {
throw new DomainException('الاشتراك غير موجود لهذا المشترك');
}
$monthDate = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfDay();
$label = self::ARABIC_MONTHS[$monthDate->month] . ' ' . $monthDate->year;
$programName = $enrollment->program?->name_ar ?? 'اشتراك';
$invoice = $this->invoices->create([
'academy_id' => $participant->academy_id,
'branch_id' => $enrollment->branch_id ?: ($branchId ?: $participant->branch_id),
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'total_amount' => $amount,
'subtotal_amount' => $amount,
'discount_amount' => 0,
'tax_amount' => 0,
'service_fee_amount' => 0,
'issue_date' => $monthDate->toDateString(),
'due_date' => $monthDate->copy()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar,
'notes' => 'تسوية: اشتراك ' . $label . ' — ' . $programName,
'metadata' => ['settlement' => true, 'month' => $month],
], [
[
'description' => 'اشتراك ' . $label . ': ' . $programName,
'quantity' => 1,
'unit_price' => $amount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
$invoice->update(['status' => InvoiceStatus::Sent]);
$paid = (int) ($a['paid_amount'] ?? 0);
$paymentId = null;
$waived = 0;
if ($paid > 0) {
$payment = $this->payments->recordPayment([
'invoice_id' => $invoice->id,
'branch_id' => $invoice->branch_id,
'amount' => min($paid, $amount),
'method' => $a['method'] ?? 'cash',
'direction' => 'inbound',
'currency' => 'EGP',
'payment_date' => $this->date($a['date'] ?? null, $monthDate)->toDateString(),
'notes' => $this->note($a, 'تسوية: تحصيل شهر ' . $label),
], $actor);
$paymentId = $payment->id;
}
// "Paid a part and the month is closed" is the same decision as
// settle_short, expressed while creating the invoice.
if (! empty($a['close_month']) && $paid < $amount) {
$waived = $this->writeOffRemainder($invoice->fresh(), $actor, $this->note($a, 'تسوية: إغلاق ' . $label));
}
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'payment_id' => $paymentId,
'month' => $month,
'billed' => $amount,
'collected' => min($paid, $amount),
'waived' => $waived,
'label' => 'فاتورة ' . $label . ' بمبلغ ' . format_money($amount),
];
}
/**
* A card or a kit he already has, that was never sold through the system.
*
* The line carries the product's morph — that is what makes it a product
* sale in every report rather than a description someone typed — and the
* stock leaves the warehouse unless the operator says the count was already
* adjusted by hand.
*/
private function sellProduct(Participant $participant, array $a, User $actor, ?int $branchId): array
{
$product = Product::withoutGlobalScopes()
->where('id', (int) ($a['product_id'] ?? 0))
->where('academy_id', $participant->academy_id)
->first();
if (! $product) {
throw new DomainException('المنتج غير موجود');
}
$quantity = max(1, (int) ($a['quantity'] ?? 1));
$unitPrice = (int) ($a['unit_price'] ?? 0);
if ($unitPrice <= 0) {
throw new DomainException('سعر المنتج يجب أن يكون أكبر من صفر');
}
$total = $unitPrice * $quantity;
$date = $this->date($a['date'] ?? null);
$invoiceBranch = $branchId ?: $participant->branch_id;
$invoice = $this->invoices->create([
'academy_id' => $participant->academy_id,
'branch_id' => $invoiceBranch,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'total_amount' => $total,
'subtotal_amount' => $total,
'discount_amount' => 0,
'tax_amount' => 0,
'service_fee_amount' => 0,
'issue_date' => $date->toDateString(),
'due_date' => $date->toDateString(),
'contact_name' => $participant->person?->name_ar,
'notes' => 'تسوية: ' . $product->name_ar,
'metadata' => ['settlement' => true, 'retro_product_sale' => true],
], [
[
'description' => $product->name_ar,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'discount_amount' => 0,
'tax_amount' => 0,
'itemable_type' => $product->getMorphClass(),
'itemable_id' => $product->id,
],
], $actor);
$invoice->update(['status' => InvoiceStatus::Sent]);
$paid = min((int) ($a['paid_amount'] ?? 0), $total);
$paymentId = null;
if ($paid > 0) {
$payment = $this->payments->recordPayment([
'invoice_id' => $invoice->id,
'branch_id' => $invoice->branch_id,
'amount' => $paid,
'method' => $a['method'] ?? 'cash',
'direction' => 'inbound',
'currency' => 'EGP',
'payment_date' => $date->toDateString(),
'notes' => $this->note($a, 'تسوية: ' . $product->name_ar),
], $actor);
$paymentId = $payment->id;
}
$movementId = null;
if (! empty($a['adjust_stock']) && $product->track_inventory) {
$movementId = $this->deductStock($product, $quantity, $invoiceBranch, $actor, $invoice->number);
}
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'payment_id' => $paymentId,
'product_id' => $product->id,
'movement_id' => $movementId,
'billed' => $total,
'collected' => $paid,
'label' => 'بيع ' . $product->name_ar . ' بأثر رجعي (' . format_money($total) . ')',
];
}
/**
* The money is already on an invoice; only its classification is wrong.
*
* No amount changes — this is the difference between "a line someone typed"
* and "a sale of this product", which is what every product report, and the
* roster's bundle column, is counting.
*/
private function linkLine(Participant $participant, array $a): array
{
$item = InvoiceItem::withoutGlobalScopes()
->with('invoice')
->where('id', (int) ($a['invoice_item_id'] ?? 0))
->first();
if (! $item || ! $item->invoice
|| (int) $item->invoice->billable_id !== (int) $participant->id
|| $item->invoice->billable_type !== $participant->getMorphClass()) {
throw new DomainException('البند غير موجود على فواتير هذا المشترك');
}
if ($item->itemable_type !== null) {
throw new DomainException('البند مرتبط بمنتج بالفعل');
}
$product = Product::withoutGlobalScopes()
->where('id', (int) ($a['product_id'] ?? 0))
->where('academy_id', $participant->academy_id)
->first();
if (! $product) {
throw new DomainException('المنتج غير موجود');
}
$item->update([
'itemable_type' => $product->getMorphClass(),
'itemable_id' => $product->id,
]);
return [
'invoice_item_id' => $item->id,
'invoice_id' => $item->invoice_id,
'invoice_number' => $item->invoice->number,
'product_id' => $product->id,
'label' => 'ربط بند «' . $item->description . '» بمنتج ' . $product->name_ar,
];
}
/**
* Put an agreed instalment plan on record, with the instalments already
* collected counted. Without this a card being paid in three goes reads as
* a shortfall and the coach chases money nobody owes yet.
*/
private function planInstallments(Participant $participant, array $a): array
{
$invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
$total = (int) ($a['total_installments'] ?? 0);
$paidCount = (int) ($a['paid_installments'] ?? 0);
if ($total < 2) {
throw new DomainException('عدد الأقساط يجب أن يكون اثنين على الأقل');
}
if ($paidCount < 0 || $paidCount > $total) {
throw new DomainException('عدد الأقساط المسددة غير منطقي');
}
$existing = PaymentPlan::withoutGlobalScopes()->where('invoice_id', $invoice->id)->first();
if ($existing) {
throw new DomainException('للفاتورة خطة أقساط بالفعل');
}
$amount = (int) ($a['installment_amount'] ?? intdiv($invoice->total_amount, $total));
$plan = PaymentPlan::create([
'academy_id' => $invoice->academy_id,
'invoice_id' => $invoice->id,
// Only these four exist in payment_plans_status_check; a plan with
// some instalments paid is still `active`, and the count is what
// says how far along it is.
'status' => $paidCount >= $total ? 'completed' : 'active',
'total_installments' => $total,
'paid_installments' => $paidCount,
'installment_amount' => $amount,
'frequency' => $a['frequency'] ?? 'monthly',
'start_date' => $invoice->issue_date?->toDateString() ?? now()->toDateString(),
'next_due_date' => $this->date($a['next_due_date'] ?? null)->toDateString(),
'notes' => $this->note($a, 'تسوية: خطة أقساط متفق عليها'),
]);
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'payment_plan_id' => $plan->id,
'label' => 'خطة أقساط ' . $paidCount . '/' . $total . ' على ' . $invoice->number,
];
}
/**
* The money was taken, entered, and put against the wrong month.
*
* Common when two invoices are open at once: the receptionist settles
* whichever the screen offered, and September's money closes July while
* September goes on looking unpaid. The payment is reversed off the invoice
* it should never have touched and re-recorded against the right one,
* keeping its original date and method — both invoices end up telling the
* truth, and the ledger carries the reversal rather than an edit.
*/
private function movePayment(Participant $participant, array $a, User $actor): array
{
$payment = Payment::withoutGlobalScopes()
->where('id', (int) ($a['payment_id'] ?? 0))
->whereNull('deleted_at')
->first();
if (! $payment || ! $payment->invoice_id) {
throw new DomainException('الدفعة غير موجودة');
}
$from = $this->participantInvoice($participant, (int) $payment->invoice_id);
$to = $this->participantInvoice($participant, (int) ($a['to_invoice_id'] ?? 0));
if ($from->id === $to->id) {
throw new DomainException('الدفعة على هذه الفاتورة بالفعل');
}
if ($payment->direction !== 'inbound' || $payment->status !== PaymentStatus::Confirmed) {
throw new DomainException('لا يمكن نقل إلا دفعة واردة مؤكدة');
}
$amount = (int) $payment->amount;
if ($amount > (int) $to->due_amount) {
throw new DomainException(
'المبلغ أكبر من المتبقي على ' . $to->number . ' (' . format_money($to->due_amount) . ')'
);
}
// Reverse it off the wrong invoice: an outbound row, the ledger entry
// that goes with it, and the balance restored. The original payment row
// is left standing — payments are history, not a draft.
$this->payments->refund($payment, $amount, $actor);
$moved = $this->payments->recordPayment([
'invoice_id' => $to->id,
'branch_id' => $to->branch_id ?: $payment->branch_id,
'amount' => $amount,
'method' => $payment->method,
'direction' => 'inbound',
'currency' => $payment->currency ?? 'EGP',
'payment_date' => $payment->payment_date?->toDateString() ?? now()->toDateString(),
'notes' => $this->note($a, 'تسوية: نقل دفعة من ' . $from->number . ' إلى ' . $to->number),
], $actor);
return [
'payment_id' => $moved->id,
'reversed_payment_id' => $payment->id,
'from_invoice_id' => $from->id,
'invoice_id' => $to->id,
'invoice_number' => $to->number,
// Not counted as collected: this money was already in the books.
'label' => 'نقل ' . format_money($amount) . ' من ' . $from->number . ' إلى ' . $to->number,
];
}
/**
* Money in hand that belongs to the member rather than to any invoice — he
* paid 1,000 against a 900 bill, or a month he had already settled was
* later dropped.
*
* It goes on his wallet as a credit he can spend on the next invoice, which
* is what the wallet is for. It is not counted as collected: the cash was
* recorded when it arrived, and counting it twice would overstate the day.
*/
private function creditWallet(Participant $participant, array $a, User $actor): array
{
$amount = (int) ($a['amount'] ?? 0);
if ($amount <= 0) {
throw new DomainException('قيمة الرصيد يجب أن تكون أكبر من صفر');
}
$wallet = $this->wallets->getOrCreateWallet($participant, (int) $participant->academy_id);
$transaction = $this->wallets->deposit(
$wallet,
$amount,
$this->note($a, 'تسوية: رصيد لصالح المشترك'),
$participant,
$actor,
);
return [
'wallet_id' => $wallet->id,
'wallet_transaction_id' => $transaction->id,
'label' => 'إضافة ' . format_money($amount) . ' لمحفظة المشترك',
];
}
// ---- helpers ----------------------------------------------------------
/**
* Write the remaining balance off the invoice, in the shape the rest of the
* system already reads: discount_amount grows, total_amount shrinks,
* subtotal_amount is untouched — which is what lets
* ParticipantBillingService still say "650 of 900, because an admin
* discounted it" rather than losing the original figure.
*
* @return int piastres written off
*/
private function writeOffRemainder(Invoice $invoice, User $actor, string $reason): int
{
$invoice = Invoice::withoutGlobalScopes()
->where('id', $invoice->id)
->lockForUpdate()
->firstOrFail();
$remaining = max(0, (int) $invoice->total_amount - (int) $invoice->paid_amount);
if ($remaining <= 0) {
return 0;
}
$originalTotal = (int) $invoice->total_amount;
$newTotal = (int) $invoice->paid_amount;
$metadata = $invoice->metadata ?? [];
$metadata['admin_override'] = [
'applied_at' => now()->toIso8601String(),
'applied_by_id' => $actor->id,
'applied_by_name' => $actor->name,
'original_total_piasters' => $originalTotal,
'original_due_piasters' => $remaining,
'new_total_piasters' => $newTotal,
'discount_piasters' => $remaining,
'reason' => $reason,
'source' => 'settlement',
];
$invoice->update([
'discount_amount' => (int) $invoice->discount_amount + $remaining,
'total_amount' => $newTotal,
'due_amount' => 0,
// Nothing was ever collected and nothing will be: the month drops
// out of the books entirely rather than sitting at zero pretending
// to be a settled bill.
'status' => $newTotal > 0 ? InvoiceStatus::Paid : InvoiceStatus::Cancelled,
'paid_at' => $newTotal > 0 ? ($invoice->paid_at ?? now()) : $invoice->paid_at,
'cancelled_at' => $newTotal > 0 ? $invoice->cancelled_at : now(),
'metadata' => $metadata,
]);
return $remaining;
}
private function deductStock(Product $product, int $quantity, ?int $branchId, User $actor, string $invoiceNumber): ?int
{
// Same resolution the POS uses: the branch's own warehouse, falling
// back to the academy-wide one, and silence rather than a failed
// settlement when a branch has none configured.
$warehouse = Warehouse::withoutGlobalScopes()
->where('is_active', true)
->where(fn ($q) => $q->where('branch_id', $branchId)->orWhereNull('branch_id'))
->orderByRaw('CASE WHEN branch_id = ? THEN 0 ELSE 1 END', [$branchId])
->first();
if (! $warehouse) {
return null;
}
$movement = $this->inventory->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::Sale,
quantity: $quantity,
actor: $actor,
unitCost: $product->cost_price,
reason: 'تسوية: بيع بأثر رجعي — ' . $invoiceNumber,
);
return $movement->id;
}
/** An invoice that actually belongs to this participant. */
private function participantInvoice(Participant $participant, int $invoiceId): Invoice
{
$invoice = Invoice::withoutGlobalScopes()
->where('id', $invoiceId)
->whereNull('deleted_at')
->first();
if (! $invoice
|| (int) $invoice->billable_id !== (int) $participant->id
|| $invoice->billable_type !== $participant->getMorphClass()) {
throw new DomainException('الفاتورة غير موجودة لهذا المشترك');
}
if ($invoice->status === InvoiceStatus::Cancelled) {
throw new DomainException('الفاتورة ملغاة: ' . $invoice->number);
}
return $invoice;
}
private function date(?string $value, ?Carbon $fallback = null): Carbon
{
if ($value) {
$date = Carbon::parse($value);
if ($date->isFuture()) {
throw new DomainException('لا يمكن تسجيل تسوية بتاريخ مستقبلي');
}
return $date;
}
return $fallback ?? now();
}
private function note(array $action, string $default): string
{
$note = trim((string) ($action['note'] ?? ''));
return $note !== '' ? $note : $default;
}
}
...@@ -92,6 +92,22 @@ public function isAnnual(): bool ...@@ -92,6 +92,22 @@ public function isAnnual(): bool
return $this->billing_cycle === 'annual'; 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 public function category(): BelongsTo
{ {
return $this->belongsTo(ProductCategory::class, 'category_id'); return $this->belongsTo(ProductCategory::class, 'category_id');
......
<?php
namespace App\Livewire\Admin;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Financial\Services\SettlementService;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Settle one participant's account, end to end.
*
* The desk already had four tools that each did a slice of this — collect a
* payment, correct one invoice's amount, back-fill missing invoices, register
* someone who started months ago — and none of them could answer the question
* an operator actually has in front of a parent: *this file is wrong, in
* several ways at once, what do we do about all of it?* So the corrections were
* made wherever a screen allowed them, and the ledger drifted.
*
* This screen puts the whole account on one page: every invoice with what is
* left on it, every free-text line that is really a product sale, every month
* nobody billed, and the bundle nobody charged for. The operator builds a list
* of corrections, sees exactly what it will collect, waive and bill, gives a
* reason, and applies it as one settlement.
*
* Nothing is written until the last step, and everything is written in one
* transaction (SettlementService). Branch handling is the models' — Participant
* and Invoice both carry BranchScope, so the search, the invoice list and the
* settlement all stay inside the branch the operator is in.
*/
#[Layout('layouts.app')]
#[Title('تسوية حساب مشترك')]
class AccountSettlementWizard extends Component
{
public int $currentStep = 1;
// Step 1
public string $search = '';
/**
* Locked: resolved server-side from a branch-scoped query. A plain public
* property here would let the browser point the whole wizard at another
* branch's participant — validating in mount() and then trusting it in
* render() is exactly the IDOR the Livewire rule warns about.
*/
#[Locked]
public ?int $participantId = null;
/**
* The corrections chosen so far. Every id inside is re-checked against this
* participant by SettlementService when the settlement is applied, so a
* tampered cart cannot reach another player's invoice.
*
* @var array<int, array<string, mixed>>
*/
public array $cart = [];
// The open editor: which correction is being described right now.
public ?string $draftType = null;
public array $draft = [];
// Step 3
public string $reason = '';
// Step 4
#[Locked]
public ?array $result = null;
public function mount(?int $participant = null): void
{
$this->authorize('settlements.manage');
if ($participant) {
// Scoped find: a participant from another branch, or one who has
// been deleted, must not open here. Saying so beats dropping the
// operator back on the search box with no explanation — they
// followed a link and deserve to know why it went nowhere.
$found = Participant::with('person')->find($participant);
if ($found) {
$this->participantId = $found->id;
$this->currentStep = 2;
} else {
session()->flash('error', __('هذا المشترك غير متاح في الفرع الحالي أو تم حذفه — ابحث عنه بالاسم.'));
}
}
}
// ---- step 1 -----------------------------------------------------------
public function selectParticipant(int $id): void
{
$this->authorize('settlements.manage');
// Branch-scoped find: the global scope is the check.
$participant = Participant::find($id);
if (! $participant) {
session()->flash('error', __('المشترك غير موجود'));
return;
}
$this->participantId = $participant->id;
$this->cart = [];
$this->draftType = null;
$this->draft = [];
$this->currentStep = 2;
}
public function searchResults()
{
if (mb_strlen(trim($this->search)) < 2) {
return collect();
}
$term = '%' . trim($this->search) . '%';
return Participant::with(['person', 'enrollments.program'])
->whereHas('person', fn ($q) => $q->where('name_ar', 'like', $term)
->orWhere('name', 'like', $term)
->orWhere('phone', 'like', $term))
->limit(15)
->get();
}
// ---- step 2: the account ---------------------------------------------
public function getParticipantProperty(): ?Participant
{
if (! $this->participantId) {
return null;
}
return Participant::with(['person', 'enrollments.program'])->find($this->participantId);
}
public function getInvoicesProperty()
{
if (! $this->participantId) {
return collect();
}
return Invoice::with('items')
->where('billable_type', Participant::class)
->where('billable_id', $this->participantId)
->orderBy('issue_date')
->get();
}
/**
* Confirmed money already on this account, so a payment filed against the
* wrong month can be pointed at the right one.
*/
public function getPaymentsProperty()
{
if (! $this->participantId) {
return collect();
}
$invoiceIds = $this->invoices->pluck('id');
if ($invoiceIds->isEmpty()) {
return collect();
}
return \App\Domain\Financial\Models\Payment::query()
->whereIn('invoice_id', $invoiceIds)
->where('direction', 'inbound')
->where('status', \App\Domain\Financial\Enums\PaymentStatus::Confirmed)
->orderBy('payment_date')
->get();
}
public function getDiagnosisProperty(): array
{
$participant = $this->participant;
if (! $participant) {
return ['money' => [], 'handtyped' => [], 'missing_bundle' => [], 'unbilled_months' => [], 'duplicate_of' => []];
}
return app(AccountAnomalyScanner::class)->forParticipant($participant);
}
/**
* What a month of this participant's programme costs today — the number the
* operator starts from when billing a month nobody billed. The pricing
* engine is the authority; when it has no price (which is a hard fail at
* sale time, deliberately) the last subscription invoice is the next best
* evidence, and the operator can always overwrite it.
*/
public function getSuggestedMonthlyPriceProperty(): int
{
$participant = $this->participant;
$enrollment = $participant?->enrollments->first();
if ($enrollment?->program) {
try {
return (int) app(PricingService::class)->calculate(
priceable: $enrollment->program,
participant: $participant,
branchId: $enrollment->branch_id ?? $participant->branch_id,
)->finalAmount;
} catch (\Throwable) {
// No active price: fall through to the evidence on the books.
}
}
return (int) ($this->invoices->where('total_amount', '>', 0)->last()?->total_amount ?? 0);
}
public function getBundleProductsProperty()
{
$participant = $this->participant;
if (! $participant) {
return collect();
}
$programIds = $participant->enrollments->pluck('training_program_id')->filter()->all();
if ($programIds === []) {
return Product::where('is_essential', true)->where('is_active', true)->get();
}
return Product::where('is_active', true)
->where(fn ($q) => $q->where('is_essential', true)
->orWhereHas('programs', fn ($p) => $p->whereIn('training_programs.id', $programIds)))
->get();
}
// ---- building the settlement -----------------------------------------
public function startDraft(string $type, array $context = []): void
{
if (! in_array($type, SettlementService::ACTIONS, true)) {
return;
}
$this->resetErrorBag();
$this->draftType = $type;
$this->draft = $this->defaultsFor($type, $context);
}
public function cancelDraft(): void
{
$this->draftType = null;
$this->draft = [];
$this->resetErrorBag();
}
/**
* Defaults that reflect what usually happened, so the common case is a
* click: a payment defaults to the whole outstanding amount, a retroactive
* sale to the price for this member's tier.
*/
private function defaultsFor(string $type, array $context): array
{
$today = now()->toDateString();
return match ($type) {
'record_payment', 'settle_short' => [
'invoice_id' => (int) ($context['invoice_id'] ?? 0),
'invoice_number' => (string) ($context['invoice_number'] ?? ''),
'amount' => $this->money((int) ($context['due'] ?? 0)),
// Today by default: money taken now is the ordinary case, and
// back-dating is a deliberate act the operator types.
'date' => $today,
'method' => 'cash',
'note' => '',
],
'waive_invoice' => [
'invoice_id' => (int) ($context['invoice_id'] ?? 0),
'invoice_number' => (string) ($context['invoice_number'] ?? ''),
'due' => (int) ($context['due'] ?? 0),
'note' => '',
],
'bill_month' => [
'enrollment_id' => (int) ($context['enrollment_id'] ?? $this->participant?->enrollments->first()?->id ?? 0),
'month' => (string) ($context['month'] ?? now()->subMonth()->format('Y-m')),
'amount' => $this->money($this->suggestedMonthlyPrice),
'paid_amount' => '0',
'close_month' => false,
'method' => 'cash',
'date' => $today,
'note' => '',
],
'sell_product' => [
'product_id' => (int) ($context['product_id'] ?? 0),
'quantity' => 1,
'unit_price' => $this->money((int) ($context['price'] ?? 0)),
'paid_amount' => $this->money((int) ($context['price'] ?? 0)),
'method' => 'cash',
'date' => $today,
'adjust_stock' => false,
'note' => '',
],
'move_payment' => [
'payment_id' => (int) ($context['payment_id'] ?? 0),
'from_invoice_number' => (string) ($context['from_invoice_number'] ?? ''),
'amount_display' => (string) ($context['amount_display'] ?? ''),
'to_invoice_id' => 0,
'note' => '',
],
'credit_wallet' => [
'amount' => '0.00',
'note' => '',
],
'link_line' => [
'invoice_item_id' => (int) ($context['invoice_item_id'] ?? 0),
'product_id' => (int) ($context['product_id'] ?? 0),
'description' => (string) ($context['description'] ?? ''),
'product_name' => (string) ($context['product_name'] ?? ''),
],
'plan_installments' => [
'invoice_id' => (int) ($context['invoice_id'] ?? 0),
'invoice_number' => (string) ($context['invoice_number'] ?? ''),
'total_installments' => 3,
'paid_installments' => 1,
'installment_amount' => $this->money(intdiv((int) ($context['total'] ?? 0), 3)),
'next_due_date' => now()->addMonth()->toDateString(),
'note' => '',
],
default => [],
};
}
public function addDraft(): void
{
$this->authorize('settlements.manage');
if (! $this->draftType) {
return;
}
$errors = $this->validateDraft();
if ($errors !== []) {
foreach ($errors as $field => $message) {
$this->addError('draft.' . $field, $message);
}
return;
}
$this->cart[] = $this->normaliseDraft();
$this->draftType = null;
$this->draft = [];
}
public function removeCartItem(int $index): void
{
unset($this->cart[$index]);
$this->cart = array_values($this->cart);
}
/**
* Front-of-house validation only. Every rule that protects money — the
* invoice belongs to this participant, the payment does not exceed what is
* due, the date is not in the future — is enforced again inside the
* service, because a disabled button is not a control.
*
* @return array<string, string>
*/
private function validateDraft(): array
{
$errors = [];
$type = $this->draftType;
$d = $this->draft;
$amount = fn ($key) => (int) round(((float) ($d[$key] ?? 0)) * 100);
if (in_array($type, ['record_payment', 'settle_short'], true)) {
if ($amount('amount') <= 0 && $type === 'record_payment') {
$errors['amount'] = __('أدخل مبلغاً أكبر من صفر');
}
if ($amount('amount') < 0) {
$errors['amount'] = __('المبلغ لا يمكن أن يكون سالباً');
}
$invoice = $this->invoices->firstWhere('id', (int) ($d['invoice_id'] ?? 0));
if (! $invoice) {
$errors['invoice_id'] = __('الفاتورة غير موجودة');
} elseif ($amount('amount') > (int) $invoice->due_amount) {
$errors['amount'] = __('المبلغ أكبر من المتبقي على الفاتورة');
}
if (! empty($d['date']) && Carbon::parse($d['date'])->isFuture()) {
$errors['date'] = __('لا يمكن اختيار تاريخ في المستقبل');
}
}
if ($type === 'bill_month') {
if ($amount('amount') <= 0) {
$errors['amount'] = __('أدخل قيمة الشهر');
}
if (! preg_match('/^\d{4}-\d{2}$/', (string) ($d['month'] ?? ''))) {
$errors['month'] = __('اختر الشهر');
}
if ($amount('paid_amount') > $amount('amount')) {
$errors['paid_amount'] = __('المحصَّل أكبر من قيمة الشهر');
}
if (empty($d['enrollment_id'])) {
$errors['enrollment_id'] = __('لا يوجد اشتراك لهذا المشترك');
}
}
if ($type === 'sell_product') {
if (empty($d['product_id'])) {
$errors['product_id'] = __('اختر المنتج');
}
if ($amount('unit_price') <= 0) {
$errors['unit_price'] = __('أدخل سعر المنتج');
}
if ($amount('paid_amount') > $amount('unit_price') * max(1, (int) ($d['quantity'] ?? 1))) {
$errors['paid_amount'] = __('المحصَّل أكبر من قيمة البيع');
}
}
if ($type === 'link_line' && (empty($d['invoice_item_id']) || empty($d['product_id']))) {
$errors['product_id'] = __('اختر المنتج');
}
if ($type === 'move_payment') {
if (empty($d['payment_id'])) {
$errors['payment_id'] = __('اختر الدفعة');
}
if (empty($d['to_invoice_id'])) {
$errors['to_invoice_id'] = __('اختر الفاتورة التي تخصها الدفعة');
}
}
if ($type === 'credit_wallet' && $amount('amount') <= 0) {
$errors['amount'] = __('أدخل مبلغاً أكبر من صفر');
}
if ($type === 'plan_installments') {
if ((int) ($d['total_installments'] ?? 0) < 2) {
$errors['total_installments'] = __('عدد الأقساط اثنان على الأقل');
}
if ((int) ($d['paid_installments'] ?? 0) > (int) ($d['total_installments'] ?? 0)) {
$errors['paid_installments'] = __('المسدد أكبر من الإجمالي');
}
}
return $errors;
}
/** Convert the form's pounds into the piastres everything downstream uses. */
private function normaliseDraft(): array
{
$d = $this->draft;
$type = $this->draftType;
$p = fn ($key) => (int) round(((float) ($d[$key] ?? 0)) * 100);
$base = ['type' => $type, 'note' => trim((string) ($d['note'] ?? ''))];
return match ($type) {
'record_payment', 'settle_short' => $base + [
'invoice_id' => (int) $d['invoice_id'],
'invoice_number' => (string) ($d['invoice_number'] ?? ''),
'amount' => $p('amount'),
'method' => (string) ($d['method'] ?? 'cash'),
'date' => (string) ($d['date'] ?? now()->toDateString()),
],
'waive_invoice' => $base + [
'invoice_id' => (int) $d['invoice_id'],
'invoice_number' => (string) ($d['invoice_number'] ?? ''),
'amount' => (int) ($d['due'] ?? 0),
],
'bill_month' => $base + [
'enrollment_id' => (int) $d['enrollment_id'],
'month' => (string) $d['month'],
'amount' => $p('amount'),
'paid_amount' => $p('paid_amount'),
'close_month' => (bool) ($d['close_month'] ?? false),
'method' => (string) ($d['method'] ?? 'cash'),
'date' => (string) ($d['date'] ?? now()->toDateString()),
],
'sell_product' => $base + [
'product_id' => (int) $d['product_id'],
'quantity' => max(1, (int) ($d['quantity'] ?? 1)),
'unit_price' => $p('unit_price'),
'paid_amount' => $p('paid_amount'),
'method' => (string) ($d['method'] ?? 'cash'),
'date' => (string) ($d['date'] ?? now()->toDateString()),
'adjust_stock' => (bool) ($d['adjust_stock'] ?? false),
],
'move_payment' => $base + [
'payment_id' => (int) $d['payment_id'],
'to_invoice_id' => (int) $d['to_invoice_id'],
'from_invoice_number' => (string) ($d['from_invoice_number'] ?? ''),
'amount_display' => (string) ($d['amount_display'] ?? ''),
],
'credit_wallet' => $base + [
'amount' => $p('amount'),
],
'link_line' => $base + [
'invoice_item_id' => (int) $d['invoice_item_id'],
'product_id' => (int) $d['product_id'],
'description' => (string) ($d['description'] ?? ''),
'product_name' => (string) ($d['product_name'] ?? ''),
],
'plan_installments' => $base + [
'invoice_id' => (int) $d['invoice_id'],
'invoice_number' => (string) ($d['invoice_number'] ?? ''),
'total_installments' => (int) $d['total_installments'],
'paid_installments' => (int) $d['paid_installments'],
'installment_amount' => $p('installment_amount'),
'next_due_date' => (string) ($d['next_due_date'] ?? now()->toDateString()),
],
default => $base,
};
}
/** What this settlement will do to the books, before it does it. */
public function getTotalsProperty(): array
{
$collected = 0;
$waived = 0;
$billed = 0;
foreach ($this->cart as $item) {
match ($item['type']) {
'record_payment' => $collected += $item['amount'],
'settle_short' => [$collected += $item['amount'], $waived += $this->remainderOf($item)],
'waive_invoice' => $waived += $item['amount'],
'bill_month' => [
$billed += $item['amount'],
$collected += $item['paid_amount'],
$waived += $item['close_month'] ? max(0, $item['amount'] - $item['paid_amount']) : 0,
],
'sell_product' => [
$billed += $item['unit_price'] * $item['quantity'],
$collected += $item['paid_amount'],
],
default => null,
};
}
return ['collected' => $collected, 'waived' => $waived, 'billed' => $billed];
}
private function remainderOf(array $item): int
{
$invoice = $this->invoices->firstWhere('id', $item['invoice_id'] ?? 0);
return $invoice ? max(0, (int) $invoice->due_amount - (int) $item['amount']) : 0;
}
// ---- steps ------------------------------------------------------------
public function goToStep(int $step): void
{
if ($step === 3 && $this->cart === []) {
session()->flash('error', __('أضف إجراءً واحداً على الأقل'));
return;
}
if ($step >= 1 && $step <= 4) {
$this->currentStep = $step;
}
}
public function applySettlement(SettlementService $settlements): void
{
$this->authorize('settlements.manage');
$participant = $this->participant;
if (! $participant) {
session()->flash('error', __('المشترك غير موجود'));
return;
}
if (mb_strlen(trim($this->reason)) < 10) {
$this->addError('reason', __('اكتب سبب التسوية (10 أحرف على الأقل)'));
return;
}
try {
$settlement = $settlements->apply(
$participant,
$this->cart,
$this->reason,
auth()->user(),
$participant->branch_id,
);
$this->result = [
'reference' => $settlement->reference,
'collected' => $settlement->collected_amount,
'waived' => $settlement->waived_amount,
'billed' => $settlement->billed_amount,
'actions' => $settlement->actions,
];
$this->cart = [];
$this->reason = '';
$this->currentStep = 4;
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} catch (\Throwable $e) {
report($e);
session()->flash('error', __('تعذر تنفيذ التسوية — لم يُحفظ أي تغيير'));
}
}
public function startOver(): void
{
$this->reset(['participantId', 'cart', 'draft', 'draftType', 'reason', 'result', 'search']);
$this->currentStep = 1;
}
private function money(int $piasters): string
{
return number_format($piasters / 100, 2, '.', '');
}
public function render()
{
return view('livewire.admin.account-settlement-wizard', [
'results' => $this->currentStep === 1 ? $this->searchResults() : collect(),
]);
}
}
<?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 ...@@ -137,6 +137,9 @@ public static function getPermissionsList(): array
'refunds.initiate', 'refunds.approve', 'refunds.initiate', 'refunds.approve',
'daily_closing.create', 'daily_closing.view', 'daily_closing.create', 'daily_closing.view',
'expenses.create', 'expenses.list', 'expenses.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
'pricing.list', 'pricing.create', 'pricing.update', 'pricing.delete', 'pricing.list', 'pricing.create', 'pricing.update', 'pricing.delete',
...@@ -397,6 +400,7 @@ private function accountantPermissions(): array ...@@ -397,6 +400,7 @@ private function accountantPermissions(): array
// cash; the maker-checker rule in PaymentProofService is what // cash; the maker-checker rule in PaymentProofService is what
// stops it being a one-person act. // stops it being a one-person act.
'payments.approve_proof', 'payments.approve_proof',
'settlements.manage',
'daily_closing.create', 'daily_closing.view', 'daily_closing.create', 'daily_closing.view',
'expenses.create', 'expenses.list', 'expenses.view', 'expenses.create', 'expenses.list', 'expenses.view',
'reports.financial', 'reports.view', 'reports.export_pdf', 'reports.export_excel', 'reports.financial', 'reports.view', 'reports.export_pdf', 'reports.export_excel',
......
{{-- Settle a participant's account. RTL: logical properties only. --}}
<div class="max-w-6xl mx-auto px-3 sm:px-4 py-4 sm:py-6" dir="rtl">
<div class="mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">{{ __('تسوية حساب مشترك') }}</h1>
<p class="mt-1 text-sm text-gray-500">
{{ __('دفعات سابقة لم تُسجَّل، شهور تُغلق بأقل من قيمتها، شهور تُسقط، منتجات بيعت خارج السيستم كلها في تسوية واحدة موثّقة.') }}
</p>
</div>
{{-- Steps --}}
<div class="mb-5 bg-white border border-gray-200 rounded-xl p-3 sm:p-4">
<div class="flex items-center gap-2 sm:gap-4">
@foreach ([1 => 'المشترك', 2 => 'الحساب والإجراءات', 3 => 'المراجعة', 4 => 'تم'] as $n => $label)
<div class="flex items-center gap-2 {{ $n < 4 ? 'flex-1' : '' }}">
<span @class([
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0',
'bg-emerald-500 text-white' => $currentStep > $n,
'bg-amber-500 text-white' => $currentStep === $n,
'bg-gray-200 text-gray-500' => $currentStep < $n,
])>{{ $currentStep > $n ? '' : $n }}</span>
<span class="text-xs sm:text-sm {{ $currentStep === $n ? 'font-bold text-gray-900' : 'text-gray-500' }}">{{ __($label) }}</span>
@if($n < 4)<div class="flex-1 h-px bg-gray-200"></div>@endif
</div>
@endforeach
</div>
</div>
@if (session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">{{ session('error') }}</div>
@endif
{{-- ================= STEP 1 : find the participant ================= --}}
@if($currentStep === 1)
<div class="bg-white border border-gray-200 rounded-xl p-4 sm:p-5">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('ابحث بالاسم أو رقم الهاتف') }}</label>
<input type="text" wire:model.live.debounce.400ms="search"
placeholder="{{ __('اسم المشترك أو رقم ولي الأمر...') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
<div class="mt-4 divide-y divide-gray-100">
@forelse($results as $row)
<button type="button" wire:click="selectParticipant({{ $row->id }})"
class="w-full text-start py-3 px-2 hover:bg-amber-50 rounded-lg transition flex items-center justify-between gap-3">
<span>
<span class="block text-sm font-medium text-gray-900">{{ $row->person?->name_ar }}</span>
<span class="block text-xs text-gray-500">
{{ $row->enrollments->first()?->program?->name_ar ?? __('بدون برنامج') }}
@if($row->person?->phone) · <span dir="ltr">{{ $row->person->phone }}</span>@endif
</span>
</span>
<span class="text-xs text-amber-700 font-medium">{{ __('فتح الحساب') }}</span>
</button>
@empty
<p class="py-6 text-center text-sm text-gray-400">
{{ mb_strlen(trim($search)) >= 2 ? __('لا توجد نتائج') : __('اكتب حرفين على الأقل للبحث') }}
</p>
@endforelse
</div>
<div class="mt-4 pt-4 border-t border-gray-100">
<a href="{{ route('admin.settlement-worklist') }}" wire:navigate
class="text-sm text-amber-700 hover:text-amber-900 font-medium">
{{ __('أو استعرض كل الحالات التي تحتاج تسوية ') }}
</a>
</div>
</div>
@endif
{{-- ================= STEP 2 : the account ================= --}}
@if($currentStep === 2 && $this->participant)
@php
$diagnosis = $this->diagnosis;
$money = $diagnosis['money'];
$cases = \App\Domain\Financial\Services\AccountAnomalyScanner::CASES;
@endphp
<div class="bg-white border border-gray-200 rounded-xl p-4 sm:p-5 mb-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-gray-900">{{ $this->participant->person?->name_ar }}</h2>
<p class="text-xs text-gray-500 mt-0.5">
{{ $this->participant->enrollments->first()?->program?->name_ar ?? __('بدون برنامج') }}
· {{ $this->participant->membership_type?->label() ?? __('غير محدد') }}
@if($this->participant->person?->phone) · <span dir="ltr">{{ $this->participant->person->phone }}</span>@endif
</p>
</div>
<button type="button" wire:click="startOver" class="text-xs text-gray-500 hover:text-gray-800">{{ __('مشترك آخر') }}</button>
</div>
<div class="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach ([
['إجمالي ما فُوتِر', $money['billed'] ?? 0, 'text-gray-900'],
['المحصَّل', $money['paid'] ?? 0, 'text-emerald-700'],
['المتبقي', $money['owed'] ?? 0, 'text-red-700'],
] as [$label, $value, $class])
<div class="p-3 bg-gray-50 rounded-lg">
<p class="text-[11px] text-gray-500">{{ __($label) }}</p>
<p class="text-base font-bold {{ $class }} tabular-nums" dir="ltr">{{ format_money($value) }}</p>
</div>
@endforeach
<div class="p-3 bg-gray-50 rounded-lg">
<p class="text-[11px] text-gray-500">{{ __('فواتير غير مسددة') }}</p>
<p class="text-base font-bold text-gray-900 tabular-nums">{{ $money['unpaid'] ?? 0 }}</p>
</div>
</div>
</div>
{{-- What the system thinks is wrong --}}
@php
$flags = [];
if (($money['paid'] ?? 0) === 0 && ($money['unpaid'] ?? 0) > 0) $flags[] = 'never_paid';
if (($money['unpaid'] ?? 0) >= 2) $flags[] = 'stacked_unpaid';
if (($money['zero_invoices'] ?? 0) > 0) $flags[] = 'zero_invoice';
if (!empty($diagnosis['handtyped'])) $flags[] = 'handtyped_product';
if (!empty($diagnosis['missing_bundle'])) $flags[] = 'missing_bundle';
if (!empty($diagnosis['unbilled_months'])) $flags[] = 'unbilled_month';
if (!empty($diagnosis['duplicate_of'])) $flags[] = 'duplicate_person';
@endphp
@if($flags)
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-amber-900 mb-2">{{ __('ما رصده النظام على هذا الحساب') }}</h3>
<ul class="space-y-1.5">
@foreach($flags as $flag)
<li class="text-xs text-amber-900 flex gap-2">
<span class="font-bold shrink-0">{{ $cases[$flag]['label'] }}:</span>
<span class="text-amber-800">{{ $cases[$flag]['hint'] }}</span>
</li>
@endforeach
</ul>
@if(!empty($diagnosis['duplicate_of']))
<p class="mt-2 text-xs text-amber-900">
{{ __('سجلات أخرى بنفس الاسم:') }}
@foreach($diagnosis['duplicate_of'] as $dup)
<a href="{{ route('admin.account-settlement', ['participant' => $dup['id']]) }}" class="underline">#{{ $dup['id'] }}</a>
@endforeach
— {{ __('الدمج يتم من شاشة المشتركين؛ سوِّ كل سجل على حدة حتى ذلك الحين.') }}
</p>
@endif
</div>
@endif
{{-- Invoices --}}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden mb-4">
<div class="px-4 py-3 border-b border-gray-100">
<h3 class="text-sm font-bold text-gray-900">{{ __('الفواتير') }}</h3>
</div>
<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 text-start font-medium">{{ __('الفاتورة') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('البيان') }}</th>
<th class="px-3 py-2 text-center font-medium">{{ __('التاريخ') }}</th>
<th class="px-3 py-2 text-center font-medium">{{ __('الإجمالي') }}</th>
<th class="px-3 py-2 text-center font-medium">{{ __('المحصَّل') }}</th>
<th class="px-3 py-2 text-center font-medium">{{ __('المتبقي') }}</th>
<th class="px-3 py-2 text-center font-medium">{{ __('إجراء') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($this->invoices as $invoice)
<tr class="{{ $invoice->due_amount > 0 ? 'bg-red-50/40' : '' }}">
<td class="px-3 py-2 font-medium text-gray-800" dir="ltr">{{ $invoice->number }}</td>
<td class="px-3 py-2 text-gray-600 text-xs">{{ $invoice->notes ?: $invoice->items->first()?->description }}</td>
<td class="px-3 py-2 text-center text-xs text-gray-500" dir="ltr">{{ $invoice->issue_date?->format('Y-m-d') }}</td>
<td class="px-3 py-2 text-center tabular-nums" dir="ltr">{{ format_money($invoice->total_amount) }}</td>
<td class="px-3 py-2 text-center tabular-nums text-emerald-700" dir="ltr">{{ format_money($invoice->paid_amount) }}</td>
<td class="px-3 py-2 text-center tabular-nums font-bold {{ $invoice->due_amount > 0 ? 'text-red-700' : 'text-gray-400' }}" dir="ltr">
{{ format_money($invoice->due_amount) }}
</td>
<td class="px-3 py-2">
@if($invoice->due_amount > 0)
<div class="flex flex-wrap gap-1 justify-center">
<button type="button" class="px-2 py-1 text-[11px] rounded bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200"
wire:click="startDraft('record_payment', {{ \Illuminate\Support\Js::from(['invoice_id' => $invoice->id, 'invoice_number' => $invoice->number, 'due' => $invoice->due_amount, 'issue_date' => $invoice->issue_date?->format('Y-m-d')]) }})">
{{ __('دفعة سابقة') }}
</button>
<button type="button" class="px-2 py-1 text-[11px] rounded bg-amber-50 text-amber-700 hover:bg-amber-100 border border-amber-200"
wire:click="startDraft('settle_short', {{ \Illuminate\Support\Js::from(['invoice_id' => $invoice->id, 'invoice_number' => $invoice->number, 'due' => $invoice->due_amount, 'issue_date' => $invoice->issue_date?->format('Y-m-d')]) }})">
{{ __('إغلاق بمبلغ') }}
</button>
<button type="button" class="px-2 py-1 text-[11px] rounded bg-red-50 text-red-700 hover:bg-red-100 border border-red-200"
wire:click="startDraft('waive_invoice', {{ \Illuminate\Support\Js::from(['invoice_id' => $invoice->id, 'invoice_number' => $invoice->number, 'due' => $invoice->due_amount]) }})">
{{ __('إسقاط') }}
</button>
<button type="button" class="px-2 py-1 text-[11px] rounded bg-indigo-50 text-indigo-700 hover:bg-indigo-100 border border-indigo-200"
wire:click="startDraft('plan_installments', {{ \Illuminate\Support\Js::from(['invoice_id' => $invoice->id, 'invoice_number' => $invoice->number, 'total' => $invoice->total_amount]) }})">
{{ __('أقساط') }}
</button>
</div>
@else
<span class="block text-center text-[11px] text-gray-400">{{ __('مسددة') }}</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="7" class="px-3 py-6 text-center text-sm text-gray-400">{{ __('لا توجد فواتير') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Hand-typed product money --}}
@if(!empty($diagnosis['handtyped']))
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-gray-900 mb-1">{{ __('بنود مكتوبة يدوياً تخص منتجاً') }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ __('المال مسجَّل بالفعل الناقص هو ربط البند بالمنتج حتى تراه تقارير المنتجات والمخزون.') }}</p>
<ul class="space-y-2">
@foreach($diagnosis['handtyped'] as $line)
<li class="flex flex-wrap items-center justify-between gap-2 p-2 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-700">
<span dir="ltr" class="font-medium">{{ $line['invoice_number'] }}</span> —
«{{ $line['description'] }}» · <span dir="ltr">{{ format_money($line['amount']) }}</span>
</span>
<button type="button" class="px-2 py-1 text-[11px] rounded bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200"
wire:click="startDraft('link_line', {{ \Illuminate\Support\Js::from($line) }})">
{{ __('ربط بـ') }} {{ $line['product_name'] }}
</button>
</li>
@endforeach
</ul>
</div>
@endif
{{-- Months nobody billed --}}
@if(!empty($diagnosis['unbilled_months']))
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-gray-900 mb-1">{{ __('شهور بلا فاتورة') }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ __('شهور بين بداية الاشتراك والشهر الماضي لم تصدر لها فاتورة اشتراك.') }}</p>
<div class="flex flex-wrap gap-2">
@foreach($diagnosis['unbilled_months'] as $m)
<button type="button" class="px-2.5 py-1.5 text-xs rounded-lg bg-gray-50 border border-gray-200 hover:bg-amber-50 hover:border-amber-200"
wire:click="startDraft('bill_month', {{ \Illuminate\Support\Js::from($m) }})">
{{ $m['label'] }} <span class="text-gray-400">· {{ $m['program'] }}</span>
</button>
@endforeach
</div>
</div>
@endif
{{-- Bundle never charged --}}
@if(!empty($diagnosis['missing_bundle']))
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-gray-900 mb-1">{{ __('مستلزمات البرنامج غير المحاسَب عليها') }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ __('البرنامج يتطلب هذه المنتجات ولا يوجد لها أي مبلغ على حساب المشترك.') }}</p>
<div class="flex flex-wrap gap-2">
@foreach($diagnosis['missing_bundle'] as $bundle)
<button type="button" class="px-2.5 py-1.5 text-xs rounded-lg bg-gray-50 border border-gray-200 hover:bg-amber-50 hover:border-amber-200"
wire:click="startDraft('sell_product', {{ \Illuminate\Support\Js::from($bundle) }})">
{{ $bundle['product_name'] }} <span class="text-gray-400" dir="ltr">· {{ format_money($bundle['price']) }}</span>
</button>
@endforeach
</div>
</div>
@endif
{{-- Money already on the account, in case it sits on the wrong month --}}
@if($this->payments->isNotEmpty())
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-gray-900 mb-1">{{ __('الدفعات المسجَّلة') }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ __('إن كانت دفعة سُجِّلت على شهر غير الذي تخصه، انقلها للفاتورة الصحيحة لا يتغير المبلغ ولا تاريخه.') }}</p>
<ul class="space-y-2">
@foreach($this->payments as $payment)
@php $onInvoice = $this->invoices->firstWhere('id', $payment->invoice_id); @endphp
<li class="flex flex-wrap items-center justify-between gap-2 p-2 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-700">
<span dir="ltr" class="font-bold">{{ format_money($payment->amount) }}</span>
· <span dir="ltr">{{ $payment->payment_date?->format('Y-m-d') }}</span>
· {{ __('على') }} <span dir="ltr">{{ $onInvoice?->number ?? '' }}</span>
</span>
@if($this->invoices->where('due_amount', '>', 0)->count() > 0)
<button type="button" class="px-2 py-1 text-[11px] rounded bg-purple-50 text-purple-700 hover:bg-purple-100 border border-purple-200"
wire:click="startDraft('move_payment', {{ \Illuminate\Support\Js::from(['payment_id' => $payment->id, 'from_invoice_number' => $onInvoice?->number, 'amount_display' => format_money($payment->amount)]) }})">
{{ __('نقل لفاتورة أخرى') }}
</button>
@endif
</li>
@endforeach
</ul>
</div>
@endif
{{-- Free-form additions --}}
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-gray-900 mb-3">{{ __('إجراءات إضافية') }}</h3>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="startDraft('bill_month')"
class="px-3 py-1.5 text-xs rounded-lg bg-gray-50 border border-gray-200 hover:bg-gray-100">{{ __('إصدار فاتورة شهر') }}</button>
<button type="button" wire:click="startDraft('sell_product')"
class="px-3 py-1.5 text-xs rounded-lg bg-gray-50 border border-gray-200 hover:bg-gray-100">{{ __('تسجيل بيع منتج بأثر رجعي') }}</button>
@can('wallets.credit')
<button type="button" wire:click="startDraft('credit_wallet')"
class="px-3 py-1.5 text-xs rounded-lg bg-gray-50 border border-gray-200 hover:bg-gray-100">{{ __('إضافة رصيد للمحفظة (دفع زائد)') }}</button>
@endcan
</div>
</div>
{{-- ---- the open editor ---- --}}
@if($draftType)
<div class="bg-white border-2 border-amber-300 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-amber-900 mb-3">
@switch($draftType)
@case('record_payment') {{ __('تسجيل دفعة سابقة') }} @break
@case('settle_short') {{ __('إغلاق الشهر بالمبلغ المحصَّل') }} @break
@case('waive_invoice') {{ __('إسقاط الفاتورة') }} @break
@case('bill_month') {{ __('إصدار فاتورة شهر بأثر رجعي') }} @break
@case('sell_product') {{ __('تسجيل بيع منتج بأثر رجعي') }} @break
@case('link_line') {{ __('ربط بند بمنتج') }} @break
@case('plan_installments') {{ __('خطة أقساط') }} @break
@case('move_payment') {{ __('نقل دفعة لفاتورة أخرى') }} @break
@case('credit_wallet') {{ __('إضافة رصيد لمحفظة المشترك') }} @break
@endswitch
@if(!empty($draft['invoice_number']))
<span class="text-gray-500 font-normal" dir="ltr">— {{ $draft['invoice_number'] }}</span>
@endif
</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
@if(in_array($draftType, ['record_payment','settle_short']))
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('المبلغ المحصَّل (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.amount"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.amount') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('تاريخ التحصيل الفعلي') }}</label>
<input type="date" dir="ltr" wire:model="draft.date" max="{{ now()->toDateString() }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.date') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('طريقة الدفع') }}</label>
<select wire:model="draft.method" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="cash">{{ __('نقدي') }}</option>
<option value="card">{{ __('بطاقة') }}</option>
<option value="bank_transfer">{{ __('تحويل بنكي') }}</option>
<option value="wallet">{{ __('محفظة') }}</option>
<option value="online">{{ __('أونلاين') }}</option>
<option value="other">{{ __('أخرى') }}</option>
</select>
</div>
@if($draftType === 'settle_short')
<div class="sm:col-span-3 p-2.5 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-900">
{{ __('الفرق بين المتبقي والمبلغ المحصَّل سيُسقَط بقرار إداري وسيظهر على الفاتورة كخصم إداري بسببه.') }}
</div>
@endif
@endif
@if($draftType === 'waive_invoice')
<div class="sm:col-span-3 p-2.5 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
{{ __('سيتم إسقاط') }} <span dir="ltr" class="font-bold">{{ format_money($draft['due'] ?? 0) }}</span>
{{ __('من هذه الفاتورة. إن لم يكن قد حُصِّل منها شيء ستُصبح ملغاة ولن تظهر في مستحقات المشترك.') }}
</div>
@endif
@if($draftType === 'bill_month')
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('الشهر') }}</label>
<input type="month" dir="ltr" wire:model="draft.month" max="{{ now()->format('Y-m') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.month') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('قيمة الشهر (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.amount"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.amount') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('المحصَّل منه (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.paid_amount"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.paid_amount') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div class="sm:col-span-3 flex items-center gap-2">
<input type="checkbox" id="close_month" wire:model="draft.close_month" class="rounded border-gray-300">
<label for="close_month" class="text-xs text-gray-700">
{{ __('اعتبر الشهر مغلقاً بهذا المبلغ (إسقاط الباقي) للاعب الذي انضم في نصف الشهر ودفع ما تبقى منه') }}
</label>
</div>
@endif
@if($draftType === 'sell_product')
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('المنتج') }}</label>
<select wire:model="draft.product_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="0">{{ __('اختر المنتج') }}</option>
@foreach($this->bundleProducts as $product)
<option value="{{ $product->id }}">{{ $product->name_ar }}</option>
@endforeach
</select>
@error('draft.product_id') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('سعر الوحدة (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.unit_price"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.unit_price') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('المحصَّل (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.paid_amount"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.paid_amount') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('الكمية') }}</label>
<input type="number" min="1" dir="ltr" wire:model="draft.quantity"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('تاريخ البيع الفعلي') }}</label>
<input type="date" dir="ltr" wire:model="draft.date" max="{{ now()->toDateString() }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
<div class="flex items-end gap-2 pb-2">
<input type="checkbox" id="adjust_stock" wire:model="draft.adjust_stock" class="rounded border-gray-300">
<label for="adjust_stock" class="text-xs text-gray-700">{{ __('خصم الكمية من المخزون') }}</label>
</div>
@endif
@if($draftType === 'link_line')
<div class="sm:col-span-3 p-2.5 bg-blue-50 border border-blue-200 rounded-lg text-xs text-blue-900">
{{ __('سيُربط البند') }} «{{ $draft['description'] ?? '' }}» {{ __('بمنتج') }}
<span class="font-bold">{{ $draft['product_name'] ?? '' }}</span>.
{{ __('لا تتغير أي مبالغ يتغير تصنيف البند فقط.') }}
</div>
@endif
@if($draftType === 'plan_installments')
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('عدد الأقساط') }}</label>
<input type="number" min="2" dir="ltr" wire:model="draft.total_installments"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.total_installments') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('المسدد منها') }}</label>
<input type="number" min="0" dir="ltr" wire:model="draft.paid_installments"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.paid_installments') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('قيمة القسط (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.installment_amount"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
@endif
@if($draftType === 'move_payment')
<div class="sm:col-span-3 p-2.5 bg-purple-50 border border-purple-200 rounded-lg text-xs text-purple-900">
{{ __('دفعة') }} <span dir="ltr" class="font-bold">{{ $draft['amount_display'] ?? '' }}</span>
{{ __('مسجَّلة حالياً على') }} <span dir="ltr">{{ $draft['from_invoice_number'] ?? '' }}</span>.
{{ __('ستُعكَس عنها وتُسجَّل على الفاتورة المختارة بنفس تاريخها وطريقتها.') }}
</div>
<div class="sm:col-span-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('الفاتورة التي تخصها الدفعة فعلاً') }}</label>
<select wire:model="draft.to_invoice_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="0">{{ __('اختر الفاتورة') }}</option>
@foreach($this->invoices->where('due_amount', '>', 0) as $target)
<option value="{{ $target->id }}">{{ $target->number }} — {{ $target->notes ?: $target->issue_date?->format('Y-m') }} ({{ format_money($target->due_amount) }})</option>
@endforeach
</select>
@error('draft.to_invoice_id') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
@endif
@if($draftType === 'credit_wallet')
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('المبلغ (ج.م)') }}</label>
<input type="number" step="0.01" min="0" dir="ltr" wire:model="draft.amount"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
@error('draft.amount') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div class="sm:col-span-2 p-2.5 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 self-end">
{{ __('رصيد يُحسب للمشترك على الفواتير القادمة للمبالغ الزائدة التي دُفعت بالفعل وسُجِّلت. لا يُحتسب تحصيلاً جديداً.') }}
</div>
@endif
<div class="sm:col-span-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('ملاحظة (تظهر على الفاتورة/الإيصال)') }}</label>
<input type="text" wire:model="draft.note" maxlength="200"
placeholder="{{ __('مثال: حُصِّل نقداً يوم التسجيل ولم يُسجَّل') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button type="button" wire:click="addDraft" wire:loading.attr="disabled"
class="px-4 py-2 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 disabled:opacity-50">
<span wire:loading.remove wire:target="addDraft">{{ __('أضف إلى التسوية') }}</span>
<span wire:loading wire:target="addDraft">{{ __('...') }}</span>
</button>
<button type="button" wire:click="cancelDraft" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">{{ __('إلغاء') }}</button>
</div>
</div>
@endif
{{-- ---- the cart ---- --}}
@include('livewire.admin.partials.settlement-cart', ['editable' => true])
<div class="flex items-center justify-between gap-3">
<button type="button" wire:click="startOver" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">{{ __('رجوع') }}</button>
<button type="button" wire:click="goToStep(3)" @disabled(empty($cart))
class="px-5 py-2.5 bg-amber-500 text-white text-sm font-bold rounded-lg hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed">
{{ __('مراجعة التسوية') }} ({{ count($cart) }})
</button>
</div>
@endif
{{-- ================= STEP 3 : review ================= --}}
@if($currentStep === 3)
@include('livewire.admin.partials.settlement-cart', ['editable' => false])
<div class="bg-white border border-gray-200 rounded-xl p-4 sm:p-5 mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سبب التسوية') }} <span class="text-red-600">*</span></label>
<p class="text-xs text-gray-500 mb-2">{{ __('ما الذي حدث فعلاً ومن قرر؟ هذا النص هو ما سيُقرأ بعد ستة أشهر عند السؤال عن هذه المبالغ.') }}</p>
<textarea wire:model="reason" rows="3" maxlength="500"
placeholder="{{ __('مثال: راجعنا دفتر الكاش مع الاستقبال دفع 900 نقداً يوم 20 أغسطس ولم تُسجَّل، واتفق المدير على إسقاط شهر سبتمبر لتأخر بدء المجموعة.') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"></textarea>
@error('reason') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex items-center justify-between gap-3">
<button type="button" wire:click="goToStep(2)" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">{{ __('تعديل الإجراءات') }}</button>
<button type="button" wire:click="applySettlement" wire:loading.attr="disabled" wire:target="applySettlement"
class="px-5 py-2.5 bg-emerald-600 text-white text-sm font-bold rounded-lg hover:bg-emerald-700 disabled:opacity-50">
<span wire:loading.remove wire:target="applySettlement">{{ __('تنفيذ التسوية') }}</span>
<span wire:loading wire:target="applySettlement">{{ __('جارٍ التنفيذ...') }}</span>
</button>
</div>
@endif
{{-- ================= STEP 4 : done ================= --}}
@if($currentStep === 4 && $result)
<div class="bg-white border border-emerald-200 rounded-xl p-5">
<div class="flex items-center gap-3 mb-4">
<span class="w-10 h-10 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center text-xl">✓</span>
<div>
<h2 class="text-lg font-bold text-gray-900">{{ __('تمت التسوية') }}</h2>
<p class="text-xs text-gray-500" dir="ltr">{{ $result['reference'] }}</p>
</div>
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="p-3 bg-emerald-50 rounded-lg">
<p class="text-[11px] text-emerald-700">{{ __('محصَّل') }}</p>
<p class="text-base font-bold text-emerald-800 tabular-nums" dir="ltr">{{ format_money($result['collected']) }}</p>
</div>
<div class="p-3 bg-red-50 rounded-lg">
<p class="text-[11px] text-red-700">{{ __('مُسقَط') }}</p>
<p class="text-base font-bold text-red-800 tabular-nums" dir="ltr">{{ format_money($result['waived']) }}</p>
</div>
<div class="p-3 bg-gray-50 rounded-lg">
<p class="text-[11px] text-gray-600">{{ __('مُفوتَر جديد') }}</p>
<p class="text-base font-bold text-gray-800 tabular-nums" dir="ltr">{{ format_money($result['billed']) }}</p>
</div>
</div>
<ul class="space-y-1.5 mb-5">
@foreach($result['actions'] as $action)
<li class="text-xs text-gray-700 flex items-start gap-2">
<span class="text-emerald-600">✓</span>
<span>{{ $action['label'] ?? $action['type'] }}</span>
</li>
@endforeach
</ul>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="startOver"
class="px-4 py-2 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600">{{ __('تسوية مشترك آخر') }}</button>
<a href="{{ route('admin.settlement-worklist') }}" wire:navigate
class="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50">{{ __('العودة لقائمة الحالات') }}</a>
</div>
</div>
@endif
</div>
{{-- 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 @@ ...@@ -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> <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>
<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"> <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> <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 ...@@ -445,6 +445,20 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-emerald-50 border border-em
</a> </a>
@endcan @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') @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"> <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"> <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 @@ ...@@ -522,6 +522,15 @@
Route::get('/admin/invoice-correction', \App\Livewire\Admin\InvoiceCorrectionWizard::class)->name('admin.invoice-correction') Route::get('/admin/invoice-correction', \App\Livewire\Admin\InvoiceCorrectionWizard::class)->name('admin.invoice-correction')
->middleware('permission:super_admin.access'); ->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 // Exports
Route::get('/export/report', [\App\Http\Controllers\ExportController::class, 'report']) Route::get('/export/report', [\App\Http\Controllers\ExportController::class, 'report'])
->name('export.report')->middleware('permission:reports.view'); ->name('export.report')->middleware('permission:reports.view');
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Models\Settlement;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Financial\Services\ParticipantBillingService;
use App\Domain\Financial\Services\SettlementService;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Settling an account, against a restored tenant.
*
* Runs on a real Postgres copy rather than the SQLite suite, because every
* assertion here is about what happens to money across invoices, payments, the
* ledger and the roster's own reading of them — the schema and the seeded
* accounts are the test fixture, and rebuilding them by hand would be testing a
* different system:
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter AccountSettlementTest
*
* Every case is wrapped in a transaction and rolled back, so the copy is left
* exactly as it was found.
*/
class AccountSettlementTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
DB::beginTransaction();
}
protected function tearDown(): void
{
if (config('database.default') === 'pgsql') {
DB::rollBack();
}
parent::tearDown();
}
private function service(): SettlementService
{
return app(SettlementService::class);
}
private function actor(): User
{
return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'admin'))->first()
?? User::query()->firstOrFail();
}
/** A participant carrying at least one invoice with money still on it. */
private function participantWithDue(): Participant
{
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->whereColumn('total_amount', '>', 'paid_amount')
->where('status', '!=', InvoiceStatus::Cancelled)
->whereNull('deleted_at')
->orderBy('id')
->firstOrFail();
return Participant::withoutGlobalScopes()->with('person')->findOrFail($invoice->billable_id);
}
private function dueInvoiceOf(Participant $participant): Invoice
{
return Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->whereColumn('total_amount', '>', 'paid_amount')
->where('status', '!=', InvoiceStatus::Cancelled)
->whereNull('deleted_at')
->orderBy('id')
->firstOrFail();
}
// ---- money that was collected and never entered -----------------------
public function test_a_back_dated_payment_lands_on_the_invoice_and_the_ledger(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$due = (int) $invoice->due_amount;
$before = DB::table('transactions')->count();
$settlement = $this->service()->apply($participant, [[
'type' => 'record_payment',
'invoice_id' => $invoice->id,
'amount' => $due,
'method' => 'cash',
'date' => '2026-08-20',
'note' => 'حُصِّل نقداً يوم التسجيل',
]], 'مراجعة دفتر الكاش مع الاستقبال', $this->actor(), $participant->branch_id);
$invoice->refresh();
$this->assertSame($due, $settlement->collected_amount);
$this->assertSame(0, (int) $invoice->due_amount);
$this->assertSame(InvoiceStatus::Paid, $invoice->status);
$payment = Payment::withoutGlobalScopes()->where('invoice_id', $invoice->id)->latest('id')->first();
$this->assertSame('2026-08-20', $payment->payment_date->toDateString(), 'The money is filed on the day it was taken.');
$this->assertGreaterThan($before, DB::table('transactions')->count(), 'A payment must post to the ledger.');
}
public function test_a_payment_larger_than_the_balance_is_refused(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'record_payment',
'invoice_id' => $invoice->id,
'amount' => (int) $invoice->due_amount + 100000,
]], 'محاولة تحصيل أكبر من المستحق', $this->actor(), $participant->branch_id);
}
public function test_another_participants_invoice_cannot_be_settled(): void
{
$participant = $this->participantWithDue();
$foreign = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', '!=', $participant->id)
->whereColumn('total_amount', '>', 'paid_amount')
->firstOrFail();
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'record_payment',
'invoice_id' => $foreign->id,
'amount' => 1000,
]], 'محاولة الوصول لفاتورة مشترك آخر', $this->actor(), $participant->branch_id);
}
public function test_a_future_dated_settlement_is_refused(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'record_payment',
'invoice_id' => $invoice->id,
'amount' => 1000,
'date' => now()->addWeek()->toDateString(),
]], 'تاريخ مستقبلي يجب أن يُرفض', $this->actor(), $participant->branch_id);
}
// ---- the month that closed for less ------------------------------------
public function test_settling_short_closes_the_month_and_records_the_discount(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$due = (int) $invoice->due_amount;
$subtotalBefore = (int) $invoice->subtotal_amount;
$paying = intdiv($due, 2);
$settlement = $this->service()->apply($participant, [[
'type' => 'settle_short',
'invoice_id' => $invoice->id,
'amount' => $paying,
'date' => '2026-08-25',
'note' => 'انضم في نصف الشهر ودفع ما تبقى منه',
]], 'تسوية شهر الانضمام حسب الأيام المتبقية', $this->actor(), $participant->branch_id);
$invoice->refresh();
$this->assertSame($paying, $settlement->collected_amount);
$this->assertSame($due - $paying, $settlement->waived_amount);
$this->assertSame(0, (int) $invoice->due_amount, 'The month must stop being owed.');
$this->assertSame(
$subtotalBefore,
(int) $invoice->subtotal_amount,
'subtotal_amount is what the lines came to — the roster still needs the original figure.'
);
$this->assertNotEmpty($invoice->metadata['admin_override'] ?? null, 'A write-off must say who and why.');
$this->assertSame('settlement', $invoice->metadata['admin_override']['source']);
}
public function test_a_waived_invoice_with_nothing_paid_drops_out_of_the_books(): void
{
$participant = $this->participantWithDue();
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->where('paid_amount', 0)
->whereColumn('total_amount', '>', 'paid_amount')
->first();
if (! $invoice) {
$this->markTestSkipped('This participant has no wholly unpaid invoice.');
}
$total = (int) $invoice->total_amount;
$settlement = $this->service()->apply($participant, [[
'type' => 'waive_invoice',
'invoice_id' => $invoice->id,
'note' => 'المجموعة لم تبدأ في هذا الشهر',
]], 'إسقاط شهر لم تُقدَّم فيه خدمة', $this->actor(), $participant->branch_id);
$invoice->refresh();
$this->assertSame($total, $settlement->waived_amount);
$this->assertSame(InvoiceStatus::Cancelled, $invoice->status);
$this->assertSame(0, (int) $invoice->due_amount);
}
// ---- months and products that were never entered -----------------------
public function test_a_missing_month_is_billed_under_the_month_it_pays_for(): void
{
$participant = Participant::withoutGlobalScopes()
->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
->firstOrFail();
$enrollment = $participant->enrollments->first();
$settlement = $this->service()->apply($participant, [[
'type' => 'bill_month',
'enrollment_id' => $enrollment->id,
'month' => '2026-07',
'amount' => 90000,
'paid_amount' => 90000,
'method' => 'cash',
'date' => '2026-07-05',
]], 'شهر يوليو دُفع نقداً ولم تصدر له فاتورة', $this->actor(), $participant->branch_id);
$invoiceId = $settlement->actions[0]['invoice_id'];
$invoice = Invoice::withoutGlobalScopes()->with('items')->findOrFail($invoiceId);
$this->assertSame(90000, $settlement->billed_amount);
$this->assertSame(90000, $settlement->collected_amount);
$this->assertSame('2026-07', $invoice->metadata['month']);
$this->assertSame(0, (int) $invoice->due_amount);
// The month has to be readable off the line, not inferred from the day
// the correction was typed — that is what files it under July.
$facts = app(ParticipantBillingService::class)
->subscriptionForPeriod([$participant->id], '2026-07-01', '2026-08-01')[$participant->id] ?? null;
$this->assertNotNull($facts, 'July must now show a subscription.');
$this->assertGreaterThanOrEqual(90000, $facts['paid']);
}
public function test_a_retroactive_product_sale_carries_the_products_morph(): void
{
$participant = $this->participantWithDue();
$product = Product::withoutGlobalScopes()
->where('academy_id', $participant->academy_id)
->where('is_active', true)
->firstOrFail();
$settlement = $this->service()->apply($participant, [[
'type' => 'sell_product',
'product_id' => $product->id,
'quantity' => 1,
'unit_price' => 250000,
'paid_amount' => 250000,
'method' => 'cash',
'date' => '2026-07-26',
'adjust_stock' => false,
]], 'اشترى المنتج نقداً ولم يُسجَّل وقتها', $this->actor(), $participant->branch_id);
$invoice = Invoice::withoutGlobalScopes()->with('items')->findOrFail($settlement->actions[0]['invoice_id']);
$line = $invoice->items->first();
$this->assertSame($product->getMorphClass(), $line->itemable_type, 'A sale is a product line, not a description.');
$this->assertSame((int) $product->id, (int) $line->itemable_id);
$this->assertSame(250000, $settlement->collected_amount);
$this->assertSame(0, (int) $invoice->due_amount);
}
public function test_a_hand_typed_line_can_be_linked_to_its_product_without_moving_money(): void
{
$item = InvoiceItem::withoutGlobalScopes()
->whereNull('itemable_type')
->whereHas('invoice', fn ($q) => $q->where('billable_type', Participant::class)->whereNull('deleted_at'))
->with('invoice')
->firstOrFail();
$participant = Participant::withoutGlobalScopes()->findOrFail($item->invoice->billable_id);
$product = Product::withoutGlobalScopes()->where('academy_id', $participant->academy_id)->firstOrFail();
$totalBefore = (int) $item->invoice->total_amount;
$paidBefore = (int) $item->invoice->paid_amount;
$this->service()->apply($participant, [[
'type' => 'link_line',
'invoice_item_id' => $item->id,
'product_id' => $product->id,
]], 'ربط بند مكتوب يدوياً بمنتجه الصحيح', $this->actor(), $participant->branch_id);
$item->refresh();
$invoice = $item->invoice->fresh();
$this->assertSame($product->getMorphClass(), $item->itemable_type);
$this->assertSame($totalBefore, (int) $invoice->total_amount, 'Classification must not move money.');
$this->assertSame($paidBefore, (int) $invoice->paid_amount);
}
public function test_an_instalment_plan_is_recorded_with_what_was_already_paid(): void
{
$participant = $this->participantWithDue();
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->whereDoesntHave('paymentPlan')
->whereColumn('total_amount', '>', 'paid_amount')
->first();
if (! $invoice) {
$this->markTestSkipped('No plan-less invoice on this account.');
}
$this->service()->apply($participant, [[
'type' => 'plan_installments',
'invoice_id' => $invoice->id,
'total_installments' => 3,
'paid_installments' => 1,
'installment_amount' => 250000,
]], 'خطة أقساط متفق عليها على القيد', $this->actor(), $participant->branch_id);
$plan = PaymentPlan::withoutGlobalScopes()->where('invoice_id', $invoice->id)->firstOrFail();
$this->assertSame(3, $plan->total_installments);
$this->assertSame(1, $plan->paid_installments);
// payment_plans_status_check allows active/completed/defaulted/cancelled
// only: a part-paid plan is active, and paid_installments is what says
// how far along it is.
$this->assertSame('active', $plan->status);
}
// ---- money on the wrong month, and money that is not any month's --------
public function test_a_payment_can_be_moved_to_the_month_it_actually_pays_for(): void
{
// Two open invoices, the money on the wrong one. The reversal is a
// ledger row, not an edit, and both invoices end up telling the truth.
$participant = Participant::withoutGlobalScopes()
->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
->firstOrFail();
$enrollment = $participant->enrollments->first();
$actor = $this->actor();
$first = $this->service()->apply($participant, [[
'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-05',
'amount' => 90000, 'paid_amount' => 90000, 'date' => '2026-05-03',
]], 'فاتورة مايو مدفوعة بالخطأ', $actor, $participant->branch_id);
$second = $this->service()->apply($participant, [[
'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-06',
'amount' => 90000, 'paid_amount' => 0,
]], 'فاتورة يونيو غير مسددة', $actor, $participant->branch_id);
$wrongInvoiceId = $first->actions[0]['invoice_id'];
$rightInvoiceId = $second->actions[0]['invoice_id'];
$paymentId = $first->actions[0]['payment_id'];
$moved = $this->service()->apply($participant, [[
'type' => 'move_payment',
'payment_id' => $paymentId,
'to_invoice_id' => $rightInvoiceId,
]], 'الدفعة كانت لشهر يونيو وسُجِّلت على مايو', $actor, $participant->branch_id);
$wrong = Invoice::withoutGlobalScopes()->findOrFail($wrongInvoiceId);
$right = Invoice::withoutGlobalScopes()->findOrFail($rightInvoiceId);
$this->assertSame(90000, (int) $wrong->due_amount, 'The month it never paid for is owed again.');
$this->assertSame(0, (int) $right->due_amount, 'The month it actually paid for is settled.');
$this->assertSame(0, $moved->collected_amount, 'Moving money is not collecting it twice.');
$reversal = Payment::withoutGlobalScopes()
->where('invoice_id', $wrongInvoiceId)->where('direction', 'outbound')->first();
$this->assertNotNull($reversal, 'The reversal must exist as its own row.');
}
public function test_a_payment_cannot_be_moved_onto_an_invoice_that_cannot_hold_it(): void
{
$participant = Participant::withoutGlobalScopes()
->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
->firstOrFail();
$enrollment = $participant->enrollments->first();
$actor = $this->actor();
$big = $this->service()->apply($participant, [[
'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-04',
'amount' => 90000, 'paid_amount' => 90000,
]], 'فاتورة أبريل مدفوعة بالكامل', $actor, $participant->branch_id);
$small = $this->service()->apply($participant, [[
'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-03',
'amount' => 10000, 'paid_amount' => 0,
]], 'فاتورة مارس بمبلغ صغير', $actor, $participant->branch_id);
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'move_payment',
'payment_id' => $big->actions[0]['payment_id'],
'to_invoice_id' => $small->actions[0]['invoice_id'],
]], 'محاولة نقل مبلغ أكبر من المتبقي', $actor, $participant->branch_id);
}
public function test_an_overpayment_can_be_held_as_wallet_credit(): void
{
$participant = $this->participantWithDue();
$settlement = $this->service()->apply($participant, [[
'type' => 'credit_wallet',
'amount' => 25000,
'note' => 'دفع 250 زيادة عن قيمة الفاتورة',
]], 'رصيد زائد يُحسب على فواتير الشهور القادمة', $this->actor(), $participant->branch_id);
$wallet = \App\Domain\Financial\Models\Wallet::withoutGlobalScopes()
->where('owner_type', $participant->getMorphClass())
->where('owner_id', $participant->id)
->firstOrFail();
$this->assertGreaterThanOrEqual(25000, (int) $wallet->balance);
$this->assertSame(0, $settlement->collected_amount, 'Credit is not a second collection of the same cash.');
}
public function test_a_zero_amount_invoice_is_dropped_rather_than_left_standing(): void
{
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('total_amount', 0)
->where('paid_amount', 0)
->where('status', '!=', InvoiceStatus::Cancelled)
->whereNull('deleted_at')
->first();
if (! $invoice) {
$this->markTestSkipped('No zero-amount invoice in this tenant.');
}
$participant = Participant::withoutGlobalScopes()->findOrFail($invoice->billable_id);
$this->service()->apply($participant, [[
'type' => 'waive_invoice',
'invoice_id' => $invoice->id,
]], 'فاتورة صدرت بصفر لعدم وجود سعر وقتها', $this->actor(), $participant->branch_id);
$this->assertSame(InvoiceStatus::Cancelled, $invoice->fresh()->status);
}
// ---- the settlement as a record ---------------------------------------
public function test_a_failing_action_rolls_the_whole_settlement_back(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$paymentsBefore = Payment::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count();
$settlementsBefore = Settlement::withoutGlobalScopes()->count();
try {
$this->service()->apply($participant, [
['type' => 'record_payment', 'invoice_id' => $invoice->id, 'amount' => 1000],
['type' => 'record_payment', 'invoice_id' => $invoice->id, 'amount' => 999999999],
], 'الإجراء الثاني يجب أن يُسقط الأول', $this->actor(), $participant->branch_id);
$this->fail('The oversized second payment should have thrown.');
} catch (DomainException) {
// expected
}
$this->assertSame($paymentsBefore, Payment::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count());
$this->assertSame($settlementsBefore, Settlement::withoutGlobalScopes()->count());
}
public function test_a_settlement_without_a_reason_is_refused(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$this->expectException(DomainException::class);
$this->service()->apply($participant, [
['type' => 'record_payment', 'invoice_id' => $invoice->id, 'amount' => 1000],
], 'قصير', $this->actor(), $participant->branch_id);
}
public function test_an_unknown_action_is_refused(): void
{
$participant = $this->participantWithDue();
$this->expectException(DomainException::class);
$this->service()->apply($participant, [
['type' => 'delete_everything', 'invoice_id' => 1],
], 'إجراء غير معروف يجب أن يُرفض', $this->actor(), $participant->branch_id);
}
public function test_the_settlement_records_what_it_did(): void
{
$participant = $this->participantWithDue();
$invoice = $this->dueInvoiceOf($participant);
$settlement = $this->service()->apply($participant, [[
'type' => 'record_payment',
'invoice_id' => $invoice->id,
'amount' => 1000,
'date' => '2026-08-20',
]], 'تسجيل دفعة نقدية سابقة بعد مراجعة الدفتر', $this->actor(), $participant->branch_id);
$this->assertMatchesRegularExpression('/^SET-\d{6}$/', $settlement->reference);
$this->assertSame($participant->id, $settlement->participant_id);
$this->assertNotNull($settlement->applied_by);
$this->assertSame('record_payment', $settlement->actions[0]['type']);
$this->assertSame($invoice->id, $settlement->actions[0]['invoice_id']);
$this->assertNotEmpty($settlement->actions[0]['payment_id']);
}
// ---- the scanner ------------------------------------------------------
public function test_the_scanner_finds_accounts_and_explains_each_one(): void
{
$rows = app(AccountAnomalyScanner::class)->scan(limit: 50);
$this->assertNotEmpty($rows, 'The restored tenant is known to carry unsettled accounts.');
foreach ($rows as $row) {
$this->assertNotEmpty($row['cases']);
foreach ($row['cases'] as $case) {
$this->assertArrayHasKey($case, AccountAnomalyScanner::CASES, "Unknown case: {$case}");
}
}
// Worst first: a file where money was probably collected and never
// recorded outranks one that is merely behind.
$severities = array_column($rows, 'severity');
$sorted = $severities;
sort($sorted);
$this->assertSame($sorted, $severities);
}
public function test_the_diagnosis_answers_the_wizards_questions(): void
{
$participant = $this->participantWithDue();
$diagnosis = app(AccountAnomalyScanner::class)->forParticipant($participant);
foreach (['money', 'handtyped', 'missing_bundle', 'unbilled_months', 'duplicate_of'] as $key) {
$this->assertArrayHasKey($key, $diagnosis);
}
$this->assertGreaterThan(0, $diagnosis['money']['owed'], 'This participant was chosen for owing money.');
}
}
<?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