# Backend Accounting Audit — Modules/Accounting

Scope: `/home/moonerpelbaset/moon_erp/Modules/Accounting/` (Laravel module, ~410 PHP files). Audit covered Models, migrations, Form Requests, Controllers, Services, Actions, and Events.

The codebase has good structural choices (decimal columns, bcmath for balance comparison, DB transactions on most flows). However, several accounting bugs would produce wrong GL balances or break audit trails. Findings below.

---

## CRITICAL findings (bugs that could cause incorrect financial records or legal liability)

### C1. CashCheck creates a self-cancelling journal entry — bank balance is never reduced when an issued check clears
**File**: `app/Actions/CashCheck.php` (full file)

- **What**: When an issued check is cashed, the JE has TWO lines but BOTH use `$bankAccount->account_id` — one as credit, one as debit. Net effect on bank GL = 0. The beneficiary/AP account is never touched.
- **Why dangerous**: A client paid by check and the company's bank balance never decreases in the GL. AP also never gets relieved. Cash position is overstated; payables are overstated. Material misstatement.
- **Evidence**:
```
[
  ['account_id' => $bankAccount->account_id, 'debit' => 0, 'credit' => $check->amount],
  ['account_id' => $bankAccount->account_id, 'debit' => $check->amount, 'credit' => 0],
]
```
- **Recommended fix**: Debit the "Checks Issued / AP" liability account (configured per company) and credit `bank_account.account_id`. The Receive/Deliver/Cash lifecycle needs distinct accounts.

### C2. CollectCheck creates a self-cancelling journal entry — bank does not increase when a received check is collected
**File**: `app/Actions/CollectCheck.php`

- **What**: Both JE lines use `$bankAccount->account_id` (one debit, one credit). Net effect on bank GL = 0; the customer AR account is never relieved.
- **Why dangerous**: Customer receivable stays open forever; bank balance unchanged. Affects AR aging, collections, cash forecasting, statutory reports.
- **Recommended fix**: Debit `bank_account.account_id`, credit "Checks Under Collection" (or AR directly). Also fix the upstream Receive/Deposit flow which currently produces no JE.

### C3. CheckReceived lifecycle creates no JE on receipt or deposit
**Files**: `app/Actions/DepositCheck.php`, `app/Models/CheckReceived.php`

- **What**: There is no `ReceiveCheck` action. `DepositCheck` only changes status, no JE. A check received from a customer therefore never gets recorded in the GL until "Collect" — but that JE is broken (C2). So no JE is ever produced for received checks under the current code.
- **Why dangerous**: Received checks (a real asset) are completely off the books. Trial balance is incomplete.
- **Recommended fix**: On Receive: Debit Checks-On-Hand, Credit Customer/AR. On Deposit: Debit Checks-Under-Collection, Credit Checks-On-Hand. On Collect: Debit Bank, Credit Checks-Under-Collection. On Return: reverse.

### C4. CheckIssued has no JE on issuance/delivery; only on "Cash" (which is broken)
**Files**: `app/Actions/DeliverCheck.php`, `app/Actions/CashCheck.php`

- **What**: Issuing or delivering a check creates no JE. Only `CashCheck` does, and it is broken (C1).
- **Why dangerous**: A delivered check is a real liability (committed cash). Must be recorded immediately (Debit AP, Credit Checks-Outstanding) and only on clearance moved to Bank.
- **Recommended fix**: Same pattern as C3 inverted (Checks-Outstanding liability).

### C5. Petty cash standalone transactions produce no journal entries
**File**: `app/Http/Controllers/PettyCashTransactionController.php` (`store()` and `destroy()`)

