# ERP Mega Plan — Accounting + Inventory + Billing

Status: design document, pre-implementation. Nothing is built yet.
Companion docs: [SCHEMA.md](SCHEMA.md) (tables), [ACCOUNTING.md](ACCOUNTING.md) (posting matrix).

---

## 0. Verified environment

| Component | Installed | Verdict |
|---|---|---|
| PHP | 8.2.12 (XAMPP, ZTS, x64) | OK for Laravel 12 (requires 8.2–8.5). Patch level is old; upgrade to PHP 8.3/8.4 before production. |
| MariaDB | 10.4.32 | OK (Laravel 12 requires MariaDB 10.3+). **Caveat below.** |
| Composer | 2.8.12 | OK |
| Node / npm | 24.11.0 / 11.6.1 | OK for Vite + Tailwind |
| Repo | empty | Greenfield |

**MariaDB 10.4 caveats that affect design:**

1. `SKIP LOCKED` was added in MariaDB **10.6**. Laravel's `database` queue driver relies on it to avoid lock contention. On 10.4, multiple queue workers will contend and can deadlock. → V1 rule: **one queue worker** on the `database` driver, or move to Redis/MariaDB 10.6+ before running multiple workers.
2. No native `JSON` type (it is a `LONGTEXT` alias with a check constraint). Functional but do not plan on JSON indexing — anything queried must be a real column.
3. `utf8mb4` + `innodb_large_prefix` is fine on 10.4, but keep indexed `varchar` columns at **191** or shorter where composite indexes are involved.
4. XAMPP defaults are lenient. Force strict mode in `config/database.php` (`strict => true`) so silent truncation of money/qty never happens.

---

## 1. The one rule everything else serves

> **One business document is the single source of truth. Every ledger — stock, subledger, general ledger — is *derived* from it by a single posting service, inside one database transaction, and is never written by any other code path.**

Concretely, there is exactly **one** class that may insert into `journal_entries`, and exactly **one** class that may insert into `stock_movements`. Everything else calls them. This is enforced by:

- Model-level guards (`JournalEntry::creating` throws unless a "posting context" flag set by the posting service is active).
- Architecture tests (Pest) asserting no class outside `App\Domain\Accounting\Posting` references `JournalEntry::create`.

If you get this wrong in month one, you will be reconciling by hand in year one.

---

## 2. Architectural decisions (with the reasoning, and where I disagree with the brief)

These are the decisions worth arguing about. Each has a recommendation and a rationale.

### D1. Money and quantity types — never floats
- Amounts: `DECIMAL(18,4)`. Unit prices: `DECIMAL(18,4)`. Unit costs: `DECIMAL(18,6)` (FIFO/WAC costs need extra places or rounding drift accumulates). Quantities: `DECIMAL(18,6)`.
- Rounding happens **once**, at document level, into an explicit `rounding_adjustment` amount posted to a "Rounding Off" GL account. Never let line-level rounding silently make debits ≠ credits.
- Balance assertion is exact equality on scaled integers, not `abs(diff) < 0.01`.

### D2. Weighted Average should be the V1 default, not FIFO
**This is a disagreement with the brief (§18).** The brief recommends FIFO for V1. FIFO is the right *long-term* option, but it has a nasty interaction with real-world data entry: backdated purchases. If a sale on the 10th consumed layers, and someone posts a purchase dated the 5th on the 15th, true FIFO requires **recosting every subsequent consumption and issuing COGS correction journals**. Small businesses backdate constantly.

Recommendation:
- Build `InventoryCostingService` behind an interface from day one (the brief is right about this).
- Ship **Moving Weighted Average (WAC)** as the default costing method — it is stable under backdating, matches how most Nepali trading businesses actually think, and produces defensible COGS.
- Ship **FIFO** as a per-company opt-in in Phase 2/3, backed by real cost layers (`stock_layers` + `stock_layer_consumptions`), plus a `RecostProduct` action for backdated receipts.
- Costing method is locked per company once the first transaction posts. Changing it mid-year is an accounting event, not a settings toggle.

