Reading additional input from stdin... OpenAI Codex v0.142.5 -------- workdir: /home/moonui/moon-erp-be model: gpt-5.5 provider: openai approval: never sandbox: read-only reasoning effort: none reasoning summaries: none session id: 019f2323-057c-7c20-84a4-7f326a7cff60 -------- user You are a money/data-safety reviewer. The COMPLETE diff (commit + stat + -U8 diff, base 22d0b4270..HEAD) is on STDIN. Review it DIRECTLY — do NOT run shell/git (sandbox blocks it; everything is on stdin). This is a Laravel artisan command 'clinic:remediate-stranded-receipts' that will run against a LIVE dev DB to heal 4 stranded receipts (status=pending, money collected, NO journal_entry_id) by POSTING the missing JE forward. Requirements: SCAN (no --force) must be READ-ONLY; --force posts an explicitly-built balanced JE per receipt (DR payment-account/CR AR for payments + DR AR/CR revenue for revenue side; Σdebit==Σcredit==paid_total) via CreateJournalEntry with idempotency_key='clinic_receipt_'.id (re-run = no-op), date=TODAY (never backdate), then flips receipt to status=paid + journal_entry_id; skip+report if company AR/revenue unset or any payment.account_id is null; per-receipt DB::transaction; must NOT reuse PostReceiptJournalEntry (its fallback reads status='ordered' lines now billed/cancelled); must NOT modify credit notes (only assert+report CN linkage healed); NO migration. Judge specifically: (1) can SCAN write anything? (2) can an unbalanced or wrong-signed JE ever post? (3) can a re-run double-post (idempotency)? (4) can it post for a company with unset AR/revenue or a null payment account? (5) does the revenue reconstruction (receipt_lines else Charge ledger rows by reference=receipt_number) risk double-count or omission? (6) any backdating / closed-period risk? (7) does it wrongly mutate a credit note or a healthy receipt? Report ONLY real defects as CRITICAL/IMPORTANT/MINOR with file:line + concrete fix. If none, output 'APPROVED — no defects'. Caveat (one line) anything needing unshown code; do NOT run shell. ### COMMIT (22d0b4270..HEAD) 29c72a436 feat(clinic): م0 — clinic:remediate-stranded-receipts (post-forward JE for stranded receipts, scan/--force/verify, idempotent) ### DIFF STAT .../app/Console/RemediateStrandedReceipts.php | 456 +++++++++++++++++++++ .../Clinic/app/Providers/ClinicServiceProvider.php | 4 +- .../Feature/RemediateStrandedReceiptsTest.php | 350 ++++++++++++++++ 3 files changed, 809 insertions(+), 1 deletion(-) ### FULL DIFF (-U8) diff --git a/Modules/Clinic/app/Console/RemediateStrandedReceipts.php b/Modules/Clinic/app/Console/RemediateStrandedReceipts.php new file mode 100644 index 000000000..d9a113d97 --- /dev/null +++ b/Modules/Clinic/app/Console/RemediateStrandedReceipts.php @@ -0,0 +1,456 @@ + 0) but NO journal_entry_id. This is the + * historical damage the م0 C4 code fix stops creating going forward; this + * command repairs what already happened. + * + * STRATEGY = POST-FORWARD (never unwind). The ledger layer is already sound + * (Charge/Payment ledger rows exist, order lines billed, the drawer counted the + * cash). The ONLY missing artifact is the journal entry, so we simply POST the + * missing JE per receipt — dated TODAY (never backdate into a possibly-closed + * fiscal period) — and flip the receipt to paid. Once a receipt has its JE, any + * credit note already posted against it becomes retroactively correct, so credit + * notes are NEVER touched — only reported as "linkage healed". + * + * Safety model (mirrors lis:prune-machine-heartbeats): + * - DRY-RUN by default (scan) — writes nothing; doubles as an any-install audit + * and as the post-remediation "verify" (re-run → empty detectors = healed). + * - --force actually posts. Each receipt is healed in its OWN DB::transaction + * (all-or-nothing) so one bad receipt never aborts the others. + * - Idempotent: the JE idempotency_key 'clinic_receipt_' makes CreateJournal- + * Entry a no-op on re-run, and a remediated receipt is no longer pending so it + * drops out of the scan entirely. + * - Never guesses an account: a company with clinic AR/revenue unset, or a + * payment with no GL account, is SKIPPED and reported as needs-manual. + * + * The JE is built EXPLICITLY here (not via PostReceiptJournalEntry, whose legacy + * fallback reads status='ordered' lines — stranded receipts' lines are now + * billed/cancelled, so it would build an empty/unbalanced revenue side). + */ +class RemediateStrandedReceipts extends Command +{ + protected $signature = 'clinic:remediate-stranded-receipts + {--force : Actually post the missing journal entries (default is a read-only scan)} + {--company= : Limit the scan/remediation to a single company id}'; + + protected $description = 'Heal stranded clinic receipts (paid, pending, no journal entry) by posting the missing JE forward. Read-only scan by default.'; + + public function __construct( + private CreateJournalEntry $createJournalEntry, + private SettingsService $settings, + ) { + parent::__construct(); + } + + public function handle(): int + { + $force = (bool) $this->option('force'); + $companyId = $this->option('company') !== null ? (int) $this->option('company') : null; + + $stranded = $this->strandedReceipts($companyId); + $orphanCreditNotes = $this->orphanCreditNotes($companyId); + $deletedOrders = $this->deletedOrdersWithMoney($companyId); + + $this->renderScan($stranded, $orphanCreditNotes, $deletedOrders); + + if (! $force) { + $remediable = $stranded->where('remediable', true)->count(); + $manual = $stranded->count() - $remediable; + $this->warn( + "DRY RUN — nothing written. Re-run with --force to remediate {$remediable} receipt(s); " + ."{$manual} need a manual decision. BACK UP THE DATABASE FIRST." + ); + + return self::SUCCESS; + } + + $this->remediate($stranded, $orphanCreditNotes); + + return self::SUCCESS; + } + + // ── Detectors (read-only) ──────────────────────────────────────────────── + + /** + * (a) Stranded pending receipts: status=pending, money collected, no JE. + * Each row is annotated with whether it is remediable (GL configured AND + * every payment carries a GL account). + * + * @return Collection> + */ + private function strandedReceipts(?int $companyId): Collection + { + $query = ReceptionReceipt::query() + ->where('status', 'pending') + ->where('paid_total', '>', 0) + ->whereNull('journal_entry_id') + ->orderBy('company_id') + ->orderBy('id'); + + if ($companyId) { + $query->where('company_id', $companyId); + } + + return $query->get()->map(function (ReceptionReceipt $receipt) { + $arId = (int) $this->settings->get('clinic.ar_account_id', $receipt->company_id); + $revId = (int) $this->settings->get('clinic.revenue_account_id', $receipt->company_id); + $glConfigured = $arId > 0 && $revId > 0; + + $payments = $receipt->payments()->get(); + $paymentsOk = $payments->isNotEmpty() + && $payments->every(fn ($p) => (int) $p->account_id > 0); + + return [ + 'receipt' => $receipt, + 'ar_id' => $arId, + 'rev_id' => $revId, + 'gl_configured' => $glConfigured, + 'payments_ok' => $paymentsOk, + 'remediable' => $glConfigured && $paymentsOk, + ]; + }); + } + + /** + * (b) Orphan credit notes: a posted credit note whose paying receipt still + * has no journal entry. Report-only — remediating the receipt (a) heals it. + * + * @return Collection + */ + private function orphanCreditNotes(?int $companyId): Collection + { + $query = ClinicCreditNote::query() + ->where('status', 'posted') + ->whereNotNull('reception_receipt_id') + ->whereHas('receipt', fn ($q) => $q->whereNull('journal_entry_id')) + ->with('receipt') + ->orderBy('company_id') + ->orderBy('id'); + + if ($companyId) { + $query->where('company_id', $companyId); + } + + return $query->get(); + } + + /** + * (c) M4 retro-detector: soft-deleted service orders that still carry a + * receipt or a credit note (financial history a delete should have blocked). + * Report-only. + * + * @return Collection> + */ + private function deletedOrdersWithMoney(?int $companyId): Collection + { + $query = ServiceOrder::withTrashed()->whereNotNull('deleted_at'); + + if ($companyId) { + $query->where('company_id', $companyId); + } + + return $query->get() + ->map(function (ServiceOrder $order) { + $receipts = ReceptionReceipt::withTrashed() + ->where('service_order_id', $order->id)->count(); + $creditNotes = ClinicCreditNote::withTrashed() + ->where('service_order_id', $order->id)->count(); + + return ['order' => $order, 'receipts' => $receipts, 'credit_notes' => $creditNotes]; + }) + ->filter(fn ($row) => $row['receipts'] > 0 || $row['credit_notes'] > 0) + ->values(); + } + + // ── Scan output ────────────────────────────────────────────────────────── + + private function renderScan(Collection $stranded, Collection $orphanCreditNotes, Collection $deletedOrders): void + { + $this->info('(a) Stranded pending receipts (paid, no journal entry)'); + if ($stranded->isEmpty()) { + $this->line(' none'); + } else { + $this->table( + ['id', 'receipt_number', 'company_id', 'paid_total', 'ar/revenue set?', 'payments have account?', 'remediable?'], + $stranded->map(fn ($row) => [ + $row['receipt']->id, + $row['receipt']->receipt_number, + $row['receipt']->company_id, + number_format((float) $row['receipt']->paid_total, 3), + $row['gl_configured'] ? 'yes' : 'NO', + $row['payments_ok'] ? 'yes' : 'NO', + $row['remediable'] ? 'yes' : 'needs manual', + ])->all() + ); + } + + $this->newLine(); + $this->info('(b) Orphan credit notes (posted against a receipt with no journal entry)'); + if ($orphanCreditNotes->isEmpty()) { + $this->line(' none'); + } else { + $this->table( + ['cn id', 'cn number', 'receipt id', 'receipt status'], + $orphanCreditNotes->map(fn (ClinicCreditNote $cn) => [ + $cn->id, + $cn->credit_note_number, + $cn->reception_receipt_id, + $cn->receipt?->status ?? '—', + ])->all() + ); + } + + $this->newLine(); + $this->info('(c) Soft-deleted service orders that still carry money (M4 retro-detector)'); + if ($deletedOrders->isEmpty()) { + $this->line(' none'); + } else { + $this->table( + ['order id', 'order_number', '#receipts', '#credit notes'], + $deletedOrders->map(fn ($row) => [ + $row['order']->id, + $row['order']->order_number, + $row['receipts'], + $row['credit_notes'], + ])->all() + ); + } + + $this->newLine(); + $remediable = $stranded->where('remediable', true)->count(); + $manual = $stranded->count() - $remediable; + $this->info( + "Summary: {$stranded->count()} stranded receipt(s) — {$remediable} remediable, {$manual} need a manual decision; " + ."{$orphanCreditNotes->count()} orphan credit note(s); {$deletedOrders->count()} soft-deleted order(s) with money." + ); + } + + // ── Remediation (--force) ──────────────────────────────────────────────── + + private function remediate(Collection $stranded, Collection $orphanCreditNotes): void + { + $this->newLine(); + $this->info('REMEDIATING — posting the missing journal entries…'); + + $remediated = 0; + $skipped = []; + + foreach ($stranded as $row) { + /** @var ReceptionReceipt $receipt */ + $receipt = $row['receipt']; + + $result = $this->remediateReceipt($row); + + if ($result['status'] === 'remediated') { + $remediated++; + $this->line(" ✔ {$receipt->receipt_number}: journal entry posted, receipt marked paid."); + } else { + $skipped[] = "{$receipt->receipt_number}: {$result['reason']}"; + $this->warn(" ⚠ {$receipt->receipt_number}: SKIPPED — {$result['reason']}"); + } + } + + // Credit-note linkage heal (report-only — never modify the credit note). + $healed = 0; + foreach ($orphanCreditNotes as $cn) { + $receipt = $cn->receipt()->first(); + if ($receipt && $receipt->journal_entry_id) { + $healed++; + $this->line(" ✔ CN {$cn->credit_note_number}: linkage healed (receipt {$receipt->receipt_number} now posted)."); + } + } + + $this->newLine(); + $this->table(['Remediated', 'Skipped (needs manual)', 'Credit notes healed'], [[ + $remediated, count($skipped), $healed, + ]]); + + if ($skipped !== []) { + $this->warn('Needs manual decision:'); + foreach ($skipped as $reason) { + $this->warn(" - {$reason}"); + } + } + + $this->info('Done. Re-run without --force to verify (healed items drop out of the scan).'); + } + + /** + * Heal a single receipt inside its own transaction. Returns + * ['status' => 'remediated'|'skipped'|'error', 'reason' => ?string]. + * + * @param array $row + * @return array + */ + private function remediateReceipt(array $row): array + { + /** @var ReceptionReceipt $receipt */ + $receipt = $row['receipt']; + + // (1) Never guess an account — skip when clinic AR/revenue is unset. + if (! $row['gl_configured']) { + return ['status' => 'skipped', 'reason' => 'clinic AR/revenue accounts are not configured']; + } + + $arId = $row['ar_id']; + $revId = $row['rev_id']; + + $payments = $receipt->payments()->get(); + if ($payments->isEmpty()) { + return ['status' => 'skipped', 'reason' => 'no payments found on a receipt with money']; + } + + // (2) Never guess an account — skip the whole receipt if any payment + // has no GL account. + foreach ($payments as $payment) { + if (! (int) $payment->account_id) { + return ['status' => 'skipped', 'reason' => 'a payment has no GL account']; + } + } + + try { + DB::transaction(function () use ($receipt, $payments, $arId, $revId) { + $lines = []; + $paymentTotal = 0.0; + + // Payment side: DR the receiving account, CR AR. + foreach ($payments as $payment) { + $amount = (float) $payment->amount; + $paymentTotal += $amount; + + $lines[] = [ + 'account_id' => $payment->account_id, + 'debit' => $amount, + 'credit' => 0, + 'description' => "Receipt {$receipt->receipt_number} - {$payment->payment_method}", + 'description_ar' => "إيصال {$receipt->receipt_number} - {$payment->payment_method}", + ]; + $lines[] = [ + 'account_id' => $arId, + 'debit' => 0, + 'credit' => $amount, + 'description' => "Receipt {$receipt->receipt_number} - AR credit", + 'description_ar' => "إيصال {$receipt->receipt_number} - دائن ذمم", + ]; + } + + // Revenue side: DR AR, CR revenue. + $revenueTotal = 0.0; + $receiptLines = $receipt->receiptLines()->get(); + + if ($receiptLines->isNotEmpty()) { + // L2 receipt — recognise exactly the lines this receipt collected. + foreach ($receiptLines as $receiptLine) { + $amount = (float) $receiptLine->amount; + if ($amount <= 0) { + continue; + } + $revenueTotal += $amount; + $this->appendRevenueLines($lines, $arId, $revId, $amount, $receipt->receipt_number); + } + } else { + // Pre-L2 receipt (no receipt-line linkage) — reconstruct from the + // Charge patient-ledger rows WritePatientLedgerEntries wrote at + // collect time (source_type=service_order_line, reference=receipt#). + $charges = PatientLedgerEntry::query() + ->where('company_id', $receipt->company_id) + ->where('entry_type', LedgerEntryType::Charge->value) + ->where('source_type', 'service_order_line') + ->where('reference', $receipt->receipt_number) + ->get(); + + foreach ($charges as $charge) { + $amount = (float) $charge->amount; + if ($amount <= 0) { + continue; + } + $revenueTotal += $amount; + $this->appendRevenueLines($lines, $arId, $revId, $amount, $receipt->receipt_number); + } + } + + // (4) Balance asserts — roll back this receipt if anything is off. + $paidTotal = round((float) $receipt->paid_total, 3); + $totalDebit = round(array_sum(array_column($lines, 'debit')), 3); + $totalCredit = round(array_sum(array_column($lines, 'credit')), 3); + $paymentTotal = round($paymentTotal, 3); + $revenueTotal = round($revenueTotal, 3); + + if ($totalDebit !== $totalCredit) { + throw new \RuntimeException("unbalanced JE (debit={$totalDebit}, credit={$totalCredit})"); + } + if ($paymentTotal !== $paidTotal) { + throw new \RuntimeException("payment side ({$paymentTotal}) != paid_total ({$paidTotal})"); + } + if ($revenueTotal !== $paidTotal) { + throw new \RuntimeException("revenue side ({$revenueTotal}) != paid_total ({$paidTotal})"); + } + + // (5) Post the JE — dated TODAY (never backdate). The idempotency + // key makes a re-run a no-op. Mirrors PostReceiptJournalEntry. + $originalDate = $receipt->created_at?->toDateString() ?? 'unknown date'; + $je = $this->createJournalEntry->execute([ + 'company_id' => $receipt->company_id, + 'date' => now()->toDateString(), + 'entry_type' => JournalEntryType::ClinicReceipt, + 'reference' => $receipt->receipt_number, + 'description' => "Clinic Receipt {$receipt->receipt_number} (remediated; originally collected {$originalDate})", + 'description_ar' => "إيصال عيادة {$receipt->receipt_number} (تسوية؛ حُصِّل بتاريخ {$originalDate})", + 'source_type' => 'reception_receipt', + 'source_id' => $receipt->id, + 'created_by' => $receipt->created_by, + 'idempotency_key' => 'clinic_receipt_'.$receipt->id, + ], $lines); + + // (6) Flip the receipt. + $receipt->update([ + 'journal_entry_id' => $je->id, + 'status' => 'paid', + ]); + }); + } catch (\Throwable $e) { + return ['status' => 'error', 'reason' => $e->getMessage()]; + } + + return ['status' => 'remediated']; + } + + /** + * Append the DR-AR / CR-revenue pair for one unit of recognised revenue. + * + * @param array> $lines + */ + private function appendRevenueLines(array &$lines, int $arId, int $revId, float $amount, string $receiptNumber): void + { + $lines[] = [ + 'account_id' => $arId, + 'debit' => $amount, + 'credit' => 0, + 'description' => "Revenue - Receipt {$receiptNumber}", + 'description_ar' => "إيراد - إيصال {$receiptNumber}", + ]; + $lines[] = [ + 'account_id' => $revId, + 'debit' => 0, + 'credit' => $amount, + 'description' => "Revenue - Receipt {$receiptNumber}", + 'description_ar' => "إيراد - إيصال {$receiptNumber}", + ]; + } +} diff --git a/Modules/Clinic/app/Providers/ClinicServiceProvider.php b/Modules/Clinic/app/Providers/ClinicServiceProvider.php index 1db7aaf80..6d3cb9d5b 100644 --- a/Modules/Clinic/app/Providers/ClinicServiceProvider.php +++ b/Modules/Clinic/app/Providers/ClinicServiceProvider.php @@ -61,17 +61,19 @@ function ($app) { ); } /** * Register commands in the format of Command::class */ protected function registerCommands(): void { - $this->commands([]); + $this->commands([ + \Modules\Clinic\Console\RemediateStrandedReceipts::class, + ]); } /** * Register command Schedules. */ protected function registerCommandSchedules(): void { // $this->app->booted(function () { diff --git a/Modules/Clinic/tests/Feature/RemediateStrandedReceiptsTest.php b/Modules/Clinic/tests/Feature/RemediateStrandedReceiptsTest.php new file mode 100644 index 000000000..f075102db --- /dev/null +++ b/Modules/Clinic/tests/Feature/RemediateStrandedReceiptsTest.php @@ -0,0 +1,350 @@ +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]; +} + +/** + * Fiscal period + AR/revenue/cash accounts. When $configureGl is false the two + * clinic.*_account_id settings are deliberately left unset (the GL-unset case). + */ +function remSetupAccounting(int $companyId, bool $configureGl = true): array +{ + $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', + ]); + + $arAccount = Account::factory()->create([ + 'company_id' => $companyId, 'name' => 'AR Clinic', 'account_type' => 'detail', + ]); + $revenueAccount = Account::factory()->create([ + 'company_id' => $companyId, 'name' => 'Revenue', 'account_type' => 'detail', + ]); + $cashAccount = Account::factory()->create([ + 'company_id' => $companyId, 'name' => 'Cash Box', 'account_type' => 'detail', + ]); + + $settings = app(SettingsService::class); + $settings->set('accounting.auto_post_entries', true, $companyId); + if ($configureGl) { + $settings->set('clinic.ar_account_id', $arAccount->id, $companyId); + $settings->set('clinic.revenue_account_id', $revenueAccount->id, $companyId); + } + + return compact('arAccount', 'revenueAccount', 'cashAccount'); +} + +function remMakeOrderWithLine(int $companyId, int $userId, float $amount): array +{ + $order = ServiceOrder::create([ + 'company_id' => $companyId, + 'order_number' => 'SO-REM-'.uniqid(), + 'patient_id' => 4242, + 'status' => 'billed', + 'subtotal' => $amount, + 'discount_total' => 0, + 'coverage_total' => 0, + 'patient_due' => $amount, + 'paid_total' => $amount, + 'created_by' => $userId, + ]); + + $line = ServiceOrderLine::create([ + 'company_id' => $companyId, + 'service_order_id' => $order->id, + 'line_type' => 'consultation', + 'description' => 'Consultation', + 'description_ar' => 'كشف', + 'quantity' => 1, + 'unit_price' => $amount, + 'patient_amount' => $amount, + 'discount_amount' => 0, + 'coverage_amount' => 0, + 'status' => OrderLineStatus::Billed->value, + 'price_source' => 'base', + 'created_by' => $userId, + ]); + + return [$order, $line]; +} + +/** + * A pre-existing stranded receipt: status=pending, money collected, NO JE. + * + * @param string $revenueVia 'lines' → seed a reception_receipt_line; + * 'ledger' → seed only a Charge patient-ledger row. + * @param ?int $paymentAccountId overrides the payment's GL account (null = strand it further). + */ +function remMakeStrandedReceipt( + int $companyId, + int $userId, + float $amount, + array $accounts, + string $revenueVia = 'lines', + bool $withPaymentAccount = true, +): ReceptionReceipt { + [$order, $line] = remMakeOrderWithLine($companyId, $userId, $amount); + + $receipt = ReceptionReceipt::factory()->create([ + 'company_id' => $companyId, + 'service_order_id' => $order->id, + 'patient_id' => $order->patient_id, + 'services_total' => $amount, + 'patient_due' => $amount, + 'paid_total' => $amount, + 'status' => 'pending', + 'journal_entry_id' => null, + 'created_by' => $userId, + ]); + + ReceptionPayment::create([ + 'company_id' => $companyId, + 'reception_receipt_id' => $receipt->id, + 'payment_method' => 'cash', + 'amount' => $amount, + 'account_id' => $withPaymentAccount ? $accounts['cashAccount']->id : null, + 'created_by' => $userId, + ]); + + if ($revenueVia === 'lines') { + ReceptionReceiptLine::create([ + 'company_id' => $companyId, + 'reception_receipt_id' => $receipt->id, + 'service_order_line_id' => $line->id, + 'amount' => $amount, + ]); + } else { // 'ledger' — pre-L2 receipt: no receipt-lines, reconstruct from charges + PatientLedgerEntry::create([ + 'company_id' => $companyId, + 'patient_id' => $order->patient_id, + 'entry_type' => LedgerEntryType::Charge->value, + 'amount' => $amount, + 'source_type' => 'service_order_line', + 'source_id' => $line->id, + 'reference' => $receipt->receipt_number, + 'occurred_at' => now(), + 'created_by' => $userId, + ]); + } + + return $receipt->fresh(); +} + +function remJeCountFor(ReceptionReceipt $receipt): int +{ + return JournalEntry::query() + ->where('company_id', $receipt->company_id) + ->where('idempotency_key', 'clinic_receipt_'.$receipt->id) + ->count(); +} + +// ── 1. scan is read-only ────────────────────────────────────────────────────── + +test('scan (no --force) reports the stranded receipt and writes nothing', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id); + $receipt = remMakeStrandedReceipt($company->id, $user->id, 100.0, $accounts); + + $this->artisan('clinic:remediate-stranded-receipts') + ->assertExitCode(0) + ->expectsOutputToContain($receipt->receipt_number); + + $fresh = $receipt->fresh(); + expect($fresh->status)->toBe('pending'); + expect($fresh->journal_entry_id)->toBeNull(); + expect(remJeCountFor($receipt))->toBe(0); +}); + +// ── 2. --force posts a balanced JE + flips paid ────────────────────────────── + +test('--force posts a balanced JE and flips the receipt to paid', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id); + $receipt = remMakeStrandedReceipt($company->id, $user->id, 100.0, $accounts); + + $ledgerBefore = PatientLedgerEntry::query()->where('company_id', $company->id)->count(); + + $this->artisan('clinic:remediate-stranded-receipts --force')->assertExitCode(0); + + $fresh = $receipt->fresh(); + expect($fresh->status)->toBe('paid'); + expect($fresh->journal_entry_id)->not->toBeNull(); + + $je = JournalEntry::query()->find($fresh->journal_entry_id); + expect($je)->not->toBeNull(); + expect((int) $je->source_id)->toBe($receipt->id); + expect($je->source_type)->toBe('reception_receipt'); + expect(round((float) $je->total_debit, 3))->toBe(round((float) $je->total_credit, 3)); + expect((float) $je->total_debit)->toBeGreaterThan(0.0); + expect($je->status)->toBe(JournalEntryStatus::Posted); + + // We never touch the patient ledger — it stays exactly as it was. + expect(PatientLedgerEntry::query()->where('company_id', $company->id)->count()) + ->toBe($ledgerBefore); +}); + +// ── 3. idempotent ──────────────────────────────────────────────────────────── + +test('running --force twice yields exactly one JE (idempotent)', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id); + $receipt = remMakeStrandedReceipt($company->id, $user->id, 100.0, $accounts); + + $this->artisan('clinic:remediate-stranded-receipts --force')->assertExitCode(0); + $this->artisan('clinic:remediate-stranded-receipts --force')->assertExitCode(0); + + expect(remJeCountFor($receipt))->toBe(1); + expect($receipt->fresh()->status)->toBe('paid'); +}); + +// ── 4. GL unset → skip as needs-manual ─────────────────────────────────────── + +test('--force skips a receipt whose company has no clinic AR/revenue configured', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id, configureGl: false); + $receipt = remMakeStrandedReceipt($company->id, $user->id, 100.0, $accounts); + + $this->artisan('clinic:remediate-stranded-receipts --force')->assertExitCode(0); + + $fresh = $receipt->fresh(); + expect($fresh->status)->toBe('pending'); + expect($fresh->journal_entry_id)->toBeNull(); + expect(remJeCountFor($receipt))->toBe(0); +}); + +// ── 5. null payment account_id → skip whole receipt ────────────────────────── + +test('--force skips a receipt when any payment has no GL account', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id); + $receipt = remMakeStrandedReceipt( + $company->id, $user->id, 100.0, $accounts, withPaymentAccount: false + ); + + $this->artisan('clinic:remediate-stranded-receipts --force')->assertExitCode(0); + + $fresh = $receipt->fresh(); + expect($fresh->status)->toBe('pending'); + expect($fresh->journal_entry_id)->toBeNull(); + expect(remJeCountFor($receipt))->toBe(0); +}); + +// ── 6. reconstruct-from-ledger path ────────────────────────────────────────── + +test('--force reconstructs the revenue side from Charge ledger rows when there are no receipt-lines', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id); + $receipt = remMakeStrandedReceipt( + $company->id, $user->id, 100.0, $accounts, revenueVia: 'ledger' + ); + + // Precondition: no receipt-line linkage rows for this receipt. + expect(ReceptionReceiptLine::query()->where('reception_receipt_id', $receipt->id)->count()) + ->toBe(0); + + $this->artisan('clinic:remediate-stranded-receipts --force')->assertExitCode(0); + + $fresh = $receipt->fresh(); + expect($fresh->status)->toBe('paid'); + expect($fresh->journal_entry_id)->not->toBeNull(); + + $je = JournalEntry::query()->find($fresh->journal_entry_id); + expect(round((float) $je->total_debit, 3))->toBe(round((float) $je->total_credit, 3)); + expect((float) $je->total_credit)->toBeGreaterThan(0.0); +}); + +// ── 7. CN linkage heal (never modify the credit note) ──────────────────────── + +test('--force heals a posted credit note linkage and never modifies the credit note', function () { + [$company, $user] = remActor(); + $accounts = remSetupAccounting($company->id); + $receipt = remMakeStrandedReceipt($company->id, $user->id, 100.0, $accounts); + + $line = $receipt->order->lines->first(); + + $cn = ClinicCreditNote::create([ + 'company_id' => $company->id, + 'credit_note_number' => 'CN-REM-'.uniqid(), + 'service_order_id' => $receipt->service_order_id, + 'service_order_line_id' => $line->id, + 'reception_receipt_id' => $receipt->id, + 'patient_id' => $receipt->patient_id, + 'amount' => 40.0, + 'reason' => 'partial refund', + 'status' => 'posted', + 'journal_entry_id' => null, + 'payment_method' => 'cash', + 'is_cash' => true, + 'created_by' => $user->id, + ]); + + $this->artisan('clinic:remediate-stranded-receipts --force') + ->assertExitCode(0) + ->expectsOutputToContain('healed'); + + // Receipt healed + expect($receipt->fresh()->journal_entry_id)->not->toBeNull(); + + // Credit note untouched + $cnFresh = $cn->fresh(); + expect($cnFresh->status)->toBe('posted'); + expect($cnFresh->journal_entry_id)->toBeNull(); + expect(round((float) $cnFresh->amount, 3))->toBe(40.0); +}); codex IMPORTANT: `Modules/Clinic/app/Console/RemediateStrandedReceipts.php:358` The pre-L2 revenue reconstruction can include unrelated `PatientLedgerEntry` rows because it filters only by `company_id`, `entry_type`, `source_type`, and `reference = receipt_number`. If receipt numbers are not globally unique per company, or if duplicate/adjustment Charge rows share the same reference, this can double-count or pull the wrong revenue. Concrete fix: constrain by the receipt’s patient/order context as far as the schema allows, e.g. `patient_id = $receipt->patient_id` and `source_id` in that receipt’s service order line IDs, then fail/skip if reconstructed revenue does not map cleanly to the receipt. IMPORTANT: `Modules/Clinic/app/Console/RemediateStrandedReceipts.php:315` Payment amounts are not validated as positive before JE lines are built. A receipt with mixed positive/negative payment rows summing to `paid_total` could pass the balance and total checks while posting negative debit/credit line amounts. Concrete fix: before appending payment lines, skip/error if any `$payment->amount <= 0`, and preferably require each generated line debit/credit to be `>= 0`. Caveat: this review assumes unshown `CreateJournalEntry` enforces idempotency uniqueness and does not itself mutate state during dry-run detectors. tokens used 21,402 IMPORTANT: `Modules/Clinic/app/Console/RemediateStrandedReceipts.php:358` The pre-L2 revenue reconstruction can include unrelated `PatientLedgerEntry` rows because it filters only by `company_id`, `entry_type`, `source_type`, and `reference = receipt_number`. If receipt numbers are not globally unique per company, or if duplicate/adjustment Charge rows share the same reference, this can double-count or pull the wrong revenue. Concrete fix: constrain by the receipt’s patient/order context as far as the schema allows, e.g. `patient_id = $receipt->patient_id` and `source_id` in that receipt’s service order line IDs, then fail/skip if reconstructed revenue does not map cleanly to the receipt. IMPORTANT: `Modules/Clinic/app/Console/RemediateStrandedReceipts.php:315` Payment amounts are not validated as positive before JE lines are built. A receipt with mixed positive/negative payment rows summing to `paid_total` could pass the balance and total checks while posting negative debit/credit line amounts. Concrete fix: before appending payment lines, skip/error if any `$payment->amount <= 0`, and preferably require each generated line debit/credit to be `>= 0`. Caveat: this review assumes unshown `CreateJournalEntry` enforces idempotency uniqueness and does not itself mutate state during dry-run detectors.