- **What**: Creating a `PettyCashTransaction` updates `petty_cash.current_balance` directly but does not create any `JournalEntry`. Deleting it just reverses the cached balance.
- **Why dangerous**: Petty cash inflows/outflows are real cash movements that must be in the GL. They are entirely missing — trial balance will not include them. Also `journal_entry_id` is nullable and not set.
- **Recommended fix**: Wrap in `CreateJournalEntry`: Receipt → Debit petty cash account, Credit fund source; Payment → Debit expense/AP, Credit petty cash account. Forbid direct deletion of posted transactions.

### C6. ApproveExpense / ApprovePaymentVoucher / ApproveReceiptVoucher pass `'type'` instead of `'entry_type'` (silently dropped); resulting JE remains Draft (no GL impact); later Cancel actions require a Posted JE and will fail
**Files**: `app/Actions/ApproveExpense.php`, `ApprovePaymentVoucher.php`, `ApproveReceiptVoucher.php`, `CancelExpense.php` (similar pattern in CancelPaymentVoucher/Revenue)

- **What**:
  1. Action arrays use `'type' => 'standard'` and `'branch_id' => ...`. Neither key exists in `JournalEntry::$fillable`, so they are silently dropped.
  2. `CreateJournalEntry` only auto-posts if `accounting.auto_post_entries` setting is on. Otherwise the JE remains **Draft**. The voucher/expense/revenue is marked Approved but its underlying JE is Draft → not in GL → expense doesn't actually post.
  3. `CancelExpense` then calls `ReverseJournalEntry`, which **requires the entry to be Posted**. If auto_post is off, cancelling an approved expense throws "entry not posted".
- **Why dangerous**: With default settings, every expense/revenue/voucher is effectively non-posted; AR/AP and cash are never moved. Reversing fails. Multi-month problem.
- **Recommended fix**: In Approve actions, explicitly approve + post the JE (`ApproveJournalEntry` + `PostJournalEntry`). Remove `'type'`/`'branch_id'` no-op keys; use `entry_type`.

### C7. CreateJournalEntry's auto-post setting bypasses approve/post permission checks
**File**: `app/Actions/CreateJournalEntry.php`

- **What**: When `accounting.auto_post_entries=true`, the action chains `ApproveJournalEntry` → `PostJournalEntry` directly inside `create`. The controller's `permission:accounting.journal-entries.approve`/`.post` middleware only protects HTTP endpoints, not direct action calls.
- **Why dangerous**: A user with only `create` permission effectively posts entries when auto-post is on. Internal-control / separation-of-duties violation.
- **Recommended fix**: Either disable auto-post by default, or check the current user's permissions before chaining approve/post.

### C8. FixedAssetService::sell uses depreciation_account_id as the debit for sale proceeds AND as gain/loss target
**File**: `app/Services/FixedAssetService.php::sell()`

- **What**: Line 1 of the sell JE debits `$asset->category->depreciation_account_id` for `$saleAmount` (this should be cash/bank). Gain/loss is also booked to the same depreciation account.
- **Why dangerous**: Asset sales completely mis-stated. Depreciation expense will inflate by the sale amount; cash never increases; no Gain/Loss on Sale on the P&L.
- **Recommended fix**: Add `cash_or_bank_account_id` and `gain_loss_account_id` on `asset_categories` (or accept as parameters), and route: Debit Cash, Debit Accumulated Depr, Credit Asset Cost, Credit Gain on Sale (or Debit Loss).

### C9. Fixed asset creation does not generate the acquisition journal entry
**File**: `app/Http/Controllers/FixedAssetController::store()`, `FixedAssetService`

- **What**: Creating a fixed asset only inserts the row. There is no JE debiting the asset account / crediting AP or Bank for the purchase cost.
- **Why dangerous**: Fixed asset register and GL diverge; balance sheet under-states fixed assets unless someone manually creates an offsetting JE.
- **Recommended fix**: On asset create, generate JE: Debit `asset_category.asset_account_id`, Credit `paying_account_id` (cash/AP, taken from the create request).

