Commit 396f19ac authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(settlement): the card's price is the debt, not the sum of everything typed toward it

Participant 128 on OC-Sport: an 8,000 registration card, 2,500 paid in
July and 2,500 in August. The settlement screen told the operator he had
paid 5,000 of 10,500 and still owed 5,500, and offered a button to bill
him that 5,500 on top.

The 10,500 is real, and that is the problem. INV-000310 carried the first
instalment on 2 July. On 5 August the full 8,000 card was invoiced again
as INV-000588, 2,500 was collected against it, and the next day it was
split — reduced to 2,500 with a 5,500 remainder as INV-000593 — by
someone who never saw the July instalment. Three lines, 10,500, for a
card that costs 8,000.

bundleStatus() then read `max(billed, price)`. That reading treats
hand-typed lines as if they defined the obligation, so every duplicate
and every correction raised the debt, and the excess disappeared into a
larger number instead of being noticed. But a hand-typed line is an
instalment TOWARD a card whose price the product record still holds: the
card is what is owed. A real product sale is different — its price froze
at the till — so that keeps billing as the obligation.

So: expected is the frozen sale price for a real sale, and the card's
price otherwise. Anything typed past it is the same money entered twice
and is reported as `over_billed_bundle` rather than absorbed. #128 now
reads 5,000 of 8,000 with 3,000 left, flagged for a 2,500 double entry.
A scan of the restored tenant finds exactly one such account: his.