### D3. Do not keep a running balance column in the stock ledger
**Disagreement with the brief (§14):** `stock_movements.balance_qty` is a concurrency hazard. Two concurrent sales of the same SKU both read balance 10 and both write 8.

Recommendation:
- `stock_movements` is **append-only and has no running balance**.
- `stock_balances (company_id, product_id, warehouse_id)` is a separate aggregate row holding `qty_on_hand`, `qty_reserved`, `avg_cost`, `value`.
- Every stock post does `SELECT ... FOR UPDATE` on the `stock_balances` row **first**, in a deterministic order (sorted by product_id, warehouse_id, to prevent deadlocks between multi-line documents), then writes movements and updates the aggregate.
- A `stock:verify` console command proves `stock_balances.qty_on_hand == SUM(qty_in - qty_out)` for every pair. This runs in CI and can run nightly.
- If you want a per-movement running balance for the stock ledger *report*, compute it in the query with a window function (MariaDB 10.4 supports window functions), not in storage.

### D4. Subledgers are dimensions on the GL, not separate ledger tables
**Disagreement with the brief (§33), which lists "Customer Ledger" as its own artifact.** A separate `customer_ledger` table is a second source of truth that *will* drift from the AR control account. Every ERP support ticket you will ever receive is "customer statement doesn't match trial balance".

Recommendation:
- `journal_entries` carries dimensions: `party_type`, `party_id`, `branch_id`, `warehouse_id`, `cost_center_id`.
- The customer ledger/statement is a **query** over `journal_entries` where `account_id = AR control` and `party_id = customer`. It reconciles to the GL **by construction** — it is literally the same rows.
- Same for suppliers (AP control) and bank accounts (each bank account maps 1:1 to a GL account).
- `customers.balance` may exist as a **cache** for list screens, rebuildable by command, and never trusted for reporting.

### D5. Document numbers are allocated at POST, never at draft, and never gapless-by-luck
- A `document_sequences` table keyed `(company_id, branch_id, doc_type, fiscal_year_id)` with `next_number`, allocated under `SELECT ... FOR UPDATE` inside the posting transaction.
- Never `MAX(number)+1`. Never `count()+1`.
- Drafts get no number (or a `DRAFT-{id}` display label). This keeps the posted invoice series **gapless**, which matters for tax audit.
- Cancellation does not reuse or renumber. A cancelled invoice number stays consumed and visible.

### D6. Draft → Posted → Cancelled(by reversal), and posted rows are immutable
- Only `POSTED` affects stock, GL, balances, reports (brief §36 — agreed, this is the most important schema-level decision).
- Posted financial documents are immutable at the DB access layer: a trait blocks `update`/`delete` on posted transaction models except for a whitelist of non-financial fields (`notes`, `internal_ref`) which are audit-logged.
- "Edit a posted invoice" in the UI = cancel-by-reversal + clone to a new draft. The user-facing verb can still be "Amend"; the ledger sees two documents.
- Reversal journals are dated per policy: **same date as the original if the period is open, otherwise the current open date**. Never silently reopen a closed period.

### D7. Period control is a first-class feature
- `fiscal_years` → `accounting_periods` (usually months) each `OPEN | CLOSED | LOCKED`.
- Posting validates the date against period status and against subscription/role (an accountant may post to an open prior month; a cashier may not).
- Year-end close posts a closing journal rolling revenue/expense into Retained Earnings, and creates the next year's opening balances. This must exist before the first customer's year-end, i.e. plan it in Phase 5, not Phase 8.

### D8. Multi-branch: branches are a *dimension*, not a separate set of books
Agreed with the brief (§3). One chart of accounts, one journal series per company; `branch_id` on documents and on every journal entry line. Branch P&L is a filtered report. Inventory is genuinely per-warehouse, and warehouses belong to branches.
- Inter-branch stock transfers use an **in-transit** warehouse + GL account when send and receive are separate events, so nothing is ever "nowhere".

