Commit 579a3a8b authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(settlements): give the desk the tool that removes a demand billed twice

The last commit taught the settlement screen to SEE money billed twice. It
still could not do anything about it: the panel described the problem and
then offered "تسجيل باقي القيمة", which bills MORE. The only way to
actually fix the reported account was an admin editing the invoice by
hand, which is how it broke in the first place.

So the screen gets the missing action. `void_duplicate` takes a demand off
the books — reduce an invoice, or cancel it outright — and it is
deliberately not a waiver. A waiver forgives a debt that was real and is
revenue given away; this removes a demand that should never have existed,
so the club is not out of pocket by a piaster and it reports as negative
billing. The cart's third tile flips to "سيُحذف من المطلوب" rather than
showing a minus sign under "سيُفوتَر".

Detecting is only half a tool. An operator standing in front of a parent
needs to know WHICH of six invoices carries the duplicate, and the second
spent working that out is the second the wrong one gets cancelled. So the
wizard matches the excess back to the invoices that could be carrying it,
newest first, and pre-fills the amount: on the reported account it offers
"INV-000593: 5,500 -> 3,000" as one button. The worklist states the
figure on the row, so the excess is visible before anything is opened, and
severity now colours the chips — a double-billing and a missing month read
identically before, which is why the list got worked top-to-bottom.

The guard is the whole value of the thing: an invoice with money collected
against it is never touched. Reducing one below what was paid strands real
money — the payment row saying one thing, its immutable ledger entry
another, which is exactly the damage the old correction wizard used to do.
That money has somewhere to go (move_payment, or credit_wallet) and which
is right is a person's decision, so the action refuses and the panel says
so instead of offering a button. Same refusal for an invoice belonging to
another participant, for raising an amount through a tool named for
lowering it, and for an invoice with more than one line, where reducing
the header cannot say which of two lines was the duplicate.