### C10. ReopenFiscalYear retroactively flips posted closing entries to Cancelled (does not reverse them) — breaks audit immutability
**File**: `app/Actions/ReopenFiscalYear.php`

- **What**: When reopening, the action does `JournalEntry::whereIn('id', $entryIds)->update(['status' => Cancelled])`. This bulk-update changes the historical status of already-posted entries without creating a reversing entry.
- **Why dangerous**:
  1. Auditors require posted entries are immutable; correction must be via a new reversing entry.
  2. GL reports filter by `status=Posted`. Retroactive cancellation changes prior-period closing balances silently — any printed/saved report from before reopening will not reconcile.
  3. `CancelJournalEntry` action explicitly forbids cancelling a Posted entry; this path bypasses that rule.
- **Recommended fix**: For each closing entry, call `ReverseJournalEntry` (dated at or after fiscal_year.end_date). Keep originals Posted; reversals are new Posted entries. Audit trail preserved.

### C11. Year-end closing entries are inserted as Posted with NULL entry_number and bypass the posting workflow
**File**: `app/Actions/ExecuteYearEndClosing::createClosingEntry()`

- **What**: After calling `createJournalEntry`, the action does `$entry->update(['status' => Posted, 'approved_by' => ..., 'posted_by' => ...])` directly. The only path that assigns `entry_number` is `PostJournalEntry` (via SequenceService). So closing entries are Posted but have `entry_number=NULL`.
- **Why dangerous**: Posted JEs without entry_number are unauditable. The `unique(company_id, entry_number)` constraint allows multiple NULLs in MySQL, breaking numbering integrity. Reports cannot reference by number.
- **Recommended fix**: Call `ApproveJournalEntry` then `PostJournalEntry` so sequence and period-open checks run.

### C12. ConfirmOpeningBalance does not auto-post the JE and does not pass `created_by`
**File**: `app/Actions/ConfirmOpeningBalance.php`

- **What**: Calls `createJournalEntry->execute([...], $lines)` without `created_by`. If auto-post is on, `CreateJournalEntry` will reference undefined `$data['created_by']`. If auto-post is off, the opening balance JE sits as Draft — confirming the opening balance does not populate the GL.
- **Why dangerous**: Without a posted JE, opening balances do not appear in trial balance, ledger, balance sheet. Year zero is effectively zero.
- **Recommended fix**: Pass `created_by => auth()->id()` and explicitly approve+post (opening balances should always post; do not depend on a setting).

### C13. ReverseJournalEntry allows reversing the same entry multiple times
**File**: `app/Actions/ReverseJournalEntry.php`

- **What**: No check on `$entry->reversals()->exists()`. A posted entry can be reversed twice, creating two reversal JEs and double-cancelling the original.
- **Why dangerous**: Two reversals = net effect doubles the cancellation; ledger shows the original entry as effectively cancelled twice.
- **Recommended fix**: `if ($entry->reversals()->where('entry_type','reversal')->exists()) throw …` before creating the reversal.

### C14. ApproveJournalEntry bypassed when CreateJournalEntry auto-post is on (no permission gate)
**File**: `app/Actions/CreateJournalEntry.php`

- **What**: See C7. Restated for emphasis: HTTP middleware does not protect intra-process action chaining. Auto-post effectively removes the approval step from any flow that uses `CreateJournalEntry`.
- **Recommended fix**: Same as C7; combine with a feature flag review of `accounting.auto_post_entries`.

### C15. CostAllocationService does not handle rounding residuals — debits may not equal credit
**File**: `app/Services/CostAllocationService::execute()`

- **What**: For each allocation line, `bcmul(totalAmount, percentage/100, 3)`. Summed debits may differ from `totalAmount` due to truncation. Example: 100 / 3 lines = 33.333 × 3 = 99.999 ≠ 100. The credit line uses raw `$totalAmount`. The JE will fail `validateBalance()` if auto-post is on; if auto-post is off, the draft is unbalanced and may be approved manually (but approval also requires balance — so allocation silently fails).
- **Recommended fix**: Compute first N−1 lines, then put the remainder into the last line (or into a "rounding adjustment" line). Validate sum == total before insertion.