### D9. Tenancy: `company_id` everywhere + enforced global scope (your choice, agreed)
- `BelongsToCompany` trait: global scope on read, auto-fill on write.
- Every unique index is **composite with `company_id`** (e.g. `unique(company_id, sku)`), never globally unique — this is the single most common multi-tenant schema bug.
- Cross-tenant leak protection: an architecture test asserting every model in `App\Models` either uses the trait or is on an explicit allowlist (`User`, `Company`, `Plan`, ...).
- Foreign keys never cross companies; a validation rule (`ExistsInCompany`) is used on all `*_id` inputs.

### D10. The POS billing screen must not be a Livewire round-trip per keystroke
Agreed with the brief on the stack (Blade + Livewire 3 + Alpine + Tailwind), with one important exception.

A cashier typing quantity or scanning barcodes cannot wait 80–300 ms per key for a server render. Recommendation:
- The billing cart is **client-side Alpine state**. All arithmetic (line totals, discounts, tax, grand total) happens in JS from a pure calculation module.
- Product lookup: a slim cached index (`id, sku, barcode, name, unit, prices, tax_id, stock`) served as JSON, ETag/version-stamped, held in IndexedDB for shops under roughly 20k SKUs; server-side search endpoint above that. Barcode scan resolves with zero network latency.
- **The server recalculates every total from scratch on post and rejects the request if the client total disagrees.** The client is a convenience, never an authority. This one rule also gets you offline billing later almost for free.
- Livewire is used for everything else (CRUD, masters, reports, filters) where it is genuinely a productivity win.

### D11. Pricing engine is resolved server-side and snapshotted
- `PricingService::resolve(product, customer, qty, date, priceList)` returns a `ResolvedPrice` value object: `{ unit_price, min_price, tax_rate_id, source }` where `source ∈ {estimate_lock, customer_specific, price_list, qty_break, promo, product_default}`.
- Documents store the **snapshot**: `unit_price`, `price_source`, `resolved_min_price`, `tax_rate_id`, `tax_rate_percent`. Never re-derive a posted document's price from master data. This is the brief's §6 requirement, generalized — and note it must snapshot the **tax rate and the min price too**, not just the price, or a VAT rate change silently rewrites history.
- Below-minimum sale: blocked unless the user holds `sales.price.override_below_min`, otherwise it creates a `price_approvals` record that a manager approves (in-session PIN/2nd-user auth for the POS case). Every override is audit-logged with old/new/approver/reason. That is the discount-leakage control the brief asks for in §7 and §38.

### D12. Payments are documents with allocations; unapplied cash is a real balance
- `payments` (a receipt or a disbursement) → `payment_lines` (one per tender: cash / bank / card / cheque / credit note) → `payment_allocations` (payment → invoice, with amount).
- Split payment (brief §42) falls out of this naturally.
- Over-allocation is prevented by locking the target invoice row; unallocated amounts sit in **Customer Advances** (a liability), not floating in AR. Cheques in hand can sit in **Undeposited Funds** until banked.
- Withholding tax (TDS) on collection/payment is a first-class allocation line, because in Nepal it will come up.

### D13. Localization is an optional per-company layer, off by default
Your answers selected both "all Nepal features" and "none", so this is the reconciliation, and it needs your confirmation (see Open Questions):
- Core stores **AD dates only** (`date` columns, UTC-safe). BS is a **presentation + input** concern via a `NepaliDate` cast/helper and a `date_system` company setting. Never store BS strings as the primary date — sorting and range queries will break.
- Fiscal year is fully configurable (start month/day), so Shrawan–Ashad is data, not code.
- Tax is `tax_rates` rows (13% VAT, 0%, exempt) + `tax_groups`, never a constant.
- IRD/CBMS-oriented fields (`is_printed`, `print_count`, `printed_by`, `sync_status`, `materialized_view` flags) are cheap to add to the invoice table now and expensive to retrofit → **add the columns in Phase 3**, implement the sync in Phase 8 only if a customer needs it.
- Devanagari UI: use Laravel localization from day one (no hardcoded strings in Blade), ship `en` first, add `ne` files later at near-zero cost. Nepali digit grouping (lakh/crore) goes in one `Money` formatter.