Verified end to end against the restored tenant, which still carries the
account in its broken state: scanner flags one, screen proposes
INV-000593 5,500 -> 3,000, applying it leaves billed 8,000 of an 8,000
card with 3,000 still owed, and the re-scan comes back clean — the same
correction that had to be made by hand on production, now one click.
364 tests green on SQLite and Postgres.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 396f19ac
...@@ -61,6 +61,7 @@ class SettlementService ...@@ -61,6 +61,7 @@ class SettlementService
'plan_installments', 'plan_installments',
'move_payment', 'move_payment',
'credit_wallet', 'credit_wallet',
'void_duplicate',
]; ];
private const ARABIC_MONTHS = [ private const ARABIC_MONTHS = [
...@@ -125,6 +126,7 @@ public function apply( ...@@ -125,6 +126,7 @@ public function apply(
'plan_installments' => $this->planInstallments($participant, $action), 'plan_installments' => $this->planInstallments($participant, $action),
'move_payment' => $this->movePayment($participant, $action, $actor), 'move_payment' => $this->movePayment($participant, $action, $actor),
'credit_wallet' => $this->creditWallet($participant, $action, $actor), 'credit_wallet' => $this->creditWallet($participant, $action, $actor),
'void_duplicate' => $this->voidDuplicate($participant, $action, $actor),
}; };
$collected += $result['collected'] ?? 0; $collected += $result['collected'] ?? 0;
...@@ -684,6 +686,115 @@ private function creditWallet(Participant $participant, array $a, User $actor): ...@@ -684,6 +686,115 @@ private function creditWallet(Participant $participant, array $a, User $actor):
* *
* @return int piastres written off * @return int piastres written off
*/ */
/**
* Take off the books a demand for money the books already carry.
*
* This is not a waiver, and the distinction is the whole point. A waiver
* forgives a debt that was real — the club decided not to collect it, and
* that is revenue given away. This removes a demand that should never have
* existed: the same instalment invoiced twice, or a "remainder" worked out
* by subtracting one payment when two had been made. The club is not out of
* pocket by a piaster; what changes is that a parent stops being asked for
* money he does not owe. So it is reported as negative billing, never as an
* amount written off.
*
* The real case it was built for: an 8,000 registration card whose first
* instalment was invoiced in July and invoiced again in August, after which
* the remainder was computed as 8,000 − 2,500 rather than 8,000 − 5,000.
* The books said 10,500 and the boy was shown as owing 5,500 instead of
* 3,000.
*
* Guarded on the one thing that matters: an invoice with money collected
* against it is not touched. Reducing it below what was paid would strand
* real money — the payment row saying one thing and the invoice another,
* which is exactly the damage the old correction wizard used to do — and
* that money has somewhere to go: move_payment onto the invoice it actually
* settles, or credit_wallet. Which of those is right is a person's
* decision, and this action refuses rather than guessing.
*/
private function voidDuplicate(Participant $participant, array $a, User $actor): array
{
$invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
$invoice = Invoice::withoutGlobalScopes()
->where('id', $invoice->id)
->lockForUpdate()
->firstOrFail();
$before = (int) $invoice->total_amount;
$after = max(0, (int) ($a['new_amount'] ?? 0));
if ((int) $invoice->paid_amount > 0) {
throw new DomainException(
'الفاتورة ' . $invoice->number . ' محصَّل عليها ' . format_money($invoice->paid_amount)
. ' — المال المحصَّل لا يُحذف. انقل الدفعة إلى فاتورتها الصحيحة أو أضفها لمحفظة المشترك أولاً.'
);
}
if ($after >= $before) {
throw new DomainException(
'المبلغ الجديد يجب أن يكون أقل من قيمة الفاتورة ' . $invoice->number
. ' (' . format_money($before) . ')'
);
}
// One line, or a person decides. A duplicate invoice carrying a card
// instalment AND a kit cannot be reduced from the header without saying
// which of the two was the duplicate, and guessing there is how the
// wrong half gets written off.
$items = InvoiceItem::withoutGlobalScopes()->where('invoice_id', $invoice->id)->get();
if ($items->count() !== 1) {
throw new DomainException(
'الفاتورة ' . $invoice->number . ' تحتوي على ' . $items->count()
. ' بنود — حدِّد البند المكرر يدوياً بدلاً من تعديل إجمالي الفاتورة.'
);
}
$removed = $before - $after;
$metadata = $invoice->metadata ?? [];
$metadata['duplicate_void'] = [
'applied_at' => now()->toIso8601String(),
'applied_by_id' => $actor->id,
'applied_by_name' => $actor->name,
'original_total_piasters' => $before,
'new_total_piasters' => $after,
'removed_piasters' => $removed,
'reason' => $this->note($a, 'إزالة مبلغ مكرر — محاسَب مرتين على نفس البند'),
'source' => 'settlement',
];
$invoice->update([
'subtotal_amount' => $after,
'total_amount' => $after,
'due_amount' => $after,
// Nothing was collected, so a zeroed invoice is not a settled bill —
// it is a demand that is withdrawn, and it leaves the books.
'status' => $after > 0 ? $invoice->status : InvoiceStatus::Cancelled,
'cancelled_at' => $after > 0 ? $invoice->cancelled_at : now(),
'notes' => $metadata['duplicate_void']['reason'],
'metadata' => $metadata,
]);
$item = $items->first();
$item->update(['unit_price' => $after, 'total_amount' => $after]);
return [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'original_amount' => $before,
'new_amount' => $after,
'removed' => $removed,
// Negative billing, not a waiver: the club gave up nothing, it
// stopped asking for something it had already asked for.
'billed' => -$removed,
'label' => $after > 0
? 'خفض الفاتورة ' . $invoice->number . ' من ' . format_money($before) . ' إلى ' . format_money($after)
: 'إلغاء الفاتورة المكررة ' . $invoice->number . ' (' . format_money($before) . ')',
];
}
private function writeOffRemainder(Invoice $invoice, User $actor, string $reason): int private function writeOffRemainder(Invoice $invoice, User $actor, string $reason): int
{ {
$invoice = Invoice::withoutGlobalScopes() $invoice = Invoice::withoutGlobalScopes()
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner; use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Financial\Services\SettlementService; use App\Domain\Financial\Services\SettlementService;
use App\Domain\Financial\Support\BundledProductLine;
use App\Domain\Inventory\Models\Product; use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService; use App\Domain\Pricing\Services\PricingService;
...@@ -200,6 +201,88 @@ public function getDiagnosisProperty(): array ...@@ -200,6 +201,88 @@ public function getDiagnosisProperty(): array
return app(AccountAnomalyScanner::class)->forParticipant($participant); return app(AccountAnomalyScanner::class)->forParticipant($participant);
} }
/**
* For each requirement billed above its price, the exact invoice to reduce
* and the exact amount to reduce it to.
*
* Detecting a double-billing is only half a tool. The operator standing in
* front of a parent needs to know *which* of six invoices carries the
* duplicate, and every second spent working that out by hand is a second in
* which the wrong one gets cancelled. So the excess is matched back to the
* invoices that could be carrying it, newest first, and only those with
* nothing collected against them — money that actually arrived is never a
* candidate for deletion, and an account whose excess sits entirely on paid
* invoices is reported as needing a person rather than offered a button.
*
* @return array<int, array{product_name:string, over_billed:int, fixes:array, blocked:bool, collected_excess:int}>
*/
public function getOverBilledFixesProperty(): array
{
$participant = $this->participant;
$rows = $this->diagnosis['over_billed'] ?? [];
if (! $participant || $rows === []) {
return [];
}
$programNames = $participant->enrollments
->pluck('program.name_ar')->filter()->values()->all();
$out = [];
foreach ($rows as $row) {
$identifying = BundledProductLine::identifyingWords($row['product_name'], $programNames);
// Invoices that carry this product as free text — the hand-typed
// instalments the excess is made of. A real product line froze its
// own price and is not what over-billed this account.
$carrying = $this->invoices->filter(function (Invoice $invoice) use ($row, $identifying) {
return $invoice->items->contains(fn ($item) => $item->itemable_type === null
&& BundledProductLine::matches($item->description, $row['product_name'], $identifying));
});
$unpaid = $carrying->where('paid_amount', 0)->sortByDesc('issue_date')->values();
$left = (int) $row['over_billed'];
$fixes = [];
foreach ($unpaid as $invoice) {
if ($left <= 0) {
break;
}
$before = (int) $invoice->total_amount;
$cut = min($left, $before);
$left -= $cut;
$fixes[] = [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->number,
'description' => (string) ($invoice->items->first()?->description ?? ''),
'lines' => $invoice->items->count(),
'current' => $before,
'new_amount' => $before - $cut,
'removed' => $cut,
];
}
$out[] = [
'product_name' => $row['product_name'],
'over_billed' => (int) $row['over_billed'],
'billed' => (int) $row['billed'],
'price' => (int) $row['price'],
'paid' => (int) $row['paid'],
'fixes' => $fixes,
// What could not be taken off an uncollected invoice: real money
// that has to be moved or refunded, which is not this tool's call.
'collected_excess' => $left,
'blocked' => $left > 0,
];
}
return $out;
}
/** /**
* What a month of this participant's programme costs today — the number the * What a month of this participant's programme costs today — the number the
* operator starts from when billing a month nobody billed. The pricing * operator starts from when billing a month nobody billed. The pricing
...@@ -330,6 +413,16 @@ private function defaultsFor(string $type, array $context): array ...@@ -330,6 +413,16 @@ private function defaultsFor(string $type, array $context): array
'description' => (string) ($context['description'] ?? ''), 'description' => (string) ($context['description'] ?? ''),
'product_name' => (string) ($context['product_name'] ?? ''), 'product_name' => (string) ($context['product_name'] ?? ''),
], ],
'void_duplicate' => [
'invoice_id' => (int) ($context['invoice_id'] ?? 0),
'invoice_number' => (string) ($context['invoice_number'] ?? ''),
'current' => (int) ($context['current'] ?? 0),
// The computed answer, already filled in: the operator confirms
// a number rather than working one out at the desk.
'new_amount' => $this->money((int) ($context['new_amount'] ?? 0)),
'product_name' => (string) ($context['product_name'] ?? ''),
'note' => '',
],
'plan_installments' => [ 'plan_installments' => [
'invoice_id' => (int) ($context['invoice_id'] ?? 0), 'invoice_id' => (int) ($context['invoice_id'] ?? 0),
'invoice_number' => (string) ($context['invoice_number'] ?? ''), 'invoice_number' => (string) ($context['invoice_number'] ?? ''),
...@@ -450,6 +543,30 @@ private function validateDraft(): array ...@@ -450,6 +543,30 @@ private function validateDraft(): array
$errors['amount'] = __('أدخل مبلغاً أكبر من صفر'); $errors['amount'] = __('أدخل مبلغاً أكبر من صفر');
} }
if ($type === 'void_duplicate') {
$invoice = $this->invoices->firstWhere('id', (int) ($d['invoice_id'] ?? 0));
if (! $invoice) {
$errors['invoice_id'] = __('الفاتورة غير موجودة');
} else {
// The same three refusals the service makes, said early so the
// operator learns why here rather than after pressing apply.
if ((int) $invoice->paid_amount > 0) {
$errors['invoice_id'] = __('هذه الفاتورة محصَّل عليها — انقل الدفعة أو أضفها للمحفظة بدلاً من حذفها');
}
if ($amount('new_amount') >= (int) $invoice->total_amount) {
$errors['new_amount'] = __('المبلغ الجديد يجب أن يكون أقل من قيمة الفاتورة');
}
if ($invoice->items->count() !== 1) {
$errors['invoice_id'] = __('الفاتورة تحتوي على أكثر من بند — عدِّل البند المكرر يدوياً');
}
}
if ($amount('new_amount') < 0) {
$errors['new_amount'] = __('المبلغ لا يمكن أن يكون سالباً');
}
}
if ($type === 'plan_installments') { if ($type === 'plan_installments') {
if ((int) ($d['total_installments'] ?? 0) < 2) { if ((int) ($d['total_installments'] ?? 0) < 2) {
$errors['total_installments'] = __('عدد الأقساط اثنان على الأقل'); $errors['total_installments'] = __('عدد الأقساط اثنان على الأقل');
...@@ -517,6 +634,13 @@ private function normaliseDraft(): array ...@@ -517,6 +634,13 @@ private function normaliseDraft(): array
'description' => (string) ($d['description'] ?? ''), 'description' => (string) ($d['description'] ?? ''),
'product_name' => (string) ($d['product_name'] ?? ''), 'product_name' => (string) ($d['product_name'] ?? ''),
], ],
'void_duplicate' => $base + [
'invoice_id' => (int) $d['invoice_id'],
'invoice_number' => (string) ($d['invoice_number'] ?? ''),
'new_amount' => $p('new_amount'),
'removed' => max(0, (int) ($d['current'] ?? 0) - $p('new_amount')),
'product_name' => (string) ($d['product_name'] ?? ''),
],
'plan_installments' => $base + [ 'plan_installments' => $base + [
'invoice_id' => (int) $d['invoice_id'], 'invoice_id' => (int) $d['invoice_id'],
'invoice_number' => (string) ($d['invoice_number'] ?? ''), 'invoice_number' => (string) ($d['invoice_number'] ?? ''),
...@@ -550,6 +674,10 @@ public function getTotalsProperty(): array ...@@ -550,6 +674,10 @@ public function getTotalsProperty(): array
$billed += $item['unit_price'] * $item['quantity'], $billed += $item['unit_price'] * $item['quantity'],
$collected += $item['paid_amount'], $collected += $item['paid_amount'],
], ],
// Negative billing, not a waiver. Removing a demand the books
// should never have carried costs the club nothing, and showing
// it under "written off" would read as revenue given away.
'void_duplicate' => $billed -= $item['removed'],
default => null, default => null,
}; };
} }
......
...@@ -84,10 +84,16 @@ public function export() ...@@ -84,10 +84,16 @@ public function export()
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
// BOM so Excel opens Arabic correctly instead of as mojibake. // BOM so Excel opens Arabic correctly instead of as mojibake.
fwrite($out, "\xEF\xBB\xBF"); fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['رقم المشترك', 'الاسم', 'الهاتف', 'البرنامج', 'الحالات', 'فواتير غير مسددة', 'المستحق (ج.م)', 'المحصَّل (ج.م)']); fputcsv($out, ['رقم المشترك', 'الاسم', 'الهاتف', 'البرنامج', 'الحالات', 'فواتير غير مسددة', 'المستحق (ج.م)', 'المحصَّل (ج.م)', 'زيادة محاسَبة (ج.م)']);
foreach ($rows as $row) { foreach ($rows as $row) {
$participant = $row['participant']; $participant = $row['participant'];
// Money the books are asking for twice. It belongs in the export
// for the same reason it belongs on the screen: it is the one
// figure on the row that is not a debt.
$overBilled = array_sum(array_column($row['detail']['over_billed_bundle'] ?? [], 'over_billed'));
fputcsv($out, [ fputcsv($out, [
$participant->id, $participant->id,
$participant->person?->name_ar, $participant->person?->name_ar,
...@@ -97,6 +103,7 @@ public function export() ...@@ -97,6 +103,7 @@ public function export()
$row['unpaid_invoices'], $row['unpaid_invoices'],
number_format($row['owed'] / 100, 2, '.', ''), number_format($row['owed'] / 100, 2, '.', ''),
number_format($row['paid'] / 100, 2, '.', ''), number_format($row['paid'] / 100, 2, '.', ''),
$overBilled > 0 ? number_format($overBilled / 100, 2, '.', '') : '',
]); ]);
} }
......
...@@ -55,6 +55,16 @@ ...@@ -55,6 +55,16 @@
{{ __('خطة أقساط') }} {{ $item['paid_installments'] }}/{{ $item['total_installments'] }} {{ __('خطة أقساط') }} {{ $item['paid_installments'] }}/{{ $item['total_installments'] }}
{{ __('على') }} <span dir="ltr">{{ $item['invoice_number'] }}</span> {{ __('على') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
@break @break
@case('void_duplicate')
@if($item['new_amount'] > 0)
{{ __('خفض الفاتورة المكررة') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
{{ __('إلى') }} <span dir="ltr" class="font-bold">{{ format_money($item['new_amount']) }}</span>
<span class="text-rose-700">(−<span dir="ltr">{{ format_money($item['removed']) }}</span>)</span>
@else
{{ __('إلغاء الفاتورة المكررة') }} <span dir="ltr">{{ $item['invoice_number'] }}</span>
<span class="text-rose-700">(−<span dir="ltr">{{ format_money($item['removed']) }}</span>)</span>
@endif
@break
@endswitch @endswitch
</p> </p>
@if(!empty($item['note'])) @if(!empty($item['note']))
...@@ -78,10 +88,20 @@ class="text-xs text-red-600 hover:text-red-800 shrink-0">{{ __('حذف') }}</but ...@@ -78,10 +88,20 @@ class="text-xs text-red-600 hover:text-red-800 shrink-0">{{ __('حذف') }}</but
<p class="text-[11px] text-red-700">{{ __('سيُسقَط') }}</p> <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> <p class="text-sm font-bold text-red-800 tabular-nums" dir="ltr">{{ format_money($totals['waived']) }}</p>
</div> </div>
<div class="p-2.5 bg-gray-50 rounded-lg"> {{-- Billing can go down as well as up: removing a demand the books should
<p class="text-[11px] text-gray-600">{{ __('سيُفوتَر') }}</p> never have carried is negative billing, and reading it as a minus sign
<p class="text-sm font-bold text-gray-800 tabular-nums" dir="ltr">{{ format_money($totals['billed']) }}</p> on "سيُفوتَر" is worse than naming what it actually is. --}}
</div> @if($totals['billed'] < 0)
<div class="p-2.5 bg-rose-50 rounded-lg">
<p class="text-[11px] text-rose-700">{{ __('سيُحذف من المطلوب') }}</p>
<p class="text-sm font-bold text-rose-800 tabular-nums" dir="ltr">{{ format_money(abs($totals['billed'])) }}</p>
</div>
@else
<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>
@endif
</div> </div>
@endif @endif
</div> </div>
...@@ -83,9 +83,27 @@ class="px-3 py-1.5 text-xs rounded-full border {{ $caseFilter === $code ? 'bg-am ...@@ -83,9 +83,27 @@ class="px-3 py-1.5 text-xs rounded-full border {{ $caseFilter === $code ? 'bg-am
<td class="px-3 py-2.5"> <td class="px-3 py-2.5">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@foreach($row['cases'] as $case) @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> {{-- Severity is the point of the list; a duplicate billing and a
missing month reading identically is what made the operator
work top-to-bottom instead of worst-first. --}}
<span class="px-1.5 py-0.5 text-[10px] rounded-full
{{ ($cases[$case]['severity'] ?? 9) <= 2 ? 'bg-rose-100 text-rose-800' : 'bg-amber-100 text-amber-800' }}">
{{ $cases[$case]['label'] }}
</span>
@endforeach @endforeach
</div> </div>
@if(!empty($row['detail']['over_billed_bundle']))
{{-- The figure is the reason this row is here; making the operator
open the wizard to find out how much is a wasted click. --}}
@foreach($row['detail']['over_billed_bundle'] as $b)
<p class="mt-1 text-[11px] text-rose-700">
{{ \Illuminate\Support\Str::limit($b['product_name'], 26) }}:
{{ __('محاسَب') }} <span dir="ltr">{{ format_money($b['billed']) }}</span>
{{ __('وسعره') }} <span dir="ltr">{{ format_money($b['price']) }}</span> —
<span class="font-bold">{{ __('زيادة') }} <span dir="ltr">{{ format_money($b['over_billed']) }}</span></span>
</p>
@endforeach
@endif
</td> </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">{{ $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"> <td class="px-3 py-2.5 text-center tabular-nums font-bold {{ $row['owed'] > 0 ? 'text-red-700' : 'text-gray-400' }}" dir="ltr">
......
...@@ -691,4 +691,150 @@ public function test_the_diagnosis_answers_the_wizards_questions(): void ...@@ -691,4 +691,150 @@ public function test_the_diagnosis_answers_the_wizards_questions(): void
$this->assertGreaterThan(0, $diagnosis['money']['owed'], 'This participant was chosen for owing money.'); $this->assertGreaterThan(0, $diagnosis['money']['owed'], 'This participant was chosen for owing money.');
} }
// ---- money the books ask for twice ------------------------------------
/**
* An invoice nothing was collected against, carrying one line: the shape a
* duplicate always has, because the duplicate is the demand nobody paid.
*/
private function unpaidSingleLineInvoiceOf(Participant $participant): ?Invoice
{
return Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->where('paid_amount', 0)
->where('total_amount', '>', 0)
->where('status', '!=', InvoiceStatus::Cancelled)
->whereNull('deleted_at')
->has('items', '=', 1)
->orderByDesc('id')
->first();
}
private function participantWithUnpaidInvoice(): Participant
{
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('paid_amount', 0)
->where('total_amount', '>', 0)
->where('status', '!=', InvoiceStatus::Cancelled)
->whereNull('deleted_at')
->has('items', '=', 1)
->orderBy('id')
->firstOrFail();
return Participant::withoutGlobalScopes()->with('person')->findOrFail($invoice->billable_id);
}
public function test_a_duplicate_demand_is_reduced_and_counts_as_negative_billing(): void
{
$participant = $this->participantWithUnpaidInvoice();
$invoice = $this->unpaidSingleLineInvoiceOf($participant);
$before = (int) $invoice->total_amount;
$target = intdiv($before, 2);
$settlement = $this->service()->apply($participant, [[
'type' => 'void_duplicate',
'invoice_id' => $invoice->id,
'new_amount' => $target,
'note' => 'القسط الأول محاسَب مرتين',
]], 'مراجعة حساب — بند مكرر على فاتورتين', $this->actor(), $participant->branch_id);
$invoice->refresh();
$this->assertSame($target, (int) $invoice->total_amount);
$this->assertSame($target, (int) $invoice->subtotal_amount);
$this->assertSame($target, (int) $invoice->due_amount);
$this->assertSame($target, (int) $invoice->items()->first()->total_amount,
'The line has to move with the header or the invoice stops adding up.');
$this->assertSame(-($before - $target), $settlement->billed_amount,
'Removing a demand is negative billing, not revenue collected.');
$this->assertSame(0, $settlement->waived_amount,
'Nothing was written off: the club never had a claim to this money.');
$this->assertSame(0, $settlement->collected_amount);
$this->assertArrayHasKey('duplicate_void', $invoice->metadata ?? [],
'The correction has to say who did it and what it removed.');
}
public function test_zeroing_a_duplicate_takes_the_invoice_off_the_books(): void
{
$participant = $this->participantWithUnpaidInvoice();
$invoice = $this->unpaidSingleLineInvoiceOf($participant);
$this->service()->apply($participant, [[
'type' => 'void_duplicate',
'invoice_id' => $invoice->id,
'new_amount' => 0,
]], 'فاتورة مكررة بالكامل — تُلغى', $this->actor(), $participant->branch_id);
$invoice->refresh();
$this->assertSame(InvoiceStatus::Cancelled, $invoice->status,
'A zeroed demand is withdrawn, not a settled bill sitting at zero.');
$this->assertNotNull($invoice->cancelled_at);
$this->assertSame(0, (int) $invoice->due_amount);
}
/** The guard that matters: collected money is never deleted. */
public function test_an_invoice_with_money_on_it_is_refused(): void
{
$participant = $this->participantWithDue();
$paid = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->where('paid_amount', '>', 0)
->whereNull('deleted_at')
->first();
if (! $paid) {
$this->markTestSkipped('This participant has no collected invoice to try it on.');
}
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'void_duplicate',
'invoice_id' => $paid->id,
'new_amount' => 0,
]], 'محاولة حذف فاتورة محصَّل عليها', $this->actor(), $participant->branch_id);
}
public function test_raising_an_invoice_through_this_action_is_refused(): void
{
$participant = $this->participantWithUnpaidInvoice();
$invoice = $this->unpaidSingleLineInvoiceOf($participant);
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'void_duplicate',
'invoice_id' => $invoice->id,
'new_amount' => (int) $invoice->total_amount + 100,
]], 'محاولة رفع قيمة الفاتورة من أداة الحذف', $this->actor(), $participant->branch_id);
}
public function test_another_participants_invoice_cannot_be_voided(): void
{
$participant = $this->participantWithUnpaidInvoice();
$foreign = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', '!=', $participant->id)
->where('paid_amount', 0)
->where('status', '!=', InvoiceStatus::Cancelled)
->whereNull('deleted_at')
->firstOrFail();
$this->expectException(DomainException::class);
$this->service()->apply($participant, [[
'type' => 'void_duplicate',
'invoice_id' => $foreign->id,
'new_amount' => 0,
]], 'محاولة الوصول لفاتورة مشترك آخر', $this->actor(), $participant->branch_id);
}
} }
...@@ -129,4 +129,124 @@ public function test_no_dead_links_in_the_settlement_screens(): void ...@@ -129,4 +129,124 @@ public function test_no_dead_links_in_the_settlement_screens(): void
$this->assertStringNotContainsString('href="#"', $content, $view); $this->assertStringNotContainsString('href="#"', $content, $view);
} }
} }
// ---- the double-billing case, end to end on the screen -----------------
/** A participant the scanner flags as billed above a requirement's price. */
private function anOverBilledParticipant(): Participant
{
$rows = app(\App\Domain\Financial\Services\AccountAnomalyScanner::class)
->scan(null, 'over_billed_bundle', 5);
if ($rows === []) {
$this->markTestSkipped('No account in the restored tenant is billed above a card price.');
}
return $rows[0]['participant'];
}
public function test_the_worklist_offers_the_double_billing_filter(): void
{
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'over_billed_bundle']));
$response->assertOk();
$response->assertSee('مستلزم محاسَب بأكثر من سعره', escape: false);
}
public function test_the_worklist_row_states_the_excess_without_opening_the_wizard(): void
{
$this->anOverBilledParticipant();
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'over_billed_bundle']));
$response->assertOk();
$response->assertSee('زيادة', escape: false);
}
public function test_the_wizard_proposes_the_invoice_to_reduce_and_the_amount(): void
{
$participant = $this->anOverBilledParticipant();
$component = \Livewire\Livewire::actingAs($this->anAdmin())
->test(\App\Livewire\Admin\AccountSettlementWizard::class, ['participant' => $participant->id]);
$fixes = $component->instance()->overBilledFixes;
$this->assertNotEmpty($fixes, 'A flagged account must come with something to do about it.');
$fix = $fixes[0];
$this->assertGreaterThan(0, $fix['over_billed']);
$this->assertNotEmpty($fix['fixes'], 'The excess sits on an uncollected invoice, so a fix is offerable.');
$candidate = $fix['fixes'][0];
$this->assertSame(
$candidate['current'] - $candidate['removed'],
$candidate['new_amount'],
'The proposed amount is the invoice minus its share of the excess.'
);
$invoice = Invoice::withoutGlobalScopes()->findOrFail($candidate['invoice_id']);
$this->assertSame(0, (int) $invoice->paid_amount,
'Only invoices with nothing collected are ever proposed for reduction.');
$this->assertSame((int) $participant->id, (int) $invoice->billable_id);
}
public function test_the_fix_can_be_staged_into_the_settlement_cart(): void
{
$participant = $this->anOverBilledParticipant();
$component = \Livewire\Livewire::actingAs($this->anAdmin())
->test(\App\Livewire\Admin\AccountSettlementWizard::class, ['participant' => $participant->id]);
$candidate = $component->instance()->overBilledFixes[0]['fixes'][0];
$component->call('startDraft', 'void_duplicate', [
'invoice_id' => $candidate['invoice_id'],
'invoice_number' => $candidate['invoice_number'],
'current' => $candidate['current'],
'new_amount' => $candidate['new_amount'],
])->call('addDraft')->assertHasNoErrors();
$cart = $component->instance()->cart;
$this->assertCount(1, $cart);
$this->assertSame('void_duplicate', $cart[0]['type']);
$this->assertSame($candidate['new_amount'], $cart[0]['new_amount']);
$this->assertSame($candidate['removed'], $cart[0]['removed']);
// Negative billing, and nothing written off — the club is not out of pocket.
$totals = $component->instance()->totals;
$this->assertSame(-$candidate['removed'], $totals['billed']);
$this->assertSame(0, $totals['waived']);
$this->assertSame(0, $totals['collected']);
}
public function test_the_wizard_refuses_to_stage_a_reduction_on_a_collected_invoice(): void
{
$participant = $this->anOverBilledParticipant();
$collected = Invoice::withoutGlobalScopes()
->where('billable_id', $participant->id)
->where('billable_type', Participant::class)
->where('paid_amount', '>', 0)
->whereNull('deleted_at')
->first();
if (! $collected) {
$this->markTestSkipped('This account has no collected invoice to try it on.');
}
\Livewire\Livewire::actingAs($this->anAdmin())
->test(\App\Livewire\Admin\AccountSettlementWizard::class, ['participant' => $participant->id])
->call('startDraft', 'void_duplicate', [
'invoice_id' => $collected->id,
'invoice_number' => $collected->number,
'current' => (int) $collected->total_amount,
'new_amount' => 0,
])
->call('addDraft')
->assertHasErrors('draft.invoice_id');
}
} }
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