### C16. ApproveJournalEntry does not enforce period.is_open
**File**: `app/Actions/ApproveJournalEntry.php`

- **What**: Only `PostJournalEntry` checks `fiscalPeriod->isOpen()`. Approval doesn't. If the period was open at creation, closed before approval, and approve is called, the entry gets Approved status. Post will then fail — but the entry sits Approved indefinitely.
- **Recommended fix**: Mirror the `isOpen()` check in approve.

---

## HIGH findings (data integrity / business rule issues)

### H1. ExchangeRateService.getRate ignores inverse direction
**File**: `app/Services/ExchangeRateService.php`

Looks up only `from→to`; no fallback to invert `to→from`. Multi-currency entries silently use `exchange_rate=1` (see H2). Recommend invert when no direct rate exists.

### H2. JournalEntryLine.exchange_rate defaults to 1 when no rate exists for a foreign currency line
**File**: `app/Actions/CreateJournalEntry.php`

`if ($rate) {...} else { exchangeRate stays 1, base_debit = original }`. Foreign currency lines silently use 1:1. Base-currency reports drift. Recommend throwing on missing rate when `currency_id != base_currency_id`.

### H3. No FX gain/loss tracking anywhere
No model, service, or action for revaluation of foreign-currency monetary balances at period-end. Multi-currency partner balances drift from base balances. Recommend a revaluation service.

### H4. Petty cash `current_balance` is updated in two competing ways
`PettyCashBalanceService::syncBalance()` derives from GL; `PettyCashTransactionController::store()` mutates the column directly with `bcadd`. Combined with C5 (no JE created on standalone transactions), syncing from GL would reset it to a wrong value. Pick one source of truth.

### H5. Account model lacks SoftDeletes trait but migration includes `softDeletes()` column
Deletes are hard. `AccountCodeGenerator::clearSoftDeletedCode` references soft-delete cleanup but no soft-delete behavior is active. Deleting an account with historical JE lines breaks queries. Add `SoftDeletes` and forbid delete when lines exist.

### H6. journal_entry_lines.account_id has no foreign key constraint
Migration uses `unsignedBigInteger` without `constrained()`. Same for `journal_entry_id`, `currency_id`, `cost_center_id`. journal_entries has no FK to fiscal_year/fiscal_period either. Add FKs with `restrictOnDelete`.

### H7. FiscalPeriod enum has only Open/Closed — no soft-close
Standard accounting differentiates soft-close (no new operational entries) from hard-close (no entries). Once closed, no documented `ReopenFiscalPeriod` action exists. A typo locks all future operations.

### H8. Trial balance treats opening JE as period movement when dateFrom = fiscal_year.start_date
`getTrialBalance` does `date < $dateFrom` for opening, `date >= $dateFrom` for period. The opening JE (entry_type=opening) is dated `start_date`, so it falls into period movement, not opening. Special-case `entry_type=opening` regardless of date.

### H9. Reversal JE allows past-dated reversal
`ReverseJournalEntry` accepts a user-supplied date with no `>= original.date` check. A reversal dated before the original creates negative-time accounting. Enforce monotonic dates.

### H10. CancelJournalEntry can orphan source-document references
Cancels any Draft/Approved entry without checking that a downstream voucher/expense still references it. Result: voucher Approved with a Cancelled JE. Forbid cancelling JEs created by other modules unless the source is also cancelled.

### H11. Tax: only single-rate flat tax; no inclusive/compound; TaxService unused by Expense/Revenue controllers
ExpenseController/Revenue: `tax_amount = round(amount * rate / 100, 3)` — bypasses `TaxService::calculateTax` (which does handle inclusive). Multi-line tax not supported. Centralize tax computation in TaxService.