### D14. Subscription/feature gating belongs behind one gate, not sprinkled `if`s
- `plans` → `plan_features` → `company_subscription`. A single `Feature::enabled('banking')` gate + a `feature:` route middleware + Blade directive.
- Modules are enabled per company (brief §1: "billing only" vs "full accounting"). Important: **the accounting engine always runs**, even for billing-only customers — it is just hidden from the navigation. Otherwise upgrading a customer later means backfilling a year of journals, which is a nightmare. Posting is cheap; hiding is free.

---

## 3. Target architecture

```
app/
├── Domain/
│   ├── Accounting/      Account, Journal, JournalEntry, FiscalYear, Period
│   │   ├── Posting/     PostingService (SOLE writer of journal_entries)
│   │   │               JournalDraft, JournalLine (value objects)
│   │   └── Actions/     PostJournal, ReverseJournal, CloseFiscalYear
│   ├── Inventory/
│   │   ├── Ledger/      StockLedger (SOLE writer of stock_movements)
│   │   ├── Costing/     CostingMethod (interface), WeightedAverage, Fifo
│   │   └── Actions/     PostStockMovements, TransferStock, AdjustStock, RecostProduct
│   ├── Sales/           Actions: CreateSale, PostSale, CancelSale, CreateSalesReturn
│   ├── Purchases/       Actions: CreatePurchase, PostPurchase, ReceiveGoods, ...
│   ├── Billing/         PricingService, TaxService, DocumentCalculator, Numbering
│   ├── Payments/        RecordPayment, AllocatePayment, ReversePayment
│   ├── Banking/         Deposit, Withdraw, Transfer, BankCharge, Reconcile
│   └── Shared/          Money, Quantity, DocumentStatus, Result
├── Models/              thin Eloquent models, no business logic
├── Policies/
├── Http/
│   ├── Controllers/     thin; POS JSON endpoints
│   └── Livewire/        CRUD + reports
├── Support/             Feature gate, NepaliDate, Formatters
└── Reports/             one query-object class per report, no logic in Blade
```

**Posting contract** — every postable document implements:

```php
interface Postable {
    public function postingDate(): CarbonImmutable;
    public function journalDraft(PostingContext $ctx): JournalDraft;   // GL effect
    public function stockDraft(PostingContext $ctx): ?StockDraft;      // inventory effect
}
```

`PostDocumentAction` then does, for *every* document type, identically:

```php
DB::transaction(function () use ($doc) {
    $this->guardPeriodOpen($doc);
    $this->guardPermissions($doc);
    $this->lockBalances($doc);                     // FOR UPDATE, deterministic order
    $doc->number = $this->numbering->allocate($doc);
    $stockResult = $this->stockLedger->post($doc->stockDraft($ctx));   // returns costs
    $this->postingService->post($doc->journalDraft($ctx->withCosts($stockResult)));
    $doc->markPosted();
    event(new DocumentPosted($doc));               // listeners are non-financial only
});
```

Note the ordering: **inventory posts first because it produces the cost figures the GL needs** (COGS). Anything in an event listener must be non-financial (PDF, email, cache bust) and queued — never let a mailer failure roll back a sale, and never let a financial write happen in a listener.

---

## 4. Invariants (these become automated tests)

These are the acceptance criteria for "the engine works". Each is a Pest test that runs against seeded random transaction sets.