Two guards on the tool that produced it. The correction wizard clamped
paid_amount down to the new total and rewrote the payment rows
themselves, so cutting an invoice below what had been collected against
it destroyed real money — the payment row said one thing and its
double-entry transaction still said another, and nobody was told. It now
refuses and names the settlement wizard, which can move the payment or
credit it to a wallet. And the split step lists what the account already
carries, so a second "first instalment" is visible before it is created
rather than three weeks after.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 22d43e2b
......@@ -61,6 +61,11 @@ class AccountAnomalyScanner
'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.',
'severity' => 6,
],
'over_billed_bundle' => [
'label' => 'مستلزم محاسَب بأكثر من سعره',
'hint' => 'مجموع البنود المكتوبة يدوياً لهذا المستلزم يتجاوز سعره — غالباً قسط أُدخل مرتين، أو تصحيح أضاف فاتورة بدل أن يحل محل القديمة.',
'severity' => 2,
],
'partial_bundle' => [
'label' => 'مستلزم مدفوع جزئياً',
'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.',
......@@ -168,6 +173,16 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
if ($partialAnomalies !== []) {
$cases[] = 'partial_bundle';
}
// Billed more than the card costs. The excess is on the books
// whatever else is true of the account, so this is checked
// against every bundle row, not only the unpaid ones.
$overBilled = array_values(array_filter($bundle, fn ($b) => ($b['over_billed'] ?? 0) > 0));
if ($overBilled !== []) {
$cases[] = 'over_billed_bundle';
$detail['over_billed_bundle'] = $overBilled;
}
}
if (! empty($unbilled[$participant->id])) {
......@@ -235,6 +250,7 @@ public function forParticipant(Participant $participant): array
'bundle_status' => $bundle = $this->bundleStatus($collection)[$participant->id] ?? [],
'missing_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'missing')),
'partial_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'partial')),
'over_billed' => array_values(array_filter($bundle, fn ($b) => ($b['over_billed'] ?? 0) > 0)),
'unbilled_months' => $this->unbilledMonths($collection)[$participant->id] ?? [],
'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [],
];
......@@ -505,12 +521,31 @@ private function bundleStatus($participants): array
? ($product->member_price ?? $product->selling_price)
: ($product->non_member_price ?? $product->selling_price);
// A real product sale froze the agreed price; hand-typed
// money is only the instalments so far, so the expected
// total is what this member would be charged.
$expected = ! empty($row['from_product_lines'])
? max($billed, 0)
: max($billed, (int) $price);
$isRealSale = ! empty($row['from_product_lines']);
// What the card actually obliges him to pay.
//
// A real product sale froze the agreed price at the till, so
// what it billed IS the obligation. Hand-typed lines are
// different in kind: each one is an instalment *toward* a
// card whose price the product record still holds, and the
// card is what is owed — not the sum of whatever anyone
// typed. Taking max(billed, price) meant every duplicate and
// every correction raised the debt: a boy billed 2,500 twice
// for the same first instalment and then 5,500 for "the
// rest" was told he owed 10,500 for an 8,000 card, and the
// "تسجيل باقي القيمة" button offered to bill him the
// inflated remainder again.
$expected = match (true) {
$isRealSale => $billed,
(int) $price > 0 => (int) $price,
default => $billed,
};
// Typed lines adding up to more than the card ever cost are
// not a larger obligation — they are the same money entered
// twice, and that is its own thing to go and look at.
$overBilled = $isRealSale ? 0 : max(0, $billed - $expected);
$status = match (true) {
$billed === 0 && $paid === 0 => 'missing',
......@@ -538,6 +573,7 @@ private function bundleStatus($participants): array
'expected' => $expected,
'billed' => $billed,
'paid' => $paid,
'over_billed' => $overBilled,
'price' => (int) $price,
'from_text' => (bool) ($row['from_text'] ?? false),
'inferred' => (bool) ($row['inferred'] ?? false),
......
......@@ -190,7 +190,11 @@ public function getDiagnosisProperty(): array
$participant = $this->participant;
if (! $participant) {
return ['money' => [], 'handtyped' => [], 'missing_bundle' => [], 'unbilled_months' => [], 'duplicate_of' => []];
return [
'money' => [], 'handtyped' => [], 'bundle_status' => [],
'missing_bundle' => [], 'partial_bundle' => [], 'over_billed' => [],
'unbilled_months' => [], 'duplicate_of' => [],
];
}
return app(AccountAnomalyScanner::class)->forParticipant($participant);
......
......@@ -204,6 +204,27 @@ public function applyCorrection(): void
return;
}
// Money that came in does not go away because a number on an invoice
// changed. This used to clamp paid_amount down to the new total and
// rewrite the payment rows themselves — so cutting an 8,000 invoice
// that had 5,000 collected against it down to 2,500 destroyed 2,500 of
// real money: the payment row said one thing, its double-entry
// transaction still said another, and nobody was ever told. The
// settlement wizard exists for this: it can move the payment to the
// right invoice or credit it to the player's wallet, both of which
// leave the money somewhere.
$collected = (int) $invoice->paid_amount;
if ($this->correctionType !== 'change_description' && $collected > $newAmount) {
session()->flash('error', sprintf(
'محصَّل على هذه الفاتورة %s وهو أكبر من المبلغ الجديد %s. المال المحصَّل لا يُحذف — استخدم تسوية الحساب لنقل الدفعة إلى فاتورتها الصحيحة أو إضافتها لمحفظة المشترك، ثم صحّح الفاتورة.',
format_money($collected),
format_money($newAmount)
));
return;
}
try {
$result = DB::transaction(function () use ($invoice, $actor, $newAmount, $originalAmount, $difference) {
$oldData = [
......@@ -223,11 +244,9 @@ public function applyCorrection(): void
$invoice->subtotal_amount = $newAmount;
$invoice->total_amount = $newAmount;
// Adjust payments if they exceed new amount
if ($invoice->paid_amount > $newAmount) {
$invoice->paid_amount = $newAmount;
}
// paid_amount is left exactly as it is: the guard above refused
// the correction if it exceeded the new total, so there is
// nothing to clamp and nothing to lose.
$invoice->due_amount = max(0, $newAmount - $invoice->paid_amount);
// Update status based on new amounts
......@@ -271,12 +290,10 @@ public function applyCorrection(): void
]);
}
// Fix payment amounts if they exceeded the new total
foreach ($invoice->payments as $payment) {
if ($payment->amount > $newAmount) {
$payment->update(['amount' => $newAmount]);
}
}
// Payments are not edited here. A payment row is the record of
// money that physically arrived, and its double-entry
// transaction is immutable by the same rule — a correction that
// rewrote one and not the other left the two disagreeing.
// Create remainder invoice if needed
if ($this->createRemainderInvoice && $difference > 0) {
......@@ -401,9 +418,26 @@ public function render()
->get();
}
// Everything else already on this account, shown beside the split so a
// second "first instalment" is visible before it is created rather
// than after. Splitting an invoice in isolation is how one boy ended up
// billed 2,500 twice for the same instalment and then 5,500 for "the
// rest" of a card that only ever cost 8,000.
$otherInvoices = collect();
if ($this->selectedInvoiceId && $this->currentStep === 3 && $this->createRemainderInvoice) {
$otherInvoices = $this->correctableInvoices()
->with('items')
->whereKeyNot($this->selectedInvoiceId)
->whereNotIn('status', ['cancelled', 'draft'])
->orderByDesc('issue_date')
->limit(15)
->get();
}
return view('livewire.admin.invoice-correction-wizard', [
'searchResults' => $searchResults,
'invoices' => $invoices,
'otherInvoices' => $otherInvoices,
]);
}
}
......@@ -263,7 +263,8 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
: 0;
$remaining = max(0, $bundle['expected'] - $bundle['paid']);
@endphp
<li class="p-3 rounded-lg border {{ $bundle['status'] === 'paid' ? 'border-emerald-200 bg-emerald-50/50' : ($bundle['status'] === 'partial' ? 'border-amber-200 bg-amber-50/50' : 'border-red-200 bg-red-50/40') }}">
@php $overBilled = $bundle['over_billed'] ?? 0; @endphp
<li class="p-3 rounded-lg border {{ $overBilled > 0 ? 'border-rose-300 bg-rose-50/60' : ($bundle['status'] === 'paid' ? 'border-emerald-200 bg-emerald-50/50' : ($bundle['status'] === 'partial' ? 'border-amber-200 bg-amber-50/50' : 'border-red-200 bg-red-50/40')) }}">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">{{ $bundle['product_name'] }}</p>
......@@ -289,6 +290,17 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
</button>
@endif
</div>
@if($overBilled > 0)
{{-- The card's price is the obligation; anything typed beyond it is the
same money entered twice, and it stays visible until someone voids it. --}}
<p class="mt-2 text-xs text-rose-800 bg-rose-100/70 border border-rose-200 rounded-lg px-2 py-1.5">
{{ __('محاسَب بأكثر من سعره:') }}
{{ __('البنود المكتوبة تساوي') }} <span dir="ltr" class="font-bold">{{ format_money($bundle['billed']) }}</span>
{{ __('بينما سعر المستلزم') }} <span dir="ltr" class="font-bold">{{ format_money($bundle['price']) }}</span> —
{{ __('زيادة') }} <span dir="ltr" class="font-bold">{{ format_money($overBilled) }}</span>.
{{ __('غالباً قسط أُدخل مرتين أو تصحيح أضاف فاتورة بدل أن يحل محل القديمة — تُلغى الفاتورة الزائدة، ولا تُسجَّل قيمة جديدة.') }}
</p>
@endif
@if($bundle['expected'] > 0 && $bundle['status'] !== 'missing')
<div class="mt-2 h-1.5 w-full bg-gray-200 rounded-full overflow-hidden">
<div class="h-full rounded-full {{ $bundle['status'] === 'paid' ? 'bg-emerald-500' : 'bg-amber-500' }}"
......
......@@ -238,6 +238,30 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" dir="ltr">
placeholder="{{ __('أقساط متبقية...') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
@if($otherInvoices->isNotEmpty())
{{-- What the account already carries. The remainder is added on top of
all of this, so an instalment that is already billed here must be
subtracted from the new amount before it is created. --}}
<div class="rounded-lg border border-amber-200 bg-amber-50/60 p-3">
<p class="text-xs font-bold text-amber-900 mb-1">{{ __('فواتير أخرى على هذا الحساب') }}</p>
<p class="text-[11px] text-amber-800 mb-2">
{{ __('الفاتورة الجديدة تُضاف فوق ما يلي. راجعها أولاً: لو قسط من نفس القيد مسجَّل هنا بالفعل، اطرحه من المبلغ المتبقي قبل الإنشاء، وإلا حُوسِب المشترك على نفس القسط مرتين.') }}
</p>
<ul class="space-y-1 max-h-40 overflow-y-auto">
@foreach($otherInvoices as $other)
<li class="flex flex-wrap items-center justify-between gap-2 text-[11px] bg-white/70 rounded px-2 py-1">
<span class="text-gray-700 min-w-0">
<span dir="ltr" class="font-medium">{{ $other->number }}</span>
<span class="text-gray-400">·</span>
{{ $other->items->first()?->description ?? $other->notes }}
</span>
<span class="shrink-0 text-gray-800 font-bold" dir="ltr">{{ format_money($other->total_amount) }}</span>
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
</div>
......
This diff is collapsed.
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