### H12. Bank reconciliation auto-match: exact-amount only, no date tolerance, picks first on ties
`BankReconciliationService::autoMatch` matches by exact amount and zero opposite. Duplicate amounts on same date pick the first row by ID arbitrarily — wrong matches persist. Add date tolerance, reference tie-break, and ambiguity-handling.

### H13. Bank reconciliation has no "unmatch" action
`ReconciliationMatch` can be created in three ways but no delete/unmatch path. Wrong matches are permanent. Add an unmatch action that deletes the match and resets `match_status`.

### H14. CompleteReconciliation does not require `difference == 0`
Only checks that no statement lines are Unmatched. A reconciliation can be marked Completed with a non-zero book-vs-bank difference. Require `bccomp(difference,'0',3)===0` or explicit override with audit log.

### H15. Statement balance integrity not verified on import
`importStatement` accepts arbitrary `balance` values per line; no validation that opening + cumulative net = closing. Bad imports silently accepted.

### H16. FixedAsset update silently allows mid-life changes to salvage_value/useful_life_months without adjustment JE
Update recomputes NBV as `cost - accumulated` but ignores new salvage value (future depreciation can dip below salvage). No audit trail of estimate change. Per IAS 8, this is a change of accounting estimate — should be prospective with documented effective date.

### H17. Fixed asset depreciation: no mid-month proration
`calculateMonthlyDepreciation` returns straight-line full month regardless of `in_service_date`. An asset placed in service on the 28th gets a full month of depreciation. Choose a convention (full-month / half-year / actual-days) and enforce.

### H18. RecurringEntry uses live template (no snapshot)
`RecurringEntryService::executeRecurring` reads the *current* template lines. Editing a template retroactively changes future recurring amounts. Snapshot template lines on RecurringEntry create, or version templates.

### H19. RecurringEntry has no backfill for missed runs
`executeRecurring` advances `next_execution_date` by exactly one cycle. Missed runs (server outage) are lost. Loop: while `next_execution_date <= today`, execute.

### H20. AccountTransfer accepts any account type/classification
`StoreAccountTransferRequest` validates only `exists:accounts,id`. Users can transfer between expense and revenue accounts, creating bogus JEs. Filter to cash/bank-classified detail accounts.

### H21. Netting can over-net
`ExecuteNetting` validates `amount > 0` but doesn't cap at `min(AR_balance, AP_balance)`. Users can net more than the offsetting balance. Cap to the smaller of the two.

### H22. CheckIssued migration lacks `branch_id` column but model fillable includes it
Mass-assignment includes `branch_id`; column does not exist. Insert silently drops it (with strict mode off) or throws (with strict mode on). Add the migration or drop from fillable.

---

## MEDIUM findings (correctness/UX issues)

### M1. JournalEntry.entry_number nullable allows multiple Posted entries with NULL number
Migration `make_entry_number_nullable_on_journal_entries` plus the year-end closing path (C11) yields posted JEs without numbers. Add a check constraint: when status=Posted, entry_number must be NOT NULL.

### M2. posted_by/approved_by are unsignedBigInteger without FK
A deleted user leaves orphan IDs in the audit metadata. Add FK with `nullOnDelete`.

### M3. Sequences are generated outside the JE post transaction
`PostJournalEntry::execute` calls `generateNext(...)` then `$entry->update(...)`. If the update fails, the sequence has advanced. Some jurisdictions require gap-free sequences; document or fix.

### M4. JournalEntry update endpoint accepts `lines` validation but silently ignores it
`JournalEntryController::update` does `$entry->update($request->validated())`; `lines` is in the validation but not a column. The route documentation says metadata-only — make this explicit (reject if lines present, or apply line edits in a transaction).

### M5. UpdateJournalEntryRequest allows updating `date` but the JE's fiscal_period_id is not re-resolved
Editing the date may push the entry into a different (perhaps closed) period without changing `fiscal_period_id`. Re-resolve period on update; abort if no open period covers the new date.