| # | Invariant |
|---|---|
| I1 | For every journal: `SUM(debit) = SUM(credit)`, exactly, in minor units. |
| I2 | Trial balance for any date range: total debits = total credits. |
| I3 | `stock_balances.qty_on_hand` = `SUM(qty_in) - SUM(qty_out)` from `stock_movements`, per product+warehouse. |
| I4 | `stock_balances.value` = `SUM(layer remaining qty × layer cost)` (FIFO) or `qty × avg_cost` (WAC), within rounding tolerance of 0. |
| I5 | Inventory GL account balance = total stock valuation across warehouses (perpetual inventory tie-out). |
| I6 | AR control account balance = `SUM(customer outstanding)` from invoices/allocations. Same for AP. |
| I7 | Bank GL account balance = bank account book balance. |
| I8 | No `DRAFT` or `CANCELLED` document contributes any journal entry or stock movement. |
| I9 | Cancel-by-reversal leaves net zero GL and net zero stock effect for that document. |
| I10 | Posted document numbers per (company, branch, doc_type, fiscal year) are gapless and unique. |
| I11 | No document line may have `unit_price < resolved_min_price` without an approved `price_approvals` row. |
| I12 | Estimate→Invoice conversion preserves `unit_price`, `tax_rate_percent`, and discount exactly. |
| I13 | Every query in a tenant-scoped request emits `company_id` in its WHERE clause (asserted via query log in tests). |

---

## 5. Delivery plan — vertical slices, not horizontal layers

The brief's phase order (§54) builds foundation → inventory → sales → purchase → **accounting last**. I'd change one thing: **the accounting engine must exist before the first sale is posted**, otherwise Phase 3 writes stock and receivables in a way that Phase 5 has to unpick. Accounting moves early; the accounting *UI and reports* stay late.

### Phase 0 — Skeleton and rails
Laravel 12 install, strict DB config, Pest, PHPStan level 8 (or Larastan), Pint, `Money`/`Quantity` value objects, `BelongsToCompany`, audit log, feature gate, CI script (`composer verify` = pint + phpstan + pest).
**Done when:** `composer verify` is green and a tenant-scope architecture test passes.

### Phase 1 — Foundation
Auth, Company, Branch, Users, Roles/Permissions (granular strings per brief §4), Settings, Fiscal years + periods, Audit log UI, base layout/navigation shell.
**Done when:** two companies coexist with zero data bleed (proven by test), and period open/close blocks a dummy posting.

### Phase 2 — Accounting core (engine only, minimal UI)
Chart of accounts (hierarchical, seeded template per brief §20 + Nepal-flavoured variant), `journals`/`journal_entries`, `PostingService`, reversal, manual journal entry screen, Trial Balance, GL/account ledger.
**Done when:** I1, I2, I8, I9 pass. Trial balance from a hand-entered set of journals matches a spreadsheet.

### Phase 3 — Inventory core
Products (+categories, brands, units with conversions, tax mapping, price fields), Warehouses, `StockLedger`, `CostingMethod` (WAC), opening stock, adjustments, transfers (with in-transit), stock ledger + valuation reports.
**Done when:** I3, I4, I5 pass under a randomized 1000-movement soak test, including concurrent posts (parallel test asserting no negative stock and no lost update).

### Phase 4 — Purchases (stock must come in before it can go out)
Suppliers, direct purchase → stock + AP + input VAT, landed cost allocation, purchase returns, supplier payments, PO → GRN → invoice for larger customers (PO/GRN can be deferred inside this phase).
**Done when:** I6 (AP side) passes; purchasing 100 units at two different costs produces correct WAC.

### Phase 5 — Sales, Estimates, and the price-lock workflow (**the commercial core**)
Customers, price lists / customer types / qty breaks, Estimates with full status lifecycle, price snapshot + `price_approvals`, Estimate→Invoice conversion, invoice posting (AR + revenue + VAT + COGS), split payments, partial payments, sales returns, receipts and allocations, A4 + 80mm print.
**Done when:** I6 (AR), I11, I12 pass, and the flow *Estimate → lock price → invoice → part payment → return → settle* leaves a clean trial balance, clean stock, and a customer statement that ties to the GL.

### Phase 6 — POS billing screen
Keyboard-first Alpine cart (F2/F3/F4/F8/F9/Ctrl+S/Esc per brief §40), barcode input, cached product index, hold/recall bills, day-open/day-close and cash drawer session, thermal receipt.
**Done when:** a 20-line bill can be entered and posted in under 30 seconds with keyboard only, and the server rejects a tampered client total.

**← This is the first commercially sellable release. Everything below is expansion.**

