### COMMITS (5b66277ad..HEAD) 22d0b4270 test(clinic): configure clinic AR/revenue in CreateVisit TEST 6 (C4 collateral) 778f45c87 fix(clinic): M4 refuse deleting an order with financial history b6bf6a752 fix(clinic): C2 lab-complete only flips an ordered line to performed 47b2e21b5 fix(clinic): C3 refund refuses a pending / no-JE paying receipt 39989dd00 fix(clinic): C4 collect fails before write when AR/revenue GL unset; JE listener throws ### DIFF STAT .../Clinic/app/Actions/CollectReceptionReceipt.php | 16 ++ .../Clinic/app/Actions/RefundServiceOrderLine.php | 10 ++ .../Http/Controllers/ServiceOrderController.php | 34 +++++ .../app/Listeners/MarkEncounterLabComplete.php | 6 +- .../app/Listeners/PostReceiptJournalEntry.php | 10 +- .../tests/Feature/CollectGlAccountGuardTest.php | 166 +++++++++++++++++++++ Modules/Clinic/tests/Feature/CreateVisitTest.php | 13 +- .../Feature/MarkLabCompleteBillingGuardTest.php | 95 ++++++++++++ Modules/Clinic/tests/Feature/RefundLineTest.php | 36 +++++ Modules/Clinic/tests/Feature/ServiceOrderTest.php | 101 +++++++++++++ 10 files changed, 484 insertions(+), 3 deletions(-) ### FULL DIFF (-U10) diff --git a/Modules/Clinic/app/Actions/CollectReceptionReceipt.php b/Modules/Clinic/app/Actions/CollectReceptionReceipt.php index 90d18a9cd..2adf8c10f 100644 --- a/Modules/Clinic/app/Actions/CollectReceptionReceipt.php +++ b/Modules/Clinic/app/Actions/CollectReceptionReceipt.php @@ -7,33 +7,49 @@ use Modules\Clinic\Enums\OrderLineStatus; use Modules\Clinic\Events\ReceiptCollected; use Modules\Clinic\Models\CashierSession; use Modules\Clinic\Models\ReceptionPayment; use Modules\Clinic\Models\ReceptionReceipt; use Modules\Clinic\Models\ReceptionReceiptLine; use Modules\Clinic\Models\ServiceOrder; use Modules\Clinic\Models\ServiceOrderLine; use Modules\Clinic\Services\CashierRoutingService; use Modules\Core\Services\SequenceService; +use Modules\Core\Services\SettingsService; use Modules\LIS\Models\LabPaymentMethod; class CollectReceptionReceipt { public function __construct( private CashierRoutingService $routingService, private SequenceService $sequenceService, + private SettingsService $settings, ) {} public function execute(array $data, User $user): ReceptionReceipt { $companyId = $user->company_id; + // م0 C4: fail-before-write. Without clinic AR/revenue GL accounts the JE + // listener cannot post a journal entry, so a receipt written here would + // stay 'pending' with money collected but NO accounting trace. Refuse the + // whole collection up front (mirrors the tender-GL 422 guard below), so + // nothing is ever written when the money path is unpostable. + $arAccountId = (int) $this->settings->get('clinic.ar_account_id', $companyId); + $revenueAccountId = (int) $this->settings->get('clinic.revenue_account_id', $companyId); + if (! $arAccountId || ! $revenueAccountId) { + throw new \Symfony\Component\HttpKernel\Exception\HttpException( + 422, + 'Clinic AR/revenue GL accounts are not configured. Cannot collect receipt.' + ); + } + return DB::transaction(function () use ($data, $user, $companyId) { $order = ServiceOrder::query() ->where('company_id', $companyId) ->findOrFail($data['service_order_id']); // Guard against re-collecting on an already-settled order. Lines are // flipped ordered→billed by WritePatientLedgerEntries; a fully billed // order has nothing left to collect, so a second receipt would // double-take money (and patient_due may not yet be re-rolled). if (in_array($order->status, ['billed', 'closed', 'cancelled'], true)) { diff --git a/Modules/Clinic/app/Actions/RefundServiceOrderLine.php b/Modules/Clinic/app/Actions/RefundServiceOrderLine.php index cac954b3d..5c4916ea2 100644 --- a/Modules/Clinic/app/Actions/RefundServiceOrderLine.php +++ b/Modules/Clinic/app/Actions/RefundServiceOrderLine.php @@ -66,20 +66,30 @@ public function execute(ServiceOrderLine $line, string $reason, string $paymentM // 2. Locate the paying receipt (must exist and not be voided). $receipt = $this->locatePayingReceipt($line, $companyId); if (! $receipt) { throw new \RuntimeException("No paying receipt found for line {$line->id}; cannot refund."); } if ($receipt->status === 'voided') { throw new \RuntimeException("The receipt for line {$line->id} is voided; there is nothing to refund."); } + // م0 C3: the paying receipt must be fully posted (status=paid + a journal + // entry). Refunding against a receipt whose revenue/cash was never + // recognized would post a reversing JE with nothing to reverse → GL + // corruption (the audited CN#1). Refuse before any write. + if ($receipt->status !== 'paid' || ! $receipt->journal_entry_id) { + throw new \RuntimeException( + "The receipt for line {$line->id} has no posted journal entry (status: {$receipt->status}); cannot refund against it." + ); + } + // 3. An open cashier session is required for the refunding user (same // lookup as CollectReceptionReceipt). $session = CashierSession::query() ->where('company_id', $companyId) ->where('user_id', $user->id) ->where('status', 'open') ->first(); if (! $session) { throw new \RuntimeException('No open cashier session found. Please open a session first.'); } diff --git a/Modules/Clinic/app/Http/Controllers/ServiceOrderController.php b/Modules/Clinic/app/Http/Controllers/ServiceOrderController.php index df787cf4a..d8a5bd18e 100644 --- a/Modules/Clinic/app/Http/Controllers/ServiceOrderController.php +++ b/Modules/Clinic/app/Http/Controllers/ServiceOrderController.php @@ -6,20 +6,23 @@ use Illuminate\Http\Request; use Modules\Clinic\Actions\AddServiceOrderLine; use Modules\Clinic\Actions\CancelServiceOrderLine; use Modules\Clinic\Enums\OrderLineStatus; use Modules\Clinic\Enums\ServiceOrderStatus; use Modules\Clinic\Http\Requests\AddServiceOrderLineRequest; use Modules\Clinic\Http\Requests\StoreServiceOrderRequest; use Modules\Clinic\Http\Requests\UpdateServiceOrderLineRequest; use Modules\Clinic\Http\Resources\ServiceOrderLineResource; use Modules\Clinic\Http\Resources\ServiceOrderResource; +use Modules\Clinic\Models\ClinicCreditNote; +use Modules\Clinic\Models\PatientLedgerEntry; +use Modules\Clinic\Models\ReceptionReceipt; use Modules\Clinic\Models\ServiceOrder; use Modules\Clinic\Models\ServiceOrderLine; use Modules\Core\Support\DataScope; /** * @group Clinic > Service Orders * WP-07: incremental multi-line service orders for clinic visits. * * @authenticated */ @@ -99,20 +102,51 @@ public function destroy(Request $request, int $order): JsonResponse { $orderModel = ServiceOrder::where('company_id', $request->user()->company_id) ->findOrFail($order); abort_unless( in_array($orderModel->status, [ServiceOrderStatus::Open->value, ServiceOrderStatus::Cancelled->value], true), 409, 'Cannot delete a billed or partially billed order.' ); + // م0 M4: never orphan financial history. A Cancelled order can still carry + // receipts (incl. voided — voided IS history), credit notes, or ledger + // rows on its lines. Refuse to delete when ANY exist (company-scoped). + $companyId = $request->user()->company_id; + $lineIds = ServiceOrderLine::query() + ->where('company_id', $companyId) + ->where('service_order_id', $orderModel->id) + ->pluck('id'); + + $hasFinancialHistory = + ReceptionReceipt::query() + ->where('company_id', $companyId) + ->where('service_order_id', $orderModel->id) + ->exists() + || ClinicCreditNote::query() + ->where('company_id', $companyId) + ->where('service_order_id', $orderModel->id) + ->exists() + || ($lineIds->isNotEmpty() + && PatientLedgerEntry::query() + ->where('company_id', $companyId) + ->where('source_type', 'service_order_line') + ->whereIn('source_id', $lineIds) + ->exists()); + + abort_if( + $hasFinancialHistory, + 409, + 'Cannot delete an order that has financial history (receipts, credit notes, or ledger entries).' + ); + $orderModel->delete(); return response()->json(['message' => 'Deleted'], 200); } // ── Line actions ──────────────────────────────────────────────────────── public function addLine( AddServiceOrderLineRequest $request, int $order, diff --git a/Modules/Clinic/app/Listeners/MarkEncounterLabComplete.php b/Modules/Clinic/app/Listeners/MarkEncounterLabComplete.php index 2bcd2812b..4c69f0bc5 100644 --- a/Modules/Clinic/app/Listeners/MarkEncounterLabComplete.php +++ b/Modules/Clinic/app/Listeners/MarkEncounterLabComplete.php @@ -16,18 +16,22 @@ class MarkEncounterLabComplete { public function handle(LabRequestCompleted $event): void { $request = $event->request; if (! $request->encounter_id) { return; } + // م0 C2: only an 'ordered' line auto-flips to 'performed'. A 'billed' + // (paid) line must stay billed — the ordered↔performed↔billed lifecycle + // (C1/M1/M3) is deliberately deferred to a later phase. (Mirrors + // MarkEncounterRadComplete, which already scopes on Ordered only.) ServiceOrderLine::query() ->where('company_id', $request->company_id) ->where('fulfillment_type', 'lab_request') ->where('fulfillment_id', $request->id) - ->where('status', '!=', OrderLineStatus::Cancelled->value) + ->where('status', OrderLineStatus::Ordered->value) ->update(['status' => OrderLineStatus::Performed->value]); } } diff --git a/Modules/Clinic/app/Listeners/PostReceiptJournalEntry.php b/Modules/Clinic/app/Listeners/PostReceiptJournalEntry.php index f9cbf2e02..acb78c427 100644 --- a/Modules/Clinic/app/Listeners/PostReceiptJournalEntry.php +++ b/Modules/Clinic/app/Listeners/PostReceiptJournalEntry.php @@ -17,22 +17,30 @@ public function __construct( ) {} public function handle(ReceiptCollected $event): void { $receipt = $event->receipt; $companyId = $receipt->company_id; $arAccountId = (int) $this->settings->get('clinic.ar_account_id', $companyId); $revenueAccountId = (int) $this->settings->get('clinic.revenue_account_id', $companyId); + // م0 C4 (defense-in-depth): NEVER silently return. This listener runs + // synchronously inside the collect transaction, so throwing here rolls the + // whole collection back atomically — a receipt can never be left 'pending' + // with money and no journal entry. (Unreachable after the fail-before-write + // guard in CollectReceptionReceipt, but this silent return was the audited + // bug, so it must be closed.) if (! $arAccountId || ! $revenueAccountId) { - return; + throw new \RuntimeException( + "Clinic AR/revenue accounts are not configured; cannot post receipt journal entry for {$receipt->receipt_number}." + ); } DB::transaction(function () use ($receipt, $companyId, $arAccountId, $revenueAccountId) { $receipt->load(['payments', 'order.lines', 'receiptLines.line']); // L2 WP2.1: new receipts carry receipt-line rows scoping exactly which // order lines this receipt is billing. Pre-L2 receipts have none — // fall back to the original status-driven (ordered-lines) behavior. $hasReceiptLines = $receipt->receiptLines->isNotEmpty(); diff --git a/Modules/Clinic/tests/Feature/CollectGlAccountGuardTest.php b/Modules/Clinic/tests/Feature/CollectGlAccountGuardTest.php new file mode 100644 index 000000000..89d798065 --- /dev/null +++ b/Modules/Clinic/tests/Feature/CollectGlAccountGuardTest.php @@ -0,0 +1,166 @@ +create(); + $branch = Branch::factory()->create(['company_id' => $company->id]); + $user = User::factory()->create(['company_id' => $company->id]); + $user->branches()->attach($branch->id, ['is_primary' => true]); + + return [$company, $user, $branch]; +} + +/** + * Full accounting/cashier setup so a collect would OTHERWISE succeed — EXCEPT + * clinic.ar_account_id / clinic.revenue_account_id are deliberately left UNSET. + * Returns the cash account so the payment method can resolve a GL account + * (isolating the failure to the missing AR/revenue settings). + */ +function c4SetupExceptArRevenue(int $companyId): Account +{ + $now = now(); + $fy = FiscalYear::factory()->create([ + 'company_id' => $companyId, + 'start_date' => $now->copy()->startOfYear()->toDateString(), + 'end_date' => $now->copy()->endOfYear()->toDateString(), + ]); + FiscalPeriod::factory() + ->forMonth($now->year, $now->month) + ->create([ + 'company_id' => $companyId, + 'fiscal_year_id' => $fy->id, + 'status' => 'open', + ]); + + $cashAccount = Account::factory()->create([ + 'company_id' => $companyId, + 'name' => 'Cash Box', + 'account_type' => 'detail', + ]); + + // NOTE: clinic.ar_account_id and clinic.revenue_account_id are NOT set. + app(SettingsService::class)->set('accounting.auto_post_entries', true, $companyId); + + return $cashAccount; +} + +function c4MakeCashMethod(int $companyId, int $accountId): LabPaymentMethod +{ + return LabPaymentMethod::create([ + 'company_id' => $companyId, 'key' => 'cash', 'label_en' => 'Cash', 'label_ar' => 'نقد', + 'icon' => 'cash', 'account_id' => $accountId, 'is_cash' => true, 'is_active' => true, 'sort_order' => 1, + ]); +} + +function c4MakeOrder(int $companyId, float $amount = 100.0, int $patientId = 1): ServiceOrder +{ + $order = ServiceOrder::create([ + 'company_id' => $companyId, 'order_number' => 'SO-C4-' . uniqid(), 'patient_id' => $patientId, + 'status' => 'open', 'subtotal' => $amount, 'discount_total' => 0, 'coverage_total' => 0, + 'patient_due' => $amount, 'paid_total' => 0, + ]); + + ServiceOrderLine::create([ + 'company_id' => $companyId, 'service_order_id' => $order->id, 'clinic_service_id' => null, + 'line_type' => 'consultation', 'description' => 'Line', 'description_ar' => 'بند', + 'quantity' => 1, 'unit_price' => $amount, 'patient_amount' => $amount, 'discount_amount' => 0, + 'coverage_amount' => 0, 'status' => OrderLineStatus::Ordered->value, 'price_source' => 'base', + ]); + + return $order; +} + +// ── C4a: collect with AR/revenue unset → 422, ZERO writes ──────────────────── + +test('C4a: collect with clinic AR/revenue GL accounts unset returns 422 and writes nothing', function () { + [$company, $user] = c4Actor(); + $cashAccount = c4SetupExceptArRevenue($company->id); + c4MakeCashMethod($company->id, $cashAccount->id); + + $order = c4MakeOrder($company->id, 100.0, 7101); + $lineA = $order->lines[0]; + app(OpenCashierSession::class)->execute(['opening_float' => 0], $user); + + $this->actingAs($user) + ->postJson('/api/clinic/receipts', [ + 'service_order_id' => $order->id, + 'line_ids' => [$lineA->id], + 'payments' => [['payment_method' => 'cash', 'amount' => 100.0]], + ]) + ->assertStatus(422) + ->assertJsonFragment(['message' => 'Clinic AR/revenue GL accounts are not configured. Cannot collect receipt.']); + + // ZERO writes of any kind for this collection. + expect(ReceptionReceipt::query()->where('company_id', $company->id)->count())->toBe(0); + expect(ReceptionPayment::query()->where('company_id', $company->id)->count())->toBe(0); + expect(ReceptionReceiptLine::query()->where('company_id', $company->id)->count())->toBe(0); + expect(PatientLedgerEntry::query()->where('company_id', $company->id)->count())->toBe(0); + expect(JournalEntry::query()->where('company_id', $company->id) + ->where('source_type', 'reception_receipt')->count())->toBe(0); + + // The line was never billed. + expect($lineA->fresh()->status)->toBe(OrderLineStatus::Ordered->value); +})->group('m0'); + +// ── C4b: the JE listener throws (never silently returns) when AR/revenue unset ─ + +test('C4b: PostReceiptJournalEntry throws (does not silently return) when AR/revenue unset', function () { + [$company, $user] = c4Actor(); + // AR/revenue settings are unset by default (no c4SetupExceptArRevenue call needed). + + $receipt = ReceptionReceipt::factory()->create([ + 'company_id' => $company->id, + 'status' => 'pending', + 'journal_entry_id' => null, + ]); + + expect(fn () => app(PostReceiptJournalEntry::class)->handle(new ReceiptCollected($receipt))) + ->toThrow(\RuntimeException::class); + + // The receipt must NOT be silently marked paid / posted. + $fresh = $receipt->fresh(); + expect($fresh->status)->toBe('pending'); + expect($fresh->journal_entry_id)->toBeNull(); +})->group('m0'); diff --git a/Modules/Clinic/tests/Feature/CreateVisitTest.php b/Modules/Clinic/tests/Feature/CreateVisitTest.php index f4ca72069..c9f33e792 100644 --- a/Modules/Clinic/tests/Feature/CreateVisitTest.php +++ b/Modules/Clinic/tests/Feature/CreateVisitTest.php @@ -1,17 +1,18 @@ actingAs($user); $dept = Department::factory()->create(['company_id' => $company->id]); $doctor = visitDoctor($company, $dept); $patient = visitPatient($company); $service = visitClinicService($company); + // Clinic AR/revenue GL accounts must be configured, otherwise the م0 C4 + // guard in CollectReceptionReceipt throws an HttpException(422) that + // CreateVisit re-throws as a config error — masking the no-cashier-session + // SOFT-failure path this test exercises. Configure them so the ONLY failure + // is the missing open session (a RuntimeException → graceful). + $ar = Account::factory()->create(['company_id' => $company->id, 'account_type' => 'detail']); + $rev = Account::factory()->create(['company_id' => $company->id, 'account_type' => 'detail']); + app(\Modules\Core\Services\SettingsService::class)->set('clinic.ar_account_id', $ar->id, $company->id); + app(\Modules\Core\Services\SettingsService::class)->set('clinic.revenue_account_id', $rev->id, $company->id); + $result = app(CreateVisit::class)->execute([ 'patient_id' => $patient->id, 'timing' => 'later', 'scheduled_date' => Carbon::today()->toDateString(), 'consultations' => [ ['clinic_service_id' => $service->id, 'doctor_id' => $doctor->id], ], 'pay' => [ 'mode' => 'now', 'payments' => [ ['payment_method' => 'cash', 'amount' => 200.0], ], ], ], $user); // Visit was created $appt = $result['appointment']; expect($appt)->not->toBeNull(); expect(Appointment::find($appt->id))->not->toBeNull(); - // Payment failed gracefully + // Payment failed gracefully (no open cashier session — soft RuntimeException) expect($result['payment']['collected'])->toBeFalse(); expect($result['payment']['reason'])->not->toBeNull(); }); // ── TEST 7: referring_doctor_id persists on service_order ── it('referring_doctor_id is persisted on the service_order', function () { [$company, $user] = visitActor(); $this->actingAs($user); diff --git a/Modules/Clinic/tests/Feature/MarkLabCompleteBillingGuardTest.php b/Modules/Clinic/tests/Feature/MarkLabCompleteBillingGuardTest.php new file mode 100644 index 000000000..5a915e627 --- /dev/null +++ b/Modules/Clinic/tests/Feature/MarkLabCompleteBillingGuardTest.php @@ -0,0 +1,95 @@ + $companyId, + 'service_order_id' => $orderId, + 'clinic_service_id' => null, + 'line_type' => 'lab', + 'description' => 'Lab line', + 'description_ar' => 'بند تحليل', + 'quantity' => 1, + 'unit_price' => 100.0, + 'patient_amount' => 100.0, + 'discount_amount' => 0, + 'coverage_amount' => 0, + 'status' => $status, + 'price_source' => 'base', + 'fulfillment_type' => 'lab_request', + 'fulfillment_id' => $fulfillmentId, + ]); +} + +// ── C2: only an ordered line auto-flips to performed ───────────────────────── + +test('C2: LabRequestCompleted flips only an ordered fulfilling line to performed; billed stays billed, cancelled stays cancelled', function () { + $company = Company::factory()->create(); + $user = User::factory()->create(['company_id' => $company->id]); + $this->actingAs($user); + + // Real encounter so the LabRequest's encounter_id FK resolves and the + // listener acts (it early-returns when encounter_id is null). + $dept = Department::factory()->create(['company_id' => $company->id]); + $doctor = LabDoctor::factory()->create([ + 'company_id' => $company->id, 'department_id' => $dept->id, 'is_active' => true, + ]); + $labPatient = LabPatient::factory()->create(['company_id' => $company->id]); + $encounter = Encounter::factory()->create([ + 'company_id' => $company->id, 'branch_id' => null, 'patient_id' => $labPatient->id, + 'doctor_id' => $doctor->id, 'department_id' => $dept->id, + 'status' => EncounterStatus::InProgress->value, 'started_at' => now(), + ]); + $labRequest = LabRequest::factory()->create([ + 'company_id' => $company->id, + 'patient_id' => $labPatient->id, + 'encounter_id' => $encounter->id, + ]); + + $order = ServiceOrder::create([ + 'company_id' => $company->id, 'order_number' => 'SO-C2-' . uniqid(), 'patient_id' => 7201, + 'status' => 'open', 'subtotal' => 300.0, 'discount_total' => 0, 'coverage_total' => 0, + 'patient_due' => 300.0, 'paid_total' => 0, + ]); + + $billed = mlcMakeLine($company->id, $order->id, $labRequest->id, OrderLineStatus::Billed->value); + $ordered = mlcMakeLine($company->id, $order->id, $labRequest->id, OrderLineStatus::Ordered->value); + $cancelled = mlcMakeLine($company->id, $order->id, $labRequest->id, OrderLineStatus::Cancelled->value); + + LabRequestCompleted::dispatch($labRequest); + + // A billed (paid) line must NOT be un-billed. + expect($billed->fresh()->status)->toBe(OrderLineStatus::Billed->value); + // An ordered line auto-flips to performed. + expect($ordered->fresh()->status)->toBe(OrderLineStatus::Performed->value); + // A cancelled line stays cancelled. + expect($cancelled->fresh()->status)->toBe(OrderLineStatus::Cancelled->value); +})->group('m0'); diff --git a/Modules/Clinic/tests/Feature/RefundLineTest.php b/Modules/Clinic/tests/Feature/RefundLineTest.php index 9af663d3a..850e4fcc6 100644 --- a/Modules/Clinic/tests/Feature/RefundLineTest.php +++ b/Modules/Clinic/tests/Feature/RefundLineTest.php @@ -587,10 +587,46 @@ function rlCollect(ServiceOrder $order, array $lineIds, float $amount, User $use // NO second reversal: no new JE, no new ledger rows, no void JE stamped expect(JournalEntry::query()->where('company_id', $company->id)->count())->toBe($jeCountBefore); expect(PatientLedgerEntry::query() ->where('company_id', $company->id)->where('patient_id', $patientId)->count())->toBe($ledgerCountBefore); $freshReceipt = $receipt->fresh(); expect($freshReceipt->status)->toBe('paid'); expect($freshReceipt->voided_journal_entry_id)->toBeNull(); // Line B untouched — still billed, refundable via its own credit note expect($lineB->fresh()->status)->toBe(OrderLineStatus::Billed->value); })->group('l2'); + +// ── م0 C3: refund refuses a pending / no-JE paying receipt ──────────────────── +// +// Refunding against a receipt whose revenue/cash was never recognized would post +// a reversing JE with nothing to reverse → GL corruption (the audited CN#1). +// The L2 guards (billed line, non-voided receipt, AR/revenue configured, double- +// refund lock) do NOT require the paying receipt to be fully posted; C3 does. + +test('M0-C3: refund is refused when the paying receipt is pending with no journal entry (no CN, no JE)', function () { + [$company, $user] = rlActor(); + $accounts = rlSetupAccounting($company->id); + rlMakeCashMethod($company->id, $accounts['cashAccount']->id); + + $order = rlMakeOrder($company->id, [100.0], 6101); + $lineA = $order->lines[0]; + rlOpenSession($user); + $receipt = rlCollect($order, [$lineA->id], 100.0, $user); + expect($lineA->fresh()->status)->toBe(OrderLineStatus::Billed->value); + expect($receipt->status)->toBe('paid'); + + // Simulate a receipt whose revenue/cash was NEVER recognized: pending, no JE. + $receipt->update(['status' => 'pending', 'journal_entry_id' => null]); + + $cnBefore = ClinicCreditNote::query()->where('company_id', $company->id)->count(); + $jeBefore = JournalEntry::query()->where('company_id', $company->id) + ->where('source_type', 'clinic_credit_note')->count(); + + expect(fn () => app(RefundServiceOrderLine::class)->execute($lineA->fresh(), 'reject', 'cash', $user)) + ->toThrow(\RuntimeException::class); + + // No credit note, no reversing JE — and the line is untouched (still billed). + expect(ClinicCreditNote::query()->where('company_id', $company->id)->count())->toBe($cnBefore); + expect(JournalEntry::query()->where('company_id', $company->id) + ->where('source_type', 'clinic_credit_note')->count())->toBe($jeBefore); + expect($lineA->fresh()->status)->toBe(OrderLineStatus::Billed->value); +})->group('m0'); diff --git a/Modules/Clinic/tests/Feature/ServiceOrderTest.php b/Modules/Clinic/tests/Feature/ServiceOrderTest.php index 7fcd1b89d..ffbd9ef3d 100644 --- a/Modules/Clinic/tests/Feature/ServiceOrderTest.php +++ b/Modules/Clinic/tests/Feature/ServiceOrderTest.php @@ -2,20 +2,22 @@ use App\Models\Company; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Modules\Clinic\Actions\AddServiceOrderLine; use Modules\Clinic\Actions\CancelServiceOrderLine; use Modules\Clinic\Enums\OrderLineStatus; use Modules\Clinic\Enums\OrderLineType; use Modules\Clinic\Enums\ServiceOrderStatus; use Modules\Clinic\Models\ClinicService; +use Modules\Clinic\Models\PatientLedgerEntry; +use Modules\Clinic\Models\ReceptionReceipt; use Modules\Clinic\Models\ServiceOrder; use Modules\Clinic\Models\ServiceOrderLine; use Modules\Clinic\Models\ServicePrice; use Modules\HRM\Models\Department; uses(RefreshDatabase::class); // ── Helpers ────────────────────────────────────────────────────────────────── function orderActor(): array @@ -519,10 +521,109 @@ function makeLabService(int $companyId, float $basePrice = 80.0): ClinicService 'payer_contract_id' => null, 'line_type' => OrderLineType::Consultation->value, 'clinic_service_id' => $consultation->id, 'quantity' => 1, 'added_by_role' => 'reception', 'created_by' => $user->id, ]); expect($line->revenue_split_scheme_id)->toBeNull(); }); + +// ── م0 M4: cannot delete an order that has financial history ────────────────── + +// A Cancelled order with a reception receipt (ANY status, incl. voided — voided +// is history) must NOT be deletable; it would orphan financial rows. +test('M4: DELETE a cancelled order that has a reception receipt returns 409 and does not delete', function () { + [$company, $user] = orderActor(); + + $order = ServiceOrder::create([ + 'company_id' => $company->id, + 'order_number' => 'SO-M4-1', + 'patient_id' => 8001, + 'status' => ServiceOrderStatus::Cancelled->value, + 'subtotal' => 0, + 'discount_total' => 0, + 'coverage_total' => 0, + 'patient_due' => 0, + 'paid_total' => 0, + ]); + + // A voided receipt IS financial history and must block deletion. + ReceptionReceipt::factory()->create([ + 'company_id' => $company->id, + 'service_order_id' => $order->id, + 'patient_id' => 8001, + 'status' => 'voided', + ]); + + $this->actingAs($user) + ->deleteJson("/api/clinic/service-orders/{$order->id}") + ->assertStatus(409); + + // Order NOT deleted (still present, not soft-deleted). + expect(ServiceOrder::query()->whereKey($order->id)->exists())->toBeTrue(); +})->group('m0'); + +// A cancelled order whose only financial trace is a ledger entry on one of its +// lines is likewise undeletable. +test('M4: DELETE a cancelled order with a patient ledger entry on its line returns 409', function () { + [$company, $user] = orderActor(); + + $order = ServiceOrder::create([ + 'company_id' => $company->id, + 'order_number' => 'SO-M4-2', + 'patient_id' => 8002, + 'status' => ServiceOrderStatus::Cancelled->value, + 'subtotal' => 0, + 'discount_total' => 0, + 'coverage_total' => 0, + 'patient_due' => 0, + 'paid_total' => 0, + ]); + + $line = ServiceOrderLine::factory()->create([ + 'company_id' => $company->id, + 'service_order_id' => $order->id, + 'status' => OrderLineStatus::Cancelled->value, + 'unit_price' => 100, + 'quantity' => 1, + 'patient_amount' => 100, + ]); + + PatientLedgerEntry::factory()->create([ + 'company_id' => $company->id, + 'patient_id' => 8002, + 'source_type' => 'service_order_line', + 'source_id' => $line->id, + ]); + + $this->actingAs($user) + ->deleteJson("/api/clinic/service-orders/{$order->id}") + ->assertStatus(409); + + expect(ServiceOrder::query()->whereKey($order->id)->exists())->toBeTrue(); +})->group('m0'); + +// A clean Open order with no financial rows must still be deletable (don't over-block). +test('M4: DELETE a clean open order with no financial history still returns 200', function () { + [$company, $user] = orderActor(); + + $order = ServiceOrder::create([ + 'company_id' => $company->id, + 'order_number' => 'SO-M4-3', + 'patient_id' => 8003, + 'status' => ServiceOrderStatus::Open->value, + 'subtotal' => 0, + 'discount_total' => 0, + 'coverage_total' => 0, + 'patient_due' => 0, + 'paid_total' => 0, + ]); + + $this->actingAs($user) + ->deleteJson("/api/clinic/service-orders/{$order->id}") + ->assertStatus(200); + + // Soft-deleted → excluded from the default scope. + expect(ServiceOrder::query()->whereKey($order->id)->exists())->toBeFalse(); +})->group('m0');