### M6. TaxRate.rate stored as decimal(8,4) — no max=100 validation visible
Allows 9999.9999%. Add a validation cap.

### M7. TaxService.calculateTax uses native float arithmetic
Mixes float `($amount * $rate)` with `round(..., 3)`. Use `bcmul`/`bcsub` consistently for monetary math.

### M8. ExchangeRateService.convert uses native float arithmetic
`round($amount * $rate, 3)` — float multiplication is non-deterministic for large amounts. Use `bcmul($amount, $rate, 3)`.

### M9. Trial balance Collection->sum() uses native float
`$data->sum('opening_debit')` on Collection of arrays sums floats. For large datasets, drift accumulates. Use bcadd row-by-row.

### M10. JournalEntry model has no SoftDeletes trait but migration includes column
Same pattern as Account model (H5). Add the trait.

### M11. AccBpExt netting message exposes internal account configuration
Error throws "partner missing AR/AP account" — fine, but ensure messages are end-user appropriate and don't leak account IDs.

### M12. Currency.decimals configurable but all JE lines stored at 3 decimals
A currency with 4 decimals (rare) cannot be stored losslessly. Document the 3-decimal cap or scale by currency.

### M13. AutoAccountService converts a Detail account to Header on the fly
If the original Detail account already has transactions, suddenly it's a Header but lines still reference it. Reports that filter by `account_type=detail` will exclude these historical balances. Validate no JE lines exist before conversion.

### M14. `has_children` is not reset to false when the last child is deleted
`AutoAccountService::createChildAccount` sets parent.has_children=true; no path resets it. Minor inconsistency.

---

## LOW findings (polish/maintainability)

### L1. Services use `auth()->id()` directly — couples services to HTTP context
`RecurringEntryService` does `auth()->id() ?? 1` — console execution silently attributes to user id 1. Pass userId as parameter.

### L2. Monthly depreciation generates one JE for ALL active assets
For thousands of assets, one JE has thousands of lines. Batch per category or per asset.

### L3. Resolving retained earnings via name LIKE matching
`YearEndClosingService::resolveRetainedEarningsAccount` uses `name LIKE '%retained%' OR name LIKE '%earnings%' OR code LIKE '%32%'`. Will misfire on Arabic-only chart of accounts. Add `is_retained_earnings` flag or settings pointer.

### L4. resolvePeriod returns the first matching period (no overlap protection)
Overlapping periods would pick first by id. Either enforce no-overlap at FiscalYear creation or pick deterministically by latest start_date.

### L5. Many controllers use `request('foo')` instead of `$request->input('foo')`
Inconsistent but works.

### L6. No rate-limiting visible on heavy report endpoints
TrialBalance, BalanceSheet, BudgetVsActual loop through accounts and run N+1-style aggregate queries each. Could be DoS / slow for large GL.

### L7. `OpeningBalance::isLocked()` defined but no enforcement beyond `isDraft()`
Locked status appears dead unless `LockOpeningBalance` action enforces it.

### L8. FormRequest `authorize()` always returns true
Reliance entirely on route middleware. If any route is registered without permission middleware (or via closure), the request is unauthorized-but-allowed. Add a policy or move auth into the request.

### L9. Multiple Action classes catch `\RuntimeException` and return 422 — but other exception types (DB errors) leak 500s
Standard Laravel behavior but worth wrapping in a uniform exception handler for accounting.

### L10. CheckIssued / CheckReceived state machines lack a "lost/voided" terminal state beyond Cancelled
Real-world checks get lost, voided, replaced — current model can't distinguish.

---

## What's well done (strengths to commend)