### Phase 7 — Banking + Expenses
Bank accounts (1:1 GL mapping), deposit/withdraw/transfer/charges, undeposited funds & cheque clearing, expenses with categories and claimable VAT, CSV/XLSX statement import, reconciliation matching UI + reconciliation report.
**Done when:** I7 passes and a reconciliation closes with a documented adjusted-balance statement.

### Phase 8 — Reports and financial statements
P&L, Balance Sheet, Cash Flow (indirect), comparative + branch-dimension versions, VAT sales/purchase registers, AR/AP aging, sales/purchase/inventory analytics, profit by product/customer/salesperson, dead & slow-moving stock, year-end close.
**Done when:** Balance Sheet balances, P&L net profit equals the retained-earnings movement, and closing a year produces correct openings.

### Phase 9 — Commercial layer
Plans/subscription enforcement, `/api/v1` (Sanctum, resources mirroring the domain services), notifications, backup, import/export, FIFO costing option + recosting, offline billing queue, Nepali/BS/IRD localization if confirmed.

---

## 6. Risk register

| Risk | Impact | Mitigation |
|---|---|---|
| Concurrent posting corrupts stock or double-allocates numbers | Data loss, unsellable product | `FOR UPDATE` locks in deterministic order; sequence table; concurrency tests in CI (Phase 3) |
| Backdated receipts break FIFO COGS | Wrong profit | WAC default (D2); FIFO gated behind explicit recosting |
| Rounding drift makes journals unbalanced | Trial balance never balances | Integer-minor-unit arithmetic; explicit rounding account; I1 test |
| Subledger drift from GL | Endless support tickets | Subledger *is* the GL (D4) |
| Livewire POS too slow on cheap hardware | Product rejected by retail users | Client-side cart with server re-validation (D10) |
| Scope explosion (this brief is ~4 products) | Never ships | Phase 6 is the release gate; Phases 7–9 are post-revenue |
| Editing posted documents demanded by users | Audit trail destroyed | "Amend" = reverse + clone, with visible lineage (D6) |
| Multi-tenant leak | Catastrophic, commercially fatal | Composite unique indexes, global scope, `ExistsInCompany` rule, I13 |
| MariaDB 10.4 queue contention | Stuck jobs | Single worker or upgrade path documented (§0) |
| Deployment on shared XAMPP hosts | No queue worker, no Redis | Queue driver `sync` fallback for non-critical jobs + scheduler-driven `queue:work --once` |

---

## 7. Open questions (need your answers before Phase 1)

1. **Nepal localization** — your answer selected everything *and* "none". Confirm the D13 reading: AD storage, BS display optional, configurable fiscal year, tax as data, IRD columns reserved but sync deferred. Or do you have a live customer who needs IRD/CBMS real-time sync in V1? That single requirement changes the invoice posting path (materialized, print-count-controlled, no post-print edits at all).
2. **Costing method** — accept WAC-first (D2), or do you have a specific customer contractually needing FIFO in V1?
3. **First real customer profile** — a retail shop (POS-first, cash, single warehouse) or a trading/wholesale house (estimate → credit sale → collection, multi-warehouse)? This decides whether Phase 5 or Phase 6 comes first. The brief implies wholesale/trading (estimate price-lock focus), so I have ordered Phase 5 before Phase 6.
4. **Service businesses** — do V1 invoices need non-stock service lines (no COGS, no stock movement)? Cheap now, awkward later. My recommendation: yes, include a `product_type ∈ {goods, service}` from Phase 3.
5. **VAT scheme** — are invoice prices tax-inclusive, tax-exclusive, or per-company configurable? Inclusive pricing changes every line calculation and is common in Nepali retail. Recommendation: per-company setting, decided in Phase 3.
6. **Deployment target** — VPS with Nginx/PHP-FPM/Redis (assumed by the brief), or shared hosting/XAMPP on-premise per shop? This decides queues, backups, and whether offline mode matters more than it looks.

---

## 8. What I recommend we do next

Phase 0 + Phase 1, in that order, once questions 1–3 are answered. I would not write a line of sales code until `PostingService` and `StockLedger` exist with their invariant tests green — those two classes are the product, and everything else is a form over a table.
