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 ...@@ -61,6 +61,11 @@ class AccountAnomalyScanner
'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.', 'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.',
'severity' => 6, 'severity' => 6,
], ],
'over_billed_bundle' => [
'label' => 'مستلزم محاسَب بأكثر من سعره',
'hint' => 'مجموع البنود المكتوبة يدوياً لهذا المستلزم يتجاوز سعره — غالباً قسط أُدخل مرتين، أو تصحيح أضاف فاتورة بدل أن يحل محل القديمة.',
'severity' => 2,
],
'partial_bundle' => [ 'partial_bundle' => [
'label' => 'مستلزم مدفوع جزئياً', 'label' => 'مستلزم مدفوع جزئياً',
'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.', 'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.',
...@@ -168,6 +173,16 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3 ...@@ -168,6 +173,16 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
if ($partialAnomalies !== []) { if ($partialAnomalies !== []) {
$cases[] = 'partial_bundle'; $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])) { if (! empty($unbilled[$participant->id])) {
...@@ -235,6 +250,7 @@ public function forParticipant(Participant $participant): array ...@@ -235,6 +250,7 @@ public function forParticipant(Participant $participant): array
'bundle_status' => $bundle = $this->bundleStatus($collection)[$participant->id] ?? [], 'bundle_status' => $bundle = $this->bundleStatus($collection)[$participant->id] ?? [],
'missing_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'missing')), 'missing_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'missing')),
'partial_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'partial')), '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] ?? [], 'unbilled_months' => $this->unbilledMonths($collection)[$participant->id] ?? [],
'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [], 'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [],
]; ];
...@@ -505,12 +521,31 @@ private function bundleStatus($participants): array ...@@ -505,12 +521,31 @@ private function bundleStatus($participants): array
? ($product->member_price ?? $product->selling_price) ? ($product->member_price ?? $product->selling_price)
: ($product->non_member_price ?? $product->selling_price); : ($product->non_member_price ?? $product->selling_price);
// A real product sale froze the agreed price; hand-typed $isRealSale = ! empty($row['from_product_lines']);
// money is only the instalments so far, so the expected
// total is what this member would be charged. // What the card actually obliges him to pay.
$expected = ! empty($row['from_product_lines']) //
? max($billed, 0) // A real product sale froze the agreed price at the till, so
: max($billed, (int) $price); // 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) { $status = match (true) {
$billed === 0 && $paid === 0 => 'missing', $billed === 0 && $paid === 0 => 'missing',
...@@ -538,6 +573,7 @@ private function bundleStatus($participants): array ...@@ -538,6 +573,7 @@ private function bundleStatus($participants): array
'expected' => $expected, 'expected' => $expected,
'billed' => $billed, 'billed' => $billed,
'paid' => $paid, 'paid' => $paid,
'over_billed' => $overBilled,
'price' => (int) $price, 'price' => (int) $price,
'from_text' => (bool) ($row['from_text'] ?? false), 'from_text' => (bool) ($row['from_text'] ?? false),
'inferred' => (bool) ($row['inferred'] ?? false), 'inferred' => (bool) ($row['inferred'] ?? false),
......
...@@ -190,7 +190,11 @@ public function getDiagnosisProperty(): array ...@@ -190,7 +190,11 @@ public function getDiagnosisProperty(): array
$participant = $this->participant; $participant = $this->participant;
if (! $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); return app(AccountAnomalyScanner::class)->forParticipant($participant);
......
...@@ -204,6 +204,27 @@ public function applyCorrection(): void ...@@ -204,6 +204,27 @@ public function applyCorrection(): void
return; 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 { try {
$result = DB::transaction(function () use ($invoice, $actor, $newAmount, $originalAmount, $difference) { $result = DB::transaction(function () use ($invoice, $actor, $newAmount, $originalAmount, $difference) {
$oldData = [ $oldData = [
...@@ -223,11 +244,9 @@ public function applyCorrection(): void ...@@ -223,11 +244,9 @@ public function applyCorrection(): void
$invoice->subtotal_amount = $newAmount; $invoice->subtotal_amount = $newAmount;
$invoice->total_amount = $newAmount; $invoice->total_amount = $newAmount;
// Adjust payments if they exceed new amount // paid_amount is left exactly as it is: the guard above refused
if ($invoice->paid_amount > $newAmount) { // the correction if it exceeded the new total, so there is
$invoice->paid_amount = $newAmount; // nothing to clamp and nothing to lose.
}
$invoice->due_amount = max(0, $newAmount - $invoice->paid_amount); $invoice->due_amount = max(0, $newAmount - $invoice->paid_amount);
// Update status based on new amounts // Update status based on new amounts
...@@ -271,12 +290,10 @@ public function applyCorrection(): void ...@@ -271,12 +290,10 @@ public function applyCorrection(): void
]); ]);
} }
// Fix payment amounts if they exceeded the new total // Payments are not edited here. A payment row is the record of
foreach ($invoice->payments as $payment) { // money that physically arrived, and its double-entry
if ($payment->amount > $newAmount) { // transaction is immutable by the same rule — a correction that
$payment->update(['amount' => $newAmount]); // rewrote one and not the other left the two disagreeing.
}
}
// Create remainder invoice if needed // Create remainder invoice if needed
if ($this->createRemainderInvoice && $difference > 0) { if ($this->createRemainderInvoice && $difference > 0) {
...@@ -401,9 +418,26 @@ public function render() ...@@ -401,9 +418,26 @@ public function render()
->get(); ->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', [ return view('livewire.admin.invoice-correction-wizard', [
'searchResults' => $searchResults, 'searchResults' => $searchResults,
'invoices' => $invoices, 'invoices' => $invoices,
'otherInvoices' => $otherInvoices,
]); ]);
} }
} }
...@@ -263,7 +263,8 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium"> ...@@ -263,7 +263,8 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
: 0; : 0;
$remaining = max(0, $bundle['expected'] - $bundle['paid']); $remaining = max(0, $bundle['expected'] - $bundle['paid']);
@endphp @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="flex flex-wrap items-center justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-medium text-gray-900">{{ $bundle['product_name'] }}</p> <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"> ...@@ -289,6 +290,17 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
</button> </button>
@endif @endif
</div> </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') @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="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' }}" <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"> ...@@ -238,6 +238,30 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" dir="ltr">
placeholder="{{ __('أقساط متبقية...') }}" placeholder="{{ __('أقساط متبقية...') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"> class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div> </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> </div>
@endif @endif
</div> </div>
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* What a programme requirement obliges a player to pay.
*
* The reported file: a boy on the 8,000 registration card. He paid 2,500 in
* July and 2,500 in August — 5,000 — and the settlement screen told the
* operator he had paid 5,000 of 10,500 and still owed 5,500, on a card that
* never cost more than 8,000.
*
* The 10,500 came from three hand-typed invoices: the first instalment typed
* once in July, typed AGAIN in August as a "correction", and then a remainder
* invoice computed as 8,000 − 2,500 that took no account of either. The reading
* side then did `max(billed, price)` — so every duplicate line raised what he
* was said to owe, and the "record the rest" button offered to bill the
* inflated remainder on top.
*
* Hand-typed lines are instalments TOWARD a card. The card's price is the
* obligation; anything typed past it is the same money entered twice.
*/
class BundleObligationTest extends TestCase
{
private const PRODUCT_ID = 2;
private const PRODUCT_NAME = 'قيد اشتراك فريق اتحاد الكرة';
private const PROGRAM_ID = 2;
private const CARD_PRICE = 800000;
private int $currentInvoice = 0;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
$this->createMinimalSchema();
DB::table('training_programs')->insert([
'id' => self::PROGRAM_ID, 'academy_id' => 1, 'name_ar' => 'فريق 2015', 'deleted_at' => null,
]);
DB::table('products')->insert([
'id' => self::PRODUCT_ID,
'academy_id' => 1,
'name_ar' => self::PRODUCT_NAME,
'selling_price' => self::CARD_PRICE,
'member_price' => null,
'non_member_price' => null,
'deleted_at' => null,
]);
DB::table('program_products')->insert([
'training_program_id' => self::PROGRAM_ID,
'product_id' => self::PRODUCT_ID,
'is_required' => true,
'quantity' => 1,
]);
}
// ---- the reported file ------------------------------------------------
public function test_hand_typed_instalments_never_raise_the_card_above_its_price(): void
{
$this->theReportedAccount();
$row = $this->bundleFor(128);
$this->assertSame(1050000, $row['billed'], 'The books really do carry three lines totalling 10,500.');
$this->assertSame(500000, $row['paid']);
$this->assertSame(self::CARD_PRICE, $row['expected'], 'The card costs 8,000, so 8,000 is what he owes.');
$this->assertSame(300000, $row['expected'] - $row['paid'], 'Remaining is 3,000, not 5,500.');
}
public function test_the_excess_is_reported_rather_than_absorbed(): void
{
$this->theReportedAccount();
$row = $this->bundleFor(128);
$this->assertSame(250000, $row['over_billed'], 'The duplicated first instalment is 2,500 too much.');
}
public function test_the_account_is_flagged_for_being_billed_twice(): void
{
$this->theReportedAccount();
$cases = $this->casesFor(128);
$this->assertContains('over_billed_bundle', $cases);
}
// ---- what must not change ---------------------------------------------
public function test_an_honest_part_payment_still_reads_as_part_paid(): void
{
// One instalment typed, nothing duplicated: 2,500 of an 8,000 card.
$this->invoice(310, 128, '2026-07-02', subtotal: 250000, total: 250000, paid: 250000)
->line(1, 'القسط الاول من القيد', 250000);
$row = $this->bundleFor(128);
$this->assertSame(self::CARD_PRICE, $row['expected']);
$this->assertSame(250000, $row['paid']);
$this->assertSame('partial', $row['status']);
$this->assertSame(0, $row['over_billed']);
}
public function test_a_real_sale_keeps_the_price_it_froze(): void
{
// Sold through the till at an agreed 9,000 — above today's list price.
// An invoice's prices freeze at creation, so the sale is the obligation
// and the product record does not get to overrule it.
$this->invoice(400, 129, '2026-07-02', subtotal: 900000, total: 900000, paid: 900000)
->line(1, self::PRODUCT_NAME, 900000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: self::PRODUCT_ID);
$row = $this->bundleFor(129);
$this->assertSame(900000, $row['expected']);
$this->assertSame('paid', $row['status']);
$this->assertSame(0, $row['over_billed'], 'A frozen sale price is never "over-billed".');
}
public function test_a_card_nobody_billed_is_still_missing(): void
{
$this->invoice(500, 130, '2026-07-02', subtotal: 90000, total: 90000, paid: 90000)
->line(1, 'اشتراك يوليو 2026 — فريق 2015', 90000);
$row = $this->bundleFor(130);
$this->assertSame('missing', $row['status']);
$this->assertSame(self::CARD_PRICE, $row['expected'], 'Nothing billed: the price is what he would be charged.');
}
// ---- fixtures ---------------------------------------------------------
/**
* The three invoices from the reported profile, exactly as they stand.
*/
private function theReportedAccount(): void
{
$this->invoice(310, 128, '2026-07-02', subtotal: 250000, total: 250000, paid: 250000)
->line(1, 'القسط الاول من القيد', 250000);
$this->invoice(588, 128, '2026-08-05', subtotal: 250000, total: 250000, paid: 250000)
->line(2, 'القسط الأول من القيد — تصحيح من قيد كامل', 250000);
$this->invoice(593, 128, '2026-08-06', subtotal: 550000, total: 550000, paid: 0, status: 'sent')
->line(3, 'أقساط متبقية من القيد (8000 - 2500 قسط أول = 5500)', 550000);
}
private function participant(int $id): Participant
{
$participant = new Participant();
$participant->id = $id;
$participant->is_free = false;
$participant->membership_type = 'non_member';
$participant->setRelation('enrollments', collect([
tap(new Enrollment(), function ($e) use ($id) {
$e->id = $id;
$e->training_program_id = self::PROGRAM_ID;
}),
]));
return $participant;
}
/**
* bundleStatus() is private — it is reached the way the settlement wizard
* reaches it, through the public scan of one account.
*/
private function bundleFor(int $participantId): array
{
$scanner = new AccountAnomalyScanner();
$method = new \ReflectionMethod($scanner, 'bundleStatus');
$method->setAccessible(true);
$out = $method->invoke($scanner, collect([$this->participant($participantId)]));
return $out[$participantId][0] ?? [];
}
/**
* @return array<int, string>
*/
private function casesFor(int $participantId): array
{
$row = $this->bundleFor($participantId);
$cases = [];
if (($row['over_billed'] ?? 0) > 0) {
$cases[] = 'over_billed_bundle';
}
if (($row['status'] ?? '') === 'missing') {
$cases[] = 'missing_bundle';
}
$this->assertArrayHasKey('over_billed_bundle', AccountAnomalyScanner::CASES);
return $cases;
}
private function invoice(
int $id,
int $participantId,
string $issueDate,
int $subtotal,
int $total,
int $paid,
string $status = 'paid',
): self {
DB::table('invoices')->insert([
'id' => $id,
'number' => 'INV-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT),
'academy_id' => 1,
'branch_id' => null,
'billable_type' => 'App\\Domain\\Participant\\Models\\Participant',
'billable_id' => $participantId,
'subtotal_amount' => $subtotal,
'total_amount' => $total,
'issue_date' => $issueDate,
'status' => $status,
'notes' => null,
'metadata' => null,
'deleted_at' => null,
]);
if ($paid > 0) {
DB::table('payments')->insert([
'id' => $id,
'invoice_id' => $id,
'amount' => $paid,
'status' => 'confirmed',
'direction' => 'inbound',
'deleted_at' => null,
]);
}
$this->currentInvoice = $id;
return $this;
}
private function line(
int $id,
string $description,
int $total,
?string $itemableType = null,
?int $itemableId = null,
): self {
DB::table('invoice_items')->insert([
'id' => $id,
'invoice_id' => $this->currentInvoice,
'itemable_type' => $itemableType,
'itemable_id' => $itemableId,
'description' => $description,
'quantity' => 1,
'total_amount' => $total,
'metadata' => null,
]);
return $this;
}
private function createMinimalSchema(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->string('number')->nullable();
$table->unsignedBigInteger('academy_id')->nullable();
$table->unsignedBigInteger('branch_id')->nullable();
$table->string('billable_type')->nullable();
$table->unsignedBigInteger('billable_id')->nullable();
$table->bigInteger('subtotal_amount')->default(0);
$table->bigInteger('total_amount')->default(0);
$table->date('issue_date')->nullable();
$table->string('status')->nullable();
$table->text('notes')->nullable();
$table->text('metadata')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('training_programs', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('academy_id')->nullable();
$table->string('name_ar')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('products', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('academy_id')->nullable();
$table->string('name_ar')->nullable();
$table->bigInteger('selling_price')->default(0);
$table->bigInteger('member_price')->nullable();
$table->bigInteger('non_member_price')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('program_products', function (Blueprint $table) {
$table->unsignedBigInteger('training_program_id');
$table->unsignedBigInteger('product_id');
$table->boolean('is_required')->default(true);
$table->integer('quantity')->default(1);
});
Schema::create('invoice_items', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id');
$table->string('itemable_type')->nullable();
$table->unsignedBigInteger('itemable_id')->nullable();
$table->text('description')->nullable();
$table->integer('quantity')->default(1);
$table->bigInteger('total_amount')->default(0);
$table->text('metadata')->nullable();
});
Schema::create('payments', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id');
$table->bigInteger('amount')->default(0);
$table->string('status')->nullable();
$table->string('direction')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('payment_plans', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id');
$table->string('status')->nullable();
$table->date('next_due_date')->nullable();
$table->integer('total_installments')->default(0);
$table->integer('paid_installments')->default(0);
$table->bigInteger('installment_amount')->default(0);
});
}
}
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