1. **bcmath used for balance validation** in `JournalEntryService::validateBalance` — proper monetary arithmetic.
2. **Decimal columns throughout** (debit/credit: 15,3; amounts: 12,3). No floats stored in DB.
3. **DB transactions** wrap multi-step operations in the core Actions (Create/Cancel/Reverse JE, dispose/sell asset, reconciliation match, year-end closing). Good consistency.
4. **Permission middleware** on all controllers with granular permissions (view/create/update/approve/post/cancel/reverse).
5. **fiscal_period.is_closed check in PostJournalEntry** — proper period gating at posting time.
6. **isBalanced() model method using bcmath** — proper balance check before approval.
7. **Reversal generation flips debits/credits** — correct mathematical pattern.
8. **YearEndClosingService is granular**: separate revenue, expense, income-summary, opening entries — each can be inspected.
9. **PettyCashBalanceService.getGlAccountBalance derives from GL** — sound approach when used.
10. **source_type / source_id traceability** on JournalEntry — every entry can trace back to its origin.
11. **Multi-currency exchange_rate decimal(18,6)** — adequate precision for FX.
12. **SoftDeletes columns on most master-data tables** — supports historical reference (though some models miss the trait).
13. **Granular CHECK at JE balance validation level** — strong invariant when respected.
14. **AccountCodeGenerator** appears to handle child-code generation and soft-delete recycling — defensive design.
15. **Quota tracking trait on JournalEntry** — per-tenant limits.

---

## Coverage gaps (areas not audited / need deeper review)

- **Routes file** (`routes/api.php`) not opened — verify all routes have permission middleware; closures could bypass it.
- **Policies** — no Policy classes referenced in controllers. Confirm none expected.
- **Background scheduler** — RecurringEntryService is not visibly hooked to a scheduler. Inspect `Console/Kernel.php` and module command list.
- **Multi-tenant scoping at model level** — controllers consistently filter by `auth()->user()->company_id`, but no global scope on BaseModel was confirmed. Direct service or console calls could leak across tenants.
- **Resources** (`Http/Resources/*`) — not opened; may leak internal fields.
- **OpeningBalanceInvoice flow** — `LockOpeningBalance`/`UnlockOpeningBalance` actions not deeply reviewed.
- **Reports**: `BalanceSheetService`, `IncomeStatementService`, `CashFlowService`, `AgingReportService`, `PartnerStatementService` — only spot-checked. All should be verified to filter by `status=Posted` consistently.
- **Bank reconciliation `bankAccount.account_id` change after matching** — if the GL account pointer is changed post-match, prior matches break. Not reviewed for guardrails.
- **Approval workflows custom** — Expense/Revenue have a single approver field; no multi-level approval state machine. If the BRD demands multi-step, gap.
- **Audit log table** — no dedicated `audit_logs` table referenced by the Accounting module. Edit history (e.g., who changed an expense from amount=100 to amount=200 while still in Draft) is not preserved.
- **Tests** (`tests/`) — not opened. Test coverage for these bugs unknown.
- **SequenceService** — assumed correct; needs verification for gap-free sequences when required by regulation.

---

## Suggested triage order

1. **Stop accepting cash via the check workflow** until C1–C4 are fixed (these will manufacture cash-position errors immediately).
2. **Disable `accounting.auto_post_entries`** globally (or force Approve actions to explicitly post JEs) so vouchers/expenses actually hit the GL (C6, C7, C14).
3. **Disable petty cash standalone transactions** in the UI until C5 is fixed.
4. **Disallow ReopenFiscalYear** until C10 is rewritten with proper reversal entries.
5. **Block fixed-asset sell** until C8 is fixed; add the acquisition JE for C9.
6. Add a defensive constraint or scheduled job validating **every Posted JE has `total_debit == total_credit`** and `entry_number IS NOT NULL` — defense-in-depth.
7. Audit existing GL data: search for self-cancelling JEs (both lines on same account), Approved vouchers with Draft JE, Posted JEs with NULL entry_number, and balance reconciliations marked Completed with difference != 0.

End of report.
