# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Identity

**Project Name:** Aradhya Enterprises ERP

**Production Domain:** https://erp.aradhyaenterprise.com

**Purpose:** Standalone ERP and business management system for Aradhya Enterprises.

**Important:** This project is completely separate from LumoraOS.

---

## Commands

**Install:**
```bash
composer install
npm install
cp .env.example .env && php artisan key:generate
php artisan migrate
```

**Local dev:** This app runs under XAMPP Apache directly, not `php artisan serve`. `public/build` is a static compiled snapshot — after any Blade/CSS/JS change, run `npm run build`, or keep `npm run dev` running in the background while editing views. (Laravel's own `composer run dev` — concurrent server + queue listener + logs + Vite — also works if you're not relying on XAMPP.)

**Tests:**
```bash
php artisan test                                # full suite (composer test also works)
php artisan test --filter=TestName              # single test method/class
php artisan test tests/Feature/ProfileTest.php  # single file
```
Tests run against in-memory SQLite (`phpunit.xml`), independent of local dev's MySQL — don't assume identical behavior across the two for anything touching raw SQL or date handling (see the Sprint 7 date-range bug further down for a concrete example of this biting). Only Breeze's default scaffolding (`tests/Feature/Auth/*`, `ProfileTest`, `ExampleTest`) is committed; every sprint's real feature/permission verification below was done with temporary `Livewire::test()` suites that were discarded once they passed, per this project's stated convention. Don't assume feature coverage exists beyond what's actually in `tests/`.

**Lint/format:** `vendor/bin/pint` (Laravel Pint, default ruleset — no `pint.json` in this repo).

**Queue & scheduler:** Notifications (`app/Notifications/*`) are `ShouldQueue`; run `php artisan queue:work` locally to actually process them instead of leaving them queued. `bootstrap/app.php`'s `withSchedule()` runs `app:generate-business-alerts` (low/out-of-stock, expiring batches, overdue balances) and `spatie/laravel-backup`'s `backup:clean`/`backup:run` daily — run `php artisan schedule:work` to exercise them locally.

**Seeders:** `php artisan db:seed --class=RolePermissionSeeder` (roles/permissions), `--class=AdminUserSeeder` (Super Admin login from `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` in `.env`), `--class=DemoDataSeeder`.

**Backups:** `php artisan backup:run`, `php artisan backup:list` (spatie/laravel-backup; excludes `.env` and `storage/`).

**CI** (`.gitlab-ci.yml`): pushes on any branch run `composer install`, `npm ci`, `npm run build`, `php artisan test` against a fresh SQLite `.env`. Pushes to `master` additionally deploy over SSH/rsync to the production host (see `DEPLOYMENT.md`).

---

## Architecture

**Stack:** Laravel 13 + Livewire 4 (classic class+view components, not Volt) + Blade + Tailwind v3 + Alpine.js — Livewire's bundled Alpine instance only, never add a second `import Alpine from 'alpinejs'` or it double-initializes. Auth is Laravel Breeze's classic `blade` stack (plain controllers); everything past login is Livewire.

**One Livewire component per module, not Index+Form.** Each module under `app/Livewire/{Module}/` (Products, Categories, Suppliers, Customers, Warehouses, Users, Roles, Inventory, Purchases, Sales, Reports, Assistant, Search, Notifications, Settings) is typically a single `{Module}Index` component handling list + search + pagination + a create/edit modal + delete — there's no separate Form component unless the editor genuinely needs a full page. Purchases (`PurchaseOrderForm`/`PurchaseOrderShow`) and Sales (`SaleForm`/`SaleShow`) are the deliberate exceptions, because a dynamic repeating line-item editor doesn't fit a small modal.

**All writes go through a `Services` layer** (`app/Services/*.php`), never directly from a Livewire component or controller: `InventoryService` (the sole write path for `stock_balances`/`stock_movements` — `recordMovement()` is the core primitive every stock-affecting action funnels through), `PurchaseService`, `SalesService`, `WarehouseAccessService`, `NotificationService`, plus `Services/Import/*` (Excel import pipeline) and `Services/Ai/*` (the business assistant). A new stock-affecting feature should be a thin wrapper around `InventoryService::recordMovement()`, not a new write path.

**Inventory is ledger-based, not a mutable stock field.** `stock_movements` rows are immutable (the model throws on `updating`/`deleting`) and `stock_balances.average_cost` is real, stateful weighted-average-cost state — recalculated only when a movement has `quantity > 0 AND unit_cost !== null`. A positive adjustment/return with no cost given deliberately leaves the average untouched, and every negative movement (sale, transfer-out, cancellation) leaves it untouched too, per the "historical cost must not change on consumption" rule below.

**Authorization is `spatie/laravel-permission`, enforced in layers**: `permission:*` route middleware (`routes/web.php`), `abort_unless(auth()->user()->can(...))`/`@can` inside components, and a `Gate::before` Super Admin bypass. `Dashboard` is gated per-widget rather than per-page, since it's the mandatory post-login landing page every role must be able to open. Warehouse-level restriction is a separate, cross-cutting concern layered on top via `WarehouseAccessService::accessibleWarehouseIds()`/`canAccess()` — it must be called explicitly in any component that lists or writes warehouse-scoped data; it is not automatic, and several modules were found missing it during the Sprint 10 security audit (see below).

**The AI business assistant (`/assistant`, `app/Services/Ai/`) is a tool-use loop, not chat-with-raw-DB-access.** `AssistantService` drives up to 5 rounds of tool calls against `AnthropicClient` (a plain `Http`-facade wrapper around the Anthropic Messages API — no SDK dependency) using `BusinessToolService`'s 11 fixed JSON-schema tools. Each tool does its own permission/warehouse check and reuses the *same* query a human-facing page already uses (e.g. `getInventory` reuses `InventoryReport`'s query), so the assistant's numbers cannot drift from what's shown on screen. Configured via `config/ai.php` / `ANTHROPIC_API_KEY`; the app is fully functional with no key set (`AnthropicClient::isConfigured()` gates a degraded UI state instead of throwing).

**Excel import (`app/Services/Import/`) never touches inventory directly.** `SevenDaysNaturalParser` (positional column mapping, not header-text matching — the real supplier file's header has a typo) → `ProductMatcher` (exact → fuzzy `similar_text()` → new; fuzzy matches are suggestions only, never auto-merged) → staged into `import_batches`/`import_batch_rows` tables → user reviews/edits the preview → `ImportService::confirm()` reads only from those staged DB rows and creates a normal `PurchaseOrder` through `PurchaseService`, unmodified. A second supplier format should follow this same stage-then-confirm shape rather than writing directly from the upload handler.

**Everything below this point is this project's running memory/decision log**, written and updated sprint-by-sprint by whichever agent built each feature. It records *why* things are shaped the way they are — including several real bugs that were found and fixed along the way — and should be read before making a structural change to a module it covers, then updated in the same style (a dated, narrative entry, not a rewrite of history) after any change significant enough that a future session would need the same context.

---

## Core Product Goal

Build a modern, simple, fast and intelligent ERP covering:

**Purchase → Inventory → Sales → Payments → Profit → Reports**

The system should be easier to use than a traditional ERP and should provide intelligent search, complete business history and optional AI-powered business insights.

---

## Technology

- Laravel
- PHP 8.3+
- MySQL
- Blade
- Livewire
- Tailwind CSS
- Alpine.js
- Redis where required
- Laravel Excel / PhpSpreadsheet
- Queues for heavy/background work
- HTTPS in production

---

## Core Architecture Principles

1. Follow Laravel conventions.
2. Keep controllers thin.
3. Put business logic in Services.
4. Use Form Requests for validation.
5. Use Policies/Permissions for authorization.
6. Use database transactions for inventory and financial operations.
7. Preserve historical transaction accuracy.
8. Never silently delete historical stock transactions.
9. Avoid unnecessary dependencies.
10. Prefer maintainability over over-engineering.
11. Write tests for important workflows.
12. Do not make destructive changes without review.

---

## Inventory Principle

Inventory is **ledger based**.

Every stock change must create a stock movement.

Examples:

- Purchase: positive quantity
- Sale: negative quantity
- Customer return: positive quantity
- Supplier return: negative quantity
- Adjustment: positive/negative
- Transfer: negative from source, positive to destination
- Opening stock: positive

Never treat a manually editable stock field as the authoritative source of truth.

Historical inventory movements must remain auditable.

---

## Purchase Workflow

The standard purchase workflow is:

**Supplier Order → Purchase Order → Goods Receipt → Stock Increase → Supplier Payable**

An order and a receipt are different concepts.

Partial receiving must be supported.

Example:

```text
Ordered: 100
Received: 80
Pending: 20
```

Only received quantity should increase inventory.

---

## Excel Import

The first real-world supplier format is the **7 Days Natural** Excel order.

Current file:

`rate list-7 days-pdf00.xlsx`

Known columns:

- S.NO
- PRODUCT NAME
- UNIT
- Box Qty
- MRP
- SS-with gst
- Dist-with gst
- Retailer-with gst
- End customer
- Order
- Value

Initial mapping:

- Order → purchase quantity
- SS-with gst → purchase cost
- MRP → MRP
- Dist-with gst → distributor price
- Retailer-with gst → retailer price
- End customer → customer selling price

The Excel Value column should be validated against:

`quantity × purchase cost`

Do not blindly trust the Excel total.

---

## Excel Import Safety Rule

Uploading an Excel file must **not** immediately change inventory.

Required workflow:

```text
Upload
↓
Validate
↓
Product Matching
↓
Preview
↓
User Confirmation
↓
Purchase Created
↓
Goods Received
↓
Inventory Updated
```

The preview must identify:

- Existing products
- New products
- Possible duplicates
- Invalid rows
- Quantity
- Purchase cost
- Calculated value
- Existing stock
- Projected stock

The user must be able to correct matching and quantities before confirmation.

---

## Product Matching

Matching priority:

1. SKU
2. Barcode
3. Supplier product code
4. Normalized product name
5. Fuzzy name matching
6. Manual confirmation

Normalize:

- case
- spaces
- hyphens
- harmless leading symbols

Example:

`-VITAMIN C CREAM 100GM`

and

`Vitamin C Cream 100Gm`

should be recognized as a likely match.

Never automatically merge low-confidence matches.

---

## User Management

User management is a first-class module.

Support:

- Users
- Roles
- Permissions
- Active/inactive users
- Password management
- Login activity
- Audit logs
- Warehouse-level access

Initial roles:

- Super Admin
- Admin
- Manager
- Purchase Manager
- Sales Manager
- Inventory Manager
- Sales User
- Inventory User
- Accounts User

Permissions should be granular.

Examples:

```text
products.view
products.create
products.update
products.delete

purchases.view
purchases.create
purchases.update
purchases.receive
purchases.cancel

sales.view
sales.create
sales.update
sales.cancel
sales.return

inventory.view
inventory.adjust
inventory.transfer

reports.view
reports.export

users.manage
roles.manage
```

AI must respect exactly the same permissions as the logged-in user.

---

## Warehouse Access

Design the authorization system to support warehouse-level permissions.

Example:

A user assigned to Warehouse A must not automatically see or modify Warehouse B inventory.

This should be implemented through authorization policies/services, not scattered hardcoded checks.

---

## Main Modules

### Products

Maintain:

- SKU
- Barcode
- Product name
- Category
- Brand
- Unit
- Box quantity
- MRP
- Purchase price
- Distributor price
- Retailer price
- Selling price
- GST rate
- Reorder level
- Minimum stock
- Maximum stock
- Batch tracking
- Expiry tracking
- Active status
- Notes

### Suppliers

Maintain supplier information, purchase history, balances and payments.

### Customers

Maintain customer information, sales history, balances and payments.

### Purchases

- Purchase orders
- Purchase items
- Goods receipt
- Partial receipt
- Supplier balances
- Supplier returns

### Sales

- Sales
- Sales items
- Invoices
- Payments
- Customer returns
- Customer balances

### Inventory

- Stock balances
- Stock movements
- Adjustments
- Transfers
- Batches
- Expiry

### Reports

- Stock
- Purchases
- Sales
- Profit
- Inventory valuation
- Product profitability
- Fast-moving products
- Slow-moving products
- Dead stock
- Low stock
- Expiring stock
- Supplier purchases
- Customer sales
- Receivables
- Payables

---

## Inventory Valuation

Initially use **Weighted Average Cost**.

Formula:

```text
new_average_cost =
(
 existing_stock × existing_average_cost
 +
 received_quantity × received_unit_cost
)
/
(
 existing_stock + received_quantity
)
```

Historical sales must retain the unit cost used at the time of sale.

Changing the product master purchase price must never alter historical transactions.

---

## Sales Principle

When a confirmed sale occurs:

```text
Stock decreases
↓
COGS calculated
↓
Revenue recorded
↓
Gross profit calculated
```

Default policy:

**Negative stock is not allowed.**

---

## GST Invoicing

Added 2026-08-26. Replaces manual rate-typing on sales with tier-driven, GST-correct invoicing.

### Price tiers

The supplier rate list quotes four tiers and **every one is GST-inclusive**:
`SS-with gst` / `Dist-with gst` / `Retailer-with gst` / `End customer`. The importer stores
these verbatim into `products.distributor_price`, `retailer_price`, `selling_price`.

`SaleItem.unit_price` is **tax-exclusive** — tax is added on top. So a tier price must be
divided by `(1 + gst_rate)` before it reaches a sale line. `PricingService` owns that
conversion; the `prices_include_gst` setting turns it off if prices are ever stored
exclusive instead.

`Customer.type` picks the tier: `distributor`/`wholesale` → distributor price,
`retailer` → retailer price, anything else → selling price. Falls through to the next
non-zero tier so a product missing its distributor price never invoices at zero.

### Tax computation

`GstService` rules:

1. **Place of supply decides the split.** `Setting company_state_code` vs
   `Customer.state_code` — same code is CGST + SGST at half the rate each, different is
   IGST at the full rate. Missing either code falls back to intra-state, since charging
   IGST wrongly is the costlier error.
2. **Tax rounds once per rate bucket**, never per line. Line-level rounding then summing
   drifts from what the buyer's accounting produces. `apportion()` pushes each bucket's
   tax back onto its lines proportionally and gives the residue to the largest line, so
   `sale_items` always add up to the invoice totals.
3. **Round off** to the whole rupee, stored on `sales.round_off`.

GST rates are per product (`products.gst_rate`) — shampoos, gummies and soaps are 5%,
most cosmetics 18%. Never assume a flat rate across an invoice.

### Invoice numbering

`InvoiceNumberService` issues `PREFIX/YY-YY/NNN` per Indian financial year (April–March),
incrementing `invoice_series` under a row lock. This replaced deriving the number from
`sales.id` after insert, which wrote the literal `'PENDING'` into a UNIQUE column and
collided whenever two sales were created at once.

### Screens

- `/sales/quick` (`QuickInvoice`) — quantity-first: pick customer and warehouse, then
  enter quantities against a stock-aware product list. Rates, GST and the split are all
  derived; nothing is typed. Blocks saving while any line exceeds stock.
- `/sales/create` (`SaleForm`) — unchanged manual form, but now prefills the **customer's
  tier** rather than always the end-customer price, and reprices when the customer changes.
- `/sales/{sale}/invoice` — printable GST tax invoice with rate-wise tax summary, amount
  in words, bank block and declaration, all driven by settings.

### Not done yet

- `products.hsn_code` column exists but is unpopulated; the invoice prints "—" until it is
  filled. HSN is mandatory on a GST invoice above the turnover threshold.
- No credit-note document for `SaleReturn` (the record exists, the print view does not).
- E-invoice / IRN generation is not implemented.

---

## Batch and Expiry

The system should support batch and expiry tracking because products may include cosmetics and supplements.

Support:

- Batch number
- Manufacturing date
- Expiry date
- Purchase cost
- Quantity
- Warehouse

Alerts:

- Expired
- Expiring within 30 days
- Expiring within 60 days
- Expiring within 90 days

---

## Global Intelligent Search

A global search should be available throughout the authenticated application.

Users should be able to search naturally:

- `Vitamin C Cream`
- `orders from 7 Days Natural`
- `what did we purchase last month?`
- `show all sales of Vitamin C Cream`
- `which products are low in stock?`
- `what did we buy from 7 Days Natural in August?`
- `show unpaid customers`
- `which products are selling fastest?`
- `show purchases above ₹20,000`
- `when did we last purchase De-Tan Cream?`
- `show complete history of Vitamin C Cream`

Search should return actual database-backed results.

---

## Entity History

Major entities should have a timeline/history:

- Product
- Supplier
- Customer
- Purchase
- Sale
- Payment
- Stock
- User
- Batch

A product history should show:

- Purchases
- Sales
- Returns
- Adjustments
- Price changes
- Stock changes
- Supplier
- Customers
- Relevant audit activity

History must come from actual transactions/audit logs.

---

## AI Principles

AI is an **optional intelligence layer**.

The ERP must remain fully functional when AI is unavailable.

AI should improve:

- Search
- Business questions
- Analysis
- Reorder recommendations
- Product insights
- Supplier insights
- Customer insights
- Profit analysis
- Inventory risk analysis

AI must never become the source of truth for financial or inventory data.

---

## AI Safety / Architecture

Never give an LLM unrestricted database access.

Use controlled application tools/services:

```text
searchProducts()
searchPurchases()
searchSales()
getInventory()
getProductHistory()
getSupplierHistory()
getCustomerHistory()
getProfitReport()
getLowStockProducts()
getExpiringProducts()
getOutstandingBalances()
```

Architecture:

```text
User
↓
AI Query Parser
↓
Intent / Entity Detection
↓
Authorized Application Tools
↓
Database / Reporting Services
↓
Structured Result
↓
AI Explanation
↓
User
```

AI must never execute arbitrary model-generated SQL.

AI must obey user and warehouse permissions.

AI responses involving business data must be based on real database results.

AI must never invent financial numbers.

---

## AI Audit

Log AI usage where appropriate:

- user
- query
- detected intent
- tools called
- filters
- result metadata
- timestamp
- duration
- success/failure

Do not store sensitive information unnecessarily.

---

## Intelligent UX

The application should feel like a modern SaaS product, not a traditional complicated ERP.

UX principles:

- Simple
- Fast
- Clear
- Responsive
- Search-first
- Minimal clicks
- Good empty states
- Good loading states
- Good validation messages
- Keyboard friendly

Global search should support `/` and/or `Ctrl/Cmd + K`.

---

## Command Palette

Common actions should be available from a command palette:

- New Sale
- New Purchase
- Add Product
- Add Customer
- Add Supplier
- Import Excel
- Receive Stock
- Stock Adjustment
- Low Stock
- Expiring Products
- Reports
- Settings

---

## Notifications

In-app notifications may include:

- Low stock
- Out of stock
- Expiry approaching
- Customer payment overdue
- Supplier payment due
- Import completed
- Import errors
- Purchase received
- Important price changes

Notifications should link directly to the relevant record.

---

## Dashboard

Dashboard should eventually show:

- Today's sales
- Today's purchases
- Today's gross profit
- Inventory value
- Total products
- Total units
- Low stock
- Out of stock
- Fast-moving products
- Slow-moving products
- Customer receivables
- Supplier payables
- Recent purchases
- Recent sales
- Intelligent business insights

Insights must be clickable and backed by actual records.

---

## Development Roadmap

### Sprint 1
Foundation, authentication, users, roles, permissions, products, categories, suppliers, customers, warehouses and settings.

### Sprint 2
Inventory ledger, balances, stock movements, adjustments and weighted average valuation.

### Sprint 3
Purchases, purchase orders, receiving, partial receiving and supplier balances.

### Sprint 4
7 Days Natural Excel import, product matching, validation, preview, duplicate detection and confirmation.

### Sprint 5
Sales, invoices, payments, stock deduction, COGS and profit.

### Sprint 6
Returns, batches, expiry and barcode.

### Sprint 7
Reports, dashboards and Excel exports.

### Sprint 8
Global intelligent search, entity history, command palette and notifications.

### Sprint 9
AI search, controlled business tools and permission-aware business assistant.

### Sprint 10
Security, performance, backups, deployment and production hardening.

---

## Sprint Status (updated 2026-08-18)

**Sprint 1: COMPLETE.** Foundation, authentication, users, roles, permissions, products, categories,
suppliers, customers, warehouses, settings — all built and verified end-to-end (CRUD + permission
gating tested in-browser for every module, including a restricted-role login confirming sidebar
visibility and direct-URL 403 enforcement both work).

**Sprint 2: COMPLETE.** Inventory ledger, stock balances, movements, adjustments, transfers, and
weighted-average valuation — built and verified end-to-end, including the actual weighted-average
math (blended cost on receipts, unchanged cost on reductions, transfer cost carry-over), the
negative-stock guard, the one-time opening-stock guard, and warehouse-scoped visibility (a user
restricted to one warehouse sees only that warehouse in every dropdown/filter, and a role without
`inventory.transfer` never sees the Transfer button).

**Sprint 3: COMPLETE.** Purchase orders (dynamic line items, live tax/total computation), goods
receipt with partial receiving, PO status lifecycle (open → partially_received → received /
cancelled), and supplier balances with payment recording — built and verified end-to-end. The
receiving flow calls `InventoryService::recordMovement()` with `type: 'purchase'` exactly as Sprint 2
was designed for, confirming that design choice. Verified with real numbers: a 2-line PO
(100 @ ₹15 + 40 @ ₹25, mixed 18%/5% GST) computed to the exact expected total (₹2,820), a partial
receipt of 60/100 on one line correctly left the PO `partially_received` and put exactly 60 units in
inventory at the right cost, receiving the remainder flipped it to `received` and hid the Receive
button, and supplier balance tracked correctly through a payment (₹2,500 owed → ₹1,500 after a
₹1,000 payment), matching everywhere it's shown (supplier detail page and the supplier list column).

**Sprint 4: COMPLETE.** 7 Days Natural Excel import — upload, positional parsing, product matching
(exact/fuzzy/new), an editable preview, and confirmation into a real Purchase Order via Sprint 3's
`PurchaseService`, unmodified. Verified against the **actual** `rate list-7 days-pdf00.xlsx` file
(117 real product rows): parsed correctly, 14 zero-order catalog rows auto-excluded, a manual
in-preview correction (skipping one row) was correctly honored at confirm time (102 line items, not
103), the resulting PO's total exactly matched the source file's own SUMPRODUCT total independently,
and inventory stayed untouched after PO creation — confirming the Safety Rule end to end.

**Sprint 5: COMPLETE.** Sales, invoices, payments, stock deduction, COGS, and profit — built and
verified. Unlike Purchases (two-step order→receipt), a sale is a single immediate transaction: no
`SaleOrder`/`Receipt` split, no partial-fulfillment status chain — `Sale` *is* the invoice
(`invoice_number` assigned post-insert, same pattern as `po_number`), and its status is just
`completed`/`cancelled`. Verified via `tinker` with real numbers (5 units @ ₹80, 18% tax, COGS
captured from a ₹50 average cost): subtotal ₹400, tax ₹72, total ₹472, cost_total ₹250, profit_total
₹150 — all matched exactly; stock correctly dropped 20→15 with average cost unchanged (per the
Sprint 2 rule for negative movements); an oversell (999 units against 15 in stock) was rejected by
`InventoryService`'s existing negative-stock guard with the whole transaction rolled back atomically
(zero orphan `Sale` row); cancelling restored stock to 20 via a `sale_cancel` movement and correctly
zeroed the customer's `soldValue()`. Browser JS was blocked in this sandbox's preview browser
(`ERR_BLOCKED_BY_CLIENT` on `livewire.js`/`app.js` — an environment limitation, not an app bug;
server-rendered HTML, routing, and permission-gated rendering all confirmed correct up to that
point), so the Livewire component layer itself (`SaleForm`'s product-price prefill and live
subtotal/tax/COGS/profit computeds, `SaleForm::save()`, `SaleShow::cancel()`, `CustomerShow`'s
payment form) was instead verified with a temporary `Livewire::test()`-based PHPUnit run (22
assertions, since discarded — this project keeps no committed Feature tests, matching Sprints 1-4's
tinker/browser-only convention) plus a second temporary run confirming permission gating (a role
with `sales.view`+`sales.create` but not `sales.cancel` sees the sale but not the Cancel button; a
role with no permissions gets a 403 on `/sales`).

**Sprint 6: COMPLETE.** Customer/supplier returns, batch tracking with expiry alerts, and
keyboard-wedge barcode quick-add — built and verified. Batches are additive to Sprint 2's costing
model, not a replacement: `stock_balances.average_cost` (product+warehouse) stays the sole
costing source of truth; `product_batches` is a parallel ledger for quantity/expiry that also
happens to carry its own weighted-average `unit_cost`, which `SalesService::createSale()` now
reads for COGS *instead of* the warehouse-blended average whenever a specific batch is selected —
more precise, since two batches of the same product can carry different costs.
`InventoryService::recordMovement()` gained one new optional `?ProductBatch $batch` param (no new
write path), locking and updating the batch's own quantity in the same transaction as the
warehouse balance, with its own independent negative-stock guard.

Verified via `tinker` (22 checks, all passing) with a product split across two batches (20 @ ₹50
expiring in 10 days, 30 @ ₹55 expiring in 90 days — warehouse blended average correctly came to
₹53): selling 15 from the near-expiry batch depleted *that batch* to 5 while the other batch's 30
stayed untouched, and the `SaleItem`'s COGS came from the batch's own ₹50, not the ₹53 blend;
oversold the near-expiry batch by 10 while the warehouse total (35) would have allowed it, and the
batch-level guard correctly rejected it anyway; a customer return of 5 restored the batch to 10 and
correctly netted `Customer::soldValue()`; a supplier return against the other batch correctly
depleted it and netted `Supplier::receivedValue()`; `ProductBatch::expired()`/`expiringWithin(30)`
scopes correctly bucketed batches at various expiry dates. Same environment limitation as Sprint 5
(sandbox browser blocks `livewire.js`/`app.js`) meant the UI layer was verified via a temporary
`Livewire::test()` suite instead (6 isolated test methods, 33 assertions, since discarded) covering
barcode quick-add on both `SaleForm`/`PurchaseOrderForm`, the batch `<select>` appearing only for
`track_batches` products, both return flows, `BatchIndex`'s expiry filters, and permission gating
(`purchases.return` hides the "Return to Supplier" button when absent).

That verification process caught one real bug before it shipped: `SaleForm::rules()` had no
validation rule for `items.*.batch_id`, so Livewire's `$this->validate()` silently stripped it from
the validated array before it ever reached `SalesService::createSale()` — the service then
correctly threw `SaleException` for the "missing" batch, but since nothing in the UI surfaced that
session-flashed error clearly during manual testing, this would have been a confusing silent
failure for a real user trying to sell a batch-tracked product. Fixed by adding
`'items.*.batch_id' => ['nullable', 'exists:product_batches,id']` to `SaleForm::rules()` — a
reminder that Livewire's `validate()` drops any array key without an explicit rule, the same class
of gotcha as Sprint 3's `#[Fillable]` mass-assignment bug (silent data loss with no exception).

**Sprint 7: COMPLETE.** Reports, a real dashboard, and Excel exports — built and verified. The
spec lists ~15 report bullets; rather than 15 near-duplicate skinny pages, they were consolidated
by underlying query into 6 report pages (Sales, Purchases, Profit, Inventory & Valuation,
Product Velocity covering fast/slow/dead stock as one sortable page, Receivables & Payables) plus
reusing Sprint 6's `BatchIndex` as the Expiring Stock report — every spec bullet covered, none of
the "15 separate pages" sprawl. Dashboard replaced Breeze's untouched placeholder with a real
`App\Livewire\Dashboard`, converting `/dashboard` from the app's last non-Livewire closure route
to match every other route. "Intelligent business insights" was explicitly left out (that's
Sprint 9's AI layer); every other spec widget is there, each tile linking to its report so
"insights must be clickable" is satisfied by drill-down rather than summarization.

Verified via `tinker` with a seeded dataset (a fast-moving product with two sales — one today, one
10 days back —, a never-sold product, an under-reorder-level product, and a fully depleted
product): sales-report totals matched exactly (revenue 410, cost 210, profit 200 across 3 sales),
low-stock/out-of-stock detection correctly isolated exactly the right SKUs, inventory valuation
summed to the exact expected 1,824, and product-velocity ordering came back fast→dead exactly as
expected (15, 5, 0, 0). Same sandbox browser limitation as Sprints 5-6, so the Livewire layer
(filters actually narrowing results, `export()` returning a real `BinaryFileResponse`, dashboard
tiles showing the right seeded numbers, permission gating) was verified with a temporary
`Livewire::test()` run (10 test methods, 28 assertions, since discarded).

That process caught a real bug: every date-range report used `whereBetween('date_col', [$from,
$to])`. Under the test suite's SQLite backend this silently excluded *same-day* records, because
Eloquent's `date` cast writes a full `00:00:00`-suffixed string and SQLite (no native DATE type)
stores it verbatim — `'2026-08-19 00:00:00'` sorts *after* the plain bound `'2026-08-19'` in a
lexicographic TEXT comparison, so "today" silently vanished from every report's own day. Checked
directly against the real schema (`SHOW COLUMNS FROM sales`) and confirmed `sale_date`/
`order_date` are genuine MySQL `date` columns there — MySQL enforces true date-only storage
regardless of what string is inserted, so **this specific bug would not have produced wrong
report data in the actual app** (dev or production, both MySQL). Fixed anyway to
`whereDate(col, '>=', $from)->whereDate(col, '<=', $to)` in every report — strictly more portable
and correct, and removes a landmine that would bite for real the moment any date column ever did
carry a time component, or if the test backend and production backend diverge further.

**Sprint 8: COMPLETE.** Global search, entity history, command palette, and in-app notifications —
built and verified. Search and the command palette are one `Ctrl/Cmd+K` (and `/`) surface, not two:
typing searches real records (Products/Customers/Suppliers/Sales/Purchases, each result group
gated by its own `.view` permission), an empty query shows the 12 spec'd quick-actions (each
gated by its `.create`/relevant permission). The natural-language examples in the Global Search
spec section ("what did we purchase last month?") were left for Sprint 9's AI query parser, which
the spec itself assigns them to — this sprint built the deterministic keyword-matching half.

Entity History leaned on what already existed rather than retrofitting audit logging everywhere:
`ProductShow` (new — Product had no Show page before this sprint) assembles its timeline straight
from `StockMovement` (one ledger query covers purchases/sales/returns/adjustments/transfers/
opening — no risk of the same event appearing twice under two different labels) plus a new,
deliberately narrow `ActivityLog` for the one thing no existing table tracks: price-field changes.
`CustomerShow`/`SupplierShow` had their two disconnected lists (sales-or-purchases, payments)
replaced with one merged, date-sorted timeline — replaced, not appended to, since a second list
bolted next to the first two isn't what "a timeline" asks for. `UserShow` (new) covers Sprint 1's
never-built "Login activity" via the same `ActivityLog`, wired through a `Login` event listener
that Laravel auto-discovers with zero registration (confirmed via `php artisan event:list` — this
Laravel version has no `Illuminate\Events\Attributes\AsEventListener`, unlike some other Laravel
versions; auto-discovery here works purely off the `handle(Login $event)` type-hint).

Notifications use Laravel's stock `database` channel (`notifications` table added via the
framework's own unmodified migration stub) — 9 classes, one per spec bullet, each `toArray()`
returning `['message', 'url', 'subject_id']` so the bell dropdown never branches per type.
Event-driven ones (Import completed/errors, Purchase received, Important price change >10%) fire
inline at the point of the action. State-based ones (low/out of stock, expiring batches, overdue
receivables/payables) come from a new `app:generate-business-alerts` command, scheduled daily via
`bootstrap/app.php`'s `withSchedule()` (a first for this app — actually running it under cron in
production is Sprint 10 territory), deduped per user+notification-type+subject so re-running it
never re-notifies for something still outstanding and unread.

Verified via `tinker`: a product's purchase price bumped 5% logged an `ActivityLog` entry but did
not notify (below the 10% "important" threshold), a 23.8% bump did both; a synthetic `Login` event
created exactly one login entry; `app:generate-business-alerts` against a seeded low-stock,
out-of-stock, expiring-batch, and two 40-day-old-unpaid-balance scenario created exactly the right
5 notifications, and — after the dedup logic caught a real bug (see below) — running it again
immediately created zero new ones. Same sandbox browser limitation as Sprints 5-7, so the UI layer
(palette search/permission-gating, `ProductShow`/`UserShow`/timeline rendering, notification
bell unread-count/mark-as-read, the three new `?action=`/`?stockFilter=` deep-link query params)
was verified with a temporary `Livewire::test()` run (8 methods, 27 assertions, since discarded).

That process caught a real bug in the overdue-receivables/payables heuristic:
`now()->diffInDays($pastDate)` returns a **signed, negative** value for a past date (Carbon does
not default to absolute value) — so `$diff < 30` was true for *every* past date regardless of how
old, and the command silently never fired a single overdue notification. Caught because the tinker
check asserted an actual notification count instead of just "the command ran without error."
Fixed with `diffInDays($date, absolute: true)`.

**Sprint 9: COMPLETE (backend + UI fully verified; live LLM round-trip deferred — see below).**
AI search, the 11 spec'd "controlled business tools," and a permission-aware business assistant —
built behind a provider-agnostic `config/ai.php`. Discussed with the user first, since this is the
first sprint needing a real external API/credentials, not something to silently pick: **Claude/
Anthropic**, no Composer SDK (Laravel's `Http` facade covers the plain-JSON Messages API in full,
same "avoid unnecessary dependencies" call as Sprint 6's barcode decision), and **no API key yet**
— the user will add `ANTHROPIC_API_KEY` to `.env` themselves.

The spec's pipeline — *User → AI Query Parser → Intent/Entity Detection → Authorized Application
Tools → DB/Reporting Services → Structured Result → AI Explanation → User* — turned out to already
be exactly what Claude's native tool-use does; nothing needed hand-building for the "parser"/
"intent detection" stages. `App\Services\Ai\AssistantService` owns that loop (capped at 5 tool
rounds) against `App\Services\Ai\AnthropicClient` (the thin HTTP wrapper) and
`App\Services\Ai\BusinessToolService` (the 11 tools, `new BusinessToolService($user)` — explicit
per-user construction rather than reading `auth()->user()` internally, so the permission boundary
is testable in isolation). "Never give an LLM unrestricted database access" / "must never execute
arbitrary model-generated SQL" is enforced by construction, not policy: the model only ever emits
`{name, input}` against 11 fixed JSON schemas, never SQL or a connection.

Reuse, not reimplementation, is what makes "AI responses must be based on real database results" /
"must never invent financial numbers" actually hold: `searchProducts`/`searchPurchases`/
`searchSales` mirror Sprint 8's `CommandPalette` queries, `getInventory`/`getLowStockProducts` reuse
`InventoryReport`'s exact query, `getExpiringProducts` reuses Sprint 6's
`ProductBatch::expiringWithin()`, `getProfitReport` reuses `ProfitReport`'s query, and
`getOutstandingBalances` reuses `Customer`/`Supplier::balance()`. `getProductHistory`/
`getSupplierHistory`/`getCustomerHistory` needed a small refactor first: `ProductShow`'s
`StockMovement` timeline and `CustomerShow`/`SupplierShow`'s merged-timeline builder (both Sprint 8)
were pulled out of the Livewire components' `render()` methods into real relations/methods —
`Product::stockMovements()`/`activityLogs()` (new `HasMany`/`MorphMany`), `User::activityLogs()`
(same), `Customer::activityTimeline()`/`Supplier::activityTimeline()` — so the tool layer and the
page a human looks at literally run the same query, not two paths that could quietly drift apart.

AI Audit logs one row per question to a new `ai_queries` table (query, first-tool-called as a
lightweight "intent," tools+args actually invoked, the model's final text, success, duration) —
deliberately not the raw tool *result* payloads, per "do not store sensitive information
unnecessarily": those are already fully audited by the tables the tools query. UI is a new,
isolated `/assistant` page (conversation held in the Livewire component's in-memory state only, not
persisted across reloads) rather than folded into Sprint 8's `CommandPalette` — an LLM round-trip
is 1-3+ seconds against the palette's instant keystroke-debounced search, and merging them would
make the palette feel broken. The palette gets one new "Ask AI" quick-action linking to it instead.
New `ai.use` permission assigned to every role (the real data boundary is each tool's own
permission check, not who can open the page).

**Verified now, via `tinker` (18 checks) and a temporary `Livewire::test()` run (5 methods, 18
assertions, both against a seeded dataset, both since discarded):** all 11 tools return correct,
exact numbers (a supplier's ₹1,000 payable only appeared *after* actually receiving the goods —
`Supplier::balance()` is receipt-based, not order-based, exactly as Sprint 3 designed it, and the
first test run correctly caught my own test script assuming otherwise); every tool refuses cleanly
(a structured `{"error": ...}` the model can explain, not an exception) for a user lacking the
relevant permission; `getInventory`/`getLowStockProducts` correctly respect `WarehouseAccessService`
scoping; with no API key configured, `AssistantChat` renders a clear "not configured" state, `ask()`
returns gracefully instead of throwing, and the attempt still gets logged — the rest of the app is
provably unaffected by AI being unavailable. **Deferred, needs a real key** (none exists yet, by
design — see above): the actual model round-trip — does Claude pick sensible tools for real
questions, does the final answer read as properly grounded. Re-run this the moment a key is added;
nothing about the architecture should need to change to do so.

That verification process caught one real bug: `AiQueryLog`'s implicit table name (Eloquent's
default pluralization gives `ai_query_logs`) didn't match what the migration actually created
(`ai_queries`) — every write would have failed with "table not found" the first time anything tried
to log, silently defeating the whole AI Audit requirement. Caught immediately by the first
`tinker` run attempting to clean up test data, before it ever reached a real user. Fixed with an
explicit `protected $table = 'ai_queries'`.

**Sprint 10: COMPLETE — the last sprint on the roadmap.** Security, performance, backups, and
deployment artifacts. The spec gives this one line with zero elaboration (confirmed via a fresh
full-file read — no dedicated Security/Performance/Backup sections exist at all, only a
`## Deployment` section with an architecture diagram and two rules: never expose MySQL publicly,
use env vars for prod credentials). This app has only ever run locally (XAMPP, no live server), so
— discussed with the user first, same as Sprint 9's provider decision — scope was code-level
hardening and deployment *artifacts*, not an actual deployment (no real server/cloud credentials
exist to deploy against).

**The most consequential part of this sprint wasn't in the original plan.** A security audit
(delegated to an Explore agent reading all 33 Livewire components against `routes/web.php` and
`RolePermissionSeeder`) was scoped expecting "few or no findings, cheap insurance" — instead it
found real, exploitable gaps:

- **`Dashboard` had zero permission check at all** — any authenticated user, regardless of role,
  could see company-wide revenue, profit, inventory value, and receivables/payables. Fixed by
  making every widget conditionally render per the viewer's *own* permissions (`sales.view` for
  the sales tiles, `inventory.view` for stock tiles, etc.) rather than gating the whole page behind
  one permission — gating the page would have 403'd any role without `reports.view` (Sales User,
  Inventory User) on the mandatory post-login landing page, breaking login for those roles
  entirely. The same per-widget-permission pattern `CommandPalette` (Sprint 8) already used for
  search result groups, applied to a dashboard instead of a search box.
- **Warehouse-restricted users could act outside their assigned warehouse.** `SaleForm`/
  `PurchaseOrderForm`/`ImportWizard` let a client submit *any* warehouse id with no
  `WarehouseAccessService::canAccess()` check — a tampered request could create a sale/PO/import
  against a warehouse the user isn't assigned to. `SalesReport`/`PurchasesReport`/`ProfitReport`
  had no `accessibleWarehouseIds()` scoping at all (unlike their siblings `InventoryReport`/
  `ProductVelocityReport`, which were already correct) — a restricted user's revenue/profit
  numbers silently included every warehouse company-wide. `ProductShow`'s stock-by-warehouse
  section and `SaleShow`/`PurchaseOrderShow` (no check that the *record's own* warehouse is one the
  viewer can access) had the same gap. Fixed across all of them with the same
  `WarehouseAccessService` calls every other correctly-scoped page already used — this was a
  consistency gap, not a missing concept.
- Three index pages (`SaleIndex`, `PurchaseOrderIndex`, `StockMovementIndex`) had no explicit
  `mount()` permission check (route middleware covered them today, but the pattern every other
  component follows is defense-in-depth, not reliance on routing alone) — `SaleIndex`/
  `PurchaseOrderIndex` were *also* missing warehouse scoping entirely, fixed at the same time.

Verified with a temporary `Livewire::test()` suite built specifically to prove the fixes actually
hold (6 methods, 19 assertions, since discarded): a warehouse-restricted user's `SaleForm` dropdown
never offers an out-of-scope warehouse, and submitting a tampered request with one anyway gets a
403 rather than silently succeeding; the same record 403s on direct view and disappears from every
index/report a restricted user can see; a no-permission user gets an empty (not 403'd, not
all-seeing) dashboard.

Separately, the sprint's originally-planned work: `spatie/laravel-backup` (not hand-rolled — unlike
Sprint 6/9's Http-facade-is-simple-enough calls, backup rotation/monitoring is genuinely complex
and this is the maintained standard), configured to exclude `.env` and `storage/` from the file
backup and dump MySQL via `mysqldump` — **tested for real** against the actual dev database
(`php artisan backup:run` produced a genuine, valid, restorable SQL dump; `backup:list` confirmed
spatie's own monitoring recognizes it), not just scaffolded. All 9 `App\Notifications\*` classes
(Sprint 8) are now `ShouldQueue` — verified end-to-end via `tinker`: triggering a >10% price change
landed a row in `jobs` (not an instant delivery), `queue:work --once` processed and delivered it,
`jobs` emptied — finally makes Sprint 1's "Queues for heavy/background work" real rather than
aspirational, and is why `deploy/supervisor-queue-worker.conf.example` is now load-bearing, not
optional. `SecurityHeaders` middleware (CSP scoped to the app's actual asset sources — `fonts.bunny.net`,
confirmed by reading `layouts/app.blade.php`, not a generic copy-paste policy) and `trustProxies(at:
'*')` (the spec's own Cloudflare→Nginx→Laravel diagram means the app always sees a proxied request
whose exact IP isn't knowable in advance — without this, Laravel would generate `http://` URLs even
in production). Missing indexes added on `sales.sale_date`/`status` and
`purchase_orders.order_date`/`status`, filtered/grouped by constantly since Sprint 7. `composer
audit` clean. `config:cache`/`route:cache`/`view:cache` all verified to succeed cleanly — confirmed
zero `env()` calls anywhere outside `config/*.php` in the whole codebase, so caching won't silently
break anything in production, a common Laravel gotcha that doesn't apply here.

`DEPLOYMENT.md`, `deploy/nginx.conf.example`, `deploy/supervisor-queue-worker.conf.example`, and
`.env.production.example` are new deployment artifacts — accurate by inspection against this
codebase's real requirements, but **not executed against a real server**, since none exists yet.
`DEPLOYMENT.md`'s own "Known gaps" section says this plainly rather than implying more was verified
than actually was.

**Roadmap status: all 10 sprints complete.** Nothing scripted remains — any further work is
genuinely new scope (a real production deployment once real infrastructure exists, ongoing
feature requests, or Sprint 9's deferred live-AI-round-trip test once an API key is added).

**Key implementation decisions from Sprint 10:**

- **The security audit was scoped to expect little and found a lot — trust but verify held.** The
  original plan called it "cheap insurance," expecting the established permission-check pattern to
  already be consistent. It mostly was (Categories/Customers/Suppliers/Users/Roles/Warehouses/
  Settings/Search/Notifications/Assistant all came back clean), but Dashboard and the
  warehouse-scoping gaps were real, not false positives — a reminder that "the pattern is
  established" and "every instance of the pattern is correct" are different claims, and only the
  second one is actually worth verifying before calling a sprint done.
- **Gating `Dashboard` by individual widget, not by page**, because it's the mandatory post-login
  landing page — every authenticated user must be able to open it, but what it *shows* should still
  respect their permissions. A single page-level permission would have been simpler code but wrong
  behavior (it would 403 real users out of their own login flow).
- **Backups and deployment artifacts stayed honestly scoped to what's actually verifiable without a
  real server.** The backup command is genuinely tested end-to-end (a real zip, a real restorable
  SQL dump); the nginx/supervisor configs and `DEPLOYMENT.md` are accurate by inspection but
  explicitly flagged as unexecuted — the same honesty Sprint 9 used for the deferred live-AI test,
  applied here to "deployment" as a whole.

**Key implementation decisions from Sprint 9:**

- **The spec's AI pipeline is Claude's tool-use loop, not a custom NLP layer to build.** Recognizing
  this upfront avoided building a redundant, worse "intent classifier" — the model already does
  entity/intent detection from the tool schemas and the user's plain-language question.
- **Reuse over reimplementation is the actual safety mechanism**, not just a DRY nicety: every tool
  that has an equivalent human-facing page calls the *same* query the page calls, so there is
  structurally no way for the AI's numbers to drift from what a human sees for the same question.
- **`ai.use` gates the *page*, not the *data*.** It's assigned broadly (every role) on purpose —
  each of the 11 tools does its own `.view`/warehouse check, so a Sales User asking about purchase
  data gets a real, honest "you don't have access to that" from the tool layer, the same denial
  they'd hit clicking into Purchases directly. Gating the page narrowly would have added a second,
  redundant permission boundary that could drift from the first.
- **Conversation state is in-memory per page load, not persisted** — the spec asks for auditing
  "AI usage" (one row per question, which `ai_queries` does), not for chat history as a feature;
  persisting full transcripts wasn't asked for and would be exactly the kind of "store sensitive
  information unnecessarily" the AI Audit section warns against.
- **This sprint ships in the "AI unavailable" state on purpose** (no key yet) — and that state is a
  first-class, tested path (`AssistantChat`'s degraded UI, `AnthropicClient::isConfigured()`), not
  an afterthought bolted on. Directly satisfies "The ERP must remain fully functional when AI is
  unavailable" as the literal, current, verified state of the app.

**Key implementation decisions from Sprint 8:**

- **`ActivityLog` stays deliberately narrow** — wired into exactly two things Entity History
  couldn't get from existing transaction tables (Product price changes, user logins), not
  retrofitted into every model's create/update for generic "audit activity" completeness. The
  model/`record()` helper are reusable, so extending this later is additive, not a redesign.
- **"Customer payment overdue" / "Supplier payment due" use a stated 30-day heuristic**
  (balance > 0 and the oldest unpaid sale's/PO's date is 30+ days old), because `Sale`/
  `PurchaseOrder` have no due-date/payment-terms field to check against. Documented as an
  assumption rather than silently invented.
- **`NotificationService::usersWithPermission()`** centralizes "which users should see this
  notification" as `User::all()->filter(fn($u) => $u->can($permission))` — deliberately reuses
  Laravel's real `Gate`/`can()` resolution (which already respects Spatie roles *and* this app's
  own `Gate::before` Super Admin bypass) rather than hand-rolling a permission-to-users SQL query,
  which would have needed to separately replicate both of those and risked silently drifting from
  what `@can`/`abort_unless` actually check everywhere else in the app.
- **Deep-linking the command palette's "Stock Adjustment"/"Add Product"/"Low Stock" actions**
  needed a tiny `mount()` addition on 4 existing Index/Report components (`request()->query('action')`/
  `?stockFilter=`) rather than a new route or a separate "quick-create" component — the modal/filter
  state these actions need already exists on those pages.

**Key implementation decisions from Sprint 7:**

- **`App\Exports\ArrayExport`** is the only export class in the app — implements `FromArray`/
  `WithHeadings` (`maatwebsite/excel` v4, already installed for Sprint 4's import, but this is the
  first time this codebase exercises its export/write side rather than only `toCollection()`
  reading), constructed with plain `(array $headings, array $rows)`. Every report builds its
  headings/rows from the *same* query powering its on-screen table, then
  `return Excel::download(new ArrayExport(...), 'name.xlsx');` directly from a Livewire action
  method — Livewire natively turns a returned `BinaryFileResponse` into a browser download, so no
  separate export route/controller was needed anywhere.
- **No new Services.** Every report is a read-only aggregation query; components query `Sale`/
  `PurchaseOrder`/`StockBalance`/`Product`/`Customer`/`Supplier` directly, the same way
  `SaleIndex`/`StockBalanceIndex` already do. There's no write path to centralize.
- **Receivables/Payables reuses `Customer::balance()`/`Supplier::balance()` directly** (fetch all,
  map, filter, sort in PHP) rather than re-deriving the same math as a separate aggregate SQL
  query — at this business's scale (a few dozen customers/suppliers, not thousands), the N+1-
  shaped simplicity of reusing the exact already-tested method outweighs the query-count cost, and
  guarantees the report can never silently drift from what `CustomerShow`/`SupplierShow` display.
- **Warehouse scoping follows two existing precedents, not one new blanket rule**: Inventory/
  Product Velocity reports and the dashboard's stock tiles scope via `WarehouseAccessService`
  (matching `StockBalanceIndex`); Sales/Purchases/Profit/Receivables-Payables stay unscoped
  (matching that Sales/Purchases themselves already don't scope either).
- **Product Velocity's zero-sales rows come from a `selectSub` correlated subquery against
  `Product`, not a `groupBy` on `SaleItem`** — a `groupBy` approach would simply omit any product
  with no matching `sale_items` rows in the window, making "dead stock" (the whole point of the
  slow-moving sort) invisible. `selectSub` (the same pattern `SupplierIndex` already used in
  Sprint 3 for `received_value`) coalesces to 0 for products with no matching rows instead.

**Key implementation decisions from Sprint 6:**

- **Returns are new header+line records (`SaleReturn`/`SaleReturnItem`,
  `PurchaseReturn`/`PurchaseReturnItem`), never mutations of the original `Sale`/`PurchaseOrder`** —
  preserves "never silently delete historical stock transactions." A return line references the
  original `SaleItem`/`PurchaseOrderItem` for quantity-remaining validation
  (`returnableQuantity()` = sold/received minus already-returned) and, for batch-tracked products,
  the specific `batch_id` the stock goes back into/out of — for a customer return this is always
  the *same* batch the item was originally sold from (`SaleItem.batch_id`, added this sprint); for
  a supplier return it's user-selected from whatever batches currently exist for that
  product+warehouse (a receipt's batch may have since been partially sold elsewhere, so it can't be
  hard-linked back to the original receipt).
- **`purchases.return` was a genuinely new permission** — every other Sprint 1-5 module had its
  permissions pre-scaffolded in Sprint 1's `RolePermissionSeeder`, but supplier returns were
  missed. Added alongside `purchases.cancel` (Purchase Manager/Admin/Super Admin).
  `sales.return` already existed and was already correctly assigned — just needed an actual check.
- **Cancelling a sale (Sprint 5's `sale_cancel`) and returning part of a sale (this sprint's
  `customer_return`) use deliberately different cost-blending behavior**, both reusing
  `recordMovement()` unchanged: cancel passes `unit_cost: null` (a pure reversal — "this never
  happened," average untouched, per Sprint 2's adjustment rule), while a return passes the
  original `SaleItem.unit_cost` (a genuine new event — the goods physically re-enter inventory and
  should blend into the average like any other positive receipt).
- **No dedicated `BatchService`.** Batch *ledger state* (quantity, locking, the negative-stock
  guard) lives in `InventoryService::recordMovement()` — the same reasoning Sprint 2 used for
  `stock_balances` being real ledger state, not a cache. Batch *selection* (which batch number to
  create/find on receipt, which batch a sale/return draws from) stays in `PurchaseService`/
  `SalesService`, mirroring how those services already own `PurchaseOrderItem`/`SaleItem` creation
  directly without an intermediary service.
- **No automatic FEFO splitting.** A `SaleForm` line for a track_batches product requires picking
  one specific batch (dropdown sorted soonest-expiry-first, nudging FEFO without forcing it) rather
  than the system silently allocating one sale across multiple batches — simpler, fully auditable,
  and leaves room for a deliberate business call (e.g. holding a near-expiry batch back).
- **Barcode support is keyboard-wedge only, by explicit user decision** — a plain text input
  (`wire:keydown.enter`) that a USB/Bluetooth scanner types into like a keyboard, looking up
  `Product::where('barcode', ...)`. No camera-scanning library was added; that was a deliberate
  scope decision, not an oversight, in case it comes up again.
- **Expiry alerts shipped as a new `BatchIndex` view under Inventory now**, not a dashboard widget
  or notification — Dashboard is Sprint 7, Notifications are Sprint 8 per the roadmap. This sprint
  built the underlying `ProductBatch::expired()`/`expiringWithin(int $days)` query scopes those
  later sprints will consume.

**Key implementation decisions from Sprint 5:**

- **`Sale` is the invoice — no separate `Invoice` model.** Same reasoning already applied to
  `PurchaseOrder`/`po_number`: a second table would just duplicate `Sale` 1:1 for no benefit.
- **Payments are tracked at the customer level, not per-invoice** — `CustomerPayment` is a direct
  mirror of `SupplierPayment` (no link to a specific `Sale`), and `Customer::balance()` is the same
  aggregate pattern as `Supplier::balance()` (`soldValue() - paidValue()`). Simpler than Supplier's
  version, though: `Sale.total` is already a stored aggregate, so `soldValue()` is a plain
  `sales()->where('status','completed')->sum('total')` — no join through a receipt-equivalent table
  needed (a sale has no separate "receipt" step the way a PO has goods receipts).
- **`App\Services\SalesService`** (`createSale()`, `cancelSale()`) is the sole write path, mirroring
  `PurchaseService`'s shape. `createSale()` captures each line's COGS (`unit_cost` on `SaleItem`)
  from `StockBalance.average_cost` *before* calling `InventoryService::recordMovement()` with a
  negative quantity — reading before vs. after is equivalent per Sprint 2's rule that a negative
  movement never touches the average, but reading before is more explicit about intent. No new
  stock-guard logic was needed anywhere: `recordMovement()`'s existing negative-stock check is
  exactly what enforces "negative stock is not allowed" for sales.
- **`cancelSale()` reverses stock via a new `sale_cancel` movement type** (positive quantity,
  `unit_cost: null`) — reusing the "positive movement, no cost given, average untouched" rule
  Sprint 2 established for adjustments, rather than inventing new cancellation-specific stock logic.
  A cancelled sale's row stays on record (revenue/profit intact for audit) but flips out of every
  balance/profit aggregate via the `status = 'completed'` filter.
- **No warehouse-access scoping on Sales**, deliberately matching Purchases (Sprint 3) rather than
  Inventory (Sprint 2) — verified by inspection that `PurchaseOrderForm`/`Index`/`Show` never call
  `WarehouseAccessService` either. Only Inventory currently enforces per-warehouse restriction.
- **`CustomerShow` (new) mirrors `SupplierShow` exactly**, and `CustomerIndex` gained the same
  Balance column `SupplierIndex` already had since Sprint 3 (via `withSum` instead of `SupplierIndex`'s
  `selectSub`, since `Sale.total` doesn't need the join `Supplier`'s `receivedValue()` needs) — bringing
  Customers to parity with what Suppliers already had.

**Key implementation decisions from Sprint 4:**

- **Always inspect the real file before trusting the spec's description of it.** The spec described
  the 7 Days Natural format from memory; the actual file (found in Downloads) differs in several ways
  that would have broken a spec-literal implementation: row 1 is a title banner (header is row 2, not
  row 1), the real header has a typo (`PORODUCT NAME`, not `PRODUCT NAME`) so **column mapping is
  positional (A–K), never header-text matching**, row 120 is a `TOTAL` footer that must be excluded,
  and the `Value` column contains live Excel formulas (`=F3*J3`) rather than static numbers — read via
  `Maatwebsite\Excel\Concerns\WithCalculatedFormulas`, not `Excel::toArray()`'s default.
  `Excel::toCollection()` needs an `Import`-implementing object as its first argument in
  maatwebsite/excel v4 (not `null`/`[]` like older docs suggest).
  `App\Services\Import\SevenDaysNaturalParser::parse()` is built around all of this.
- **A full price catalog isn't a pure order form** — many rows have `Order = 0` (e.g. "Hip Up Cream").
  These are valid data, not invalid rows; they're simply excluded from the resulting PO. Only
  `product_name` or `purchase_cost` missing makes a row `is_invalid`.
- **`App\Services\Import\ProductMatcher`** implements the spec's matching priority for this format
  (SKU/barcode/supplier-code tiers don't apply — this format has none of those columns): normalize
  (lowercase, squish whitespace, strip leading symbols) → exact match on normalized name → fuzzy via
  `similar_text()` scoring (≥70%, top 3) → `new`. Fuzzy candidates are only ever suggested, never
  auto-selected, per "never automatically merge low-confidence matches."
- **`import_batches`/`import_batch_rows` are staging tables**, not transient component state — the
  Safety Rule's Upload → Validate → Match → Preview → Confirm workflow needs durable server-side state
  so a mid-review page reload doesn't lose progress. `ImportWizard`'s preview step edits load from and
  write back to these rows; `ImportService::confirm()` reads only from the DB rows, never from
  in-memory wizard state directly — keeping the "what actually gets imported" source of truth in one
  place.
- **New products from an import get an auto-generated SKU** (slugified name + numeric suffix on
  collision) since this supplier format has no SKU/barcode column at all and `products.sku` is
  required/unique.
- **`Product.gst_rate` was left at its default (0) for imported new products** — this format has no
  GST-rate column of its own (its price tiers already read as GST-inclusive), so nothing meaningful to
  map; left as a manual follow-up in the product master if/when needed, rather than guessing.

**Key implementation decisions from Sprint 3:**

- **Bug found and fixed during this sprint's tinker sanity check, affecting Sprint 2 as well:**
  `StockMovement`'s `#[Fillable]` attribute was missing `reference_type`/`reference_id` — Laravel
  silently drops unlisted attributes on mass-assignment (no error, no exception), so every movement
  ever created with a `$reference` — including Sprint 2's Transfers — had a null reference, silently
  breaking the audit link `StockTransfer::movements()` was built to provide. No stale bad data existed
  to fix (all Sprint 2 test data had already been cleaned up), just the model. **Lesson: a mass-
  assignment field silently dropping is invisible until you specifically check for it** — a plain
  "did it save without erroring" check would never have caught this; the fix required actually reading
  back `$movement->reference_type` and asserting it was non-null.
- **`PurchaseService` is the sole write path for PO/receiving**, mirroring `InventoryService`'s shape:
  `createPurchaseOrder()`, `receiveGoods()` (row-locks the `PurchaseOrderItem` via `lockForUpdate()`,
  validates against `pendingQuantity()` before writing, throws `App\Exceptions\PurchaseException`),
  `cancelPurchaseOrder()`. `receiveGoods()` calls `InventoryService::recordMovement()` directly rather
  than duplicating any stock-writing logic.
- **PO/receipt numbers are assigned after insert** (`'PO-' . str_pad($id, 6, '0', STR_PAD_LEFT)`), not
  pre-computed — avoids a race condition from guessing the next number before the row exists.
- **Supplier balance is a plain computed sum, not a stored column** (`Supplier::receivedValue()` /
  `paidValue()` / `balance()`), unlike `stock_balances` — a payable balance has no sequential-averaging
  complexity, so materialized state would have been unjustified caching. The supplier list computes
  both sums via a single query (`selectSub` + `withSum`) to avoid N+1 across rows.
- **PO create/detail screens are full pages, not modals** — a deliberate, explicit departure from
  Sprints 1–2's "everything in a modal" pattern, because a dynamic repeating line-item editor doesn't
  fit a small modal without hurting the UX the spec asks for. Receiving still follows the established
  pattern (an action modal embedded in the page that owns it — here, the PO detail page).

**Key implementation decisions from Sprint 2:**

- **`App\Services\InventoryService`** (`app/Services/InventoryService.php`) is the sole write path for
  stock — `recordMovement()` is the core primitive (DB-transactional, row-locks the `stock_balances`
  row via `lockForUpdate()`, throws `App\Exceptions\InventoryException` before any write if the
  result would go negative), with `openingStock()`, `adjust()`, and `transfer()` as the three entry
  points this sprint exposes. Sprint 3/5 should add `purchase`/`sale`/`customer_return`/
  `supplier_return` as new thin wrappers around the same `recordMovement()`, not new write paths.
- **`stock_balances` is real, necessary state, not a cache** — weighted-average cost is a stateful
  sequential calculation (blend on receipt, unchanged on reduction) that cannot be correctly derived
  by aggregating raw `stock_movements` rows at read time. It's kept in sync transactionally in the
  same transaction as every movement insert.
- **Cost blending rule**: a movement only recalculates `average_cost` when `quantity > 0 AND
  unit_cost !== null`. A positive adjustment with no cost given (e.g. "found stock, same as what's
  already here") deliberately leaves the average untouched — this is what lets a pure quantity
  correction not distort historical costing. Every negative movement always leaves the average
  untouched (per spec: historical cost must not change on consumption).
- **`stock_movements` rows are immutable** — `StockMovement::booted()` throws on `updating`/`deleting`
  as defense-in-depth; no UI or route ever attempts either.
- **Transfers get a thin `stock_transfers` header row** linking two `stock_movements` rows
  (`transfer_out` at the source, `transfer_in` at the destination) via the polymorphic
  `reference_type`/`reference_id` columns, so a transfer reads as one audit entry. The destination
  receipt carries the source warehouse's average cost at the moment of transfer, blending into
  whatever average the destination already has.
- **One `StockBalanceIndex` component owns three modals** (Opening Stock, Adjust, Transfer) rather
  than three separate components — same "list + modal actions in one component" pattern as every
  Sprint 1 module. A separate `StockMovementIndex` is the read-only ledger/report view, kept apart
  because a live-action screen and a historical report are genuinely different concerns.
- **Warehouse scoping** reuses Sprint 1's `WarehouseAccessService` unchanged — both inventory
  components resolve `accessibleWarehouseIds()` once in `render()` and apply it to the balances/
  movements query and to every warehouse `<select>` in the action modals. No new scoping mechanism
  was needed.

**Key implementation decisions from Sprint 1:**

- **Auth stack:** Laravel Breeze's `blade` stack (plain controllers, not the Volt-based `livewire`
  stack) + `livewire/livewire` (v4) installed separately for CRUD screens, using classic two-file
  class+view components in `app/Livewire/{Module}/`. Auth stays traditional MVC; CRUD screens are
  Livewire.
- **One component per module, not Index+Form.** Each module (Products, Categories, Suppliers,
  Customers, Warehouses, Users, Roles) is a single `{Module}Index` Livewire component handling
  list + search + pagination + a create/edit modal + delete, rather than a separate Form component.
  Zero parent-child Livewire event coordination needed; same UX, less moving parts. Settings is a
  single non-list `GeneralSettings` component (key-value form only).
- **No Policies, no per-module Services for Sprint 1.** Authorization is flat `spatie/laravel-permission`
  checks (`@can` in Blade, `abort_unless(auth()->user()->can(...))` in component actions, `permission:`
  route middleware). There's no per-record ownership logic yet, so Policies would be premature.
  The one Service that exists is `App\Services\WarehouseAccessService` — a deliberate exception to
  centralize the cross-cutting warehouse-scoping concern ahead of Sprint 2, per the "not scattered
  hardcoded checks" rule.
- **Products/Categories/Warehouses `unique` validation excludes soft-deleted rows** — added
  `->whereNull('deleted_at')` to every `Rule::unique()` on those tables. Laravel's `unique` rule
  checks the raw table, bypassing Eloquent's SoftDeletingScope, so without this a soft-deleted
  "Main Warehouse" would permanently block ever creating another warehouse with that name. Caught
  live during browser testing, not a hypothetical.
- **Tailwind ended up on v3, not v4.** The project was scaffolded fresh with Tailwind v4
  (`@tailwindcss/vite`), but `breeze:install blade` overwrote `vite.config.js`, `resources/css/app.css`,
  and added `tailwind.config.js`/`postcss.config.js` for v3. Kept as-is (functional, officially
  supported combination) rather than fighting the scaffolding back to v4.
- **Alpine.js is Livewire's bundled instance only.** Breeze's default `resources/js/app.js` used to
  separately import and start Alpine, which conflicted with Livewire v4's own bundled Alpine
  (`Detected multiple instances of Alpine running` console warning). Removed the separate
  import/start — Livewire's `@livewireScripts` is now the sole Alpine source for the whole app,
  including plain `x-data` usage in Blade components (sidebar mobile toggle, dropdown, modal).
- **Shared layout:** `resources/views/layouts/app.blade.php` (Livewire's `component_layout` config
  already pointed here by default) now contains the sidebar+topbar shell, used by both Breeze pages
  (dashboard, profile) and every routed Livewire component. Sidebar links are guarded with
  `Route::has($link['route'])` — matters during incremental development so an unbuilt module's route
  doesn't 500 the entire sidebar for every other page.
- **Local dev needs `npm run build` after Blade/CSS changes**, or run `npm run dev` in the background
  while actively changing views — the compiled `public/build` CSS is a static snapshot from whenever
  `npm run build` last ran, since this project is served by XAMPP Apache directly rather than
  `php artisan serve`.
- **Seeded Super Admin login:** credentials in `.env` as `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`
  (defaults: `admin@aradhyaenterprise.com` / `Aradhya@2026`), created by `AdminUserSeeder`.

---

## Post-Roadmap Fixes

**2026-09-03 — Real SMTP wiring, password-reset hardening, "Login with Google" (Socialite).**

Not a new module, three related auth capabilities requested together. Forgot-password/
reset-password themselves were already fully functional Breeze scaffolding (verified,
not rebuilt) - `password_reset_tokens` already exists per-tenant
(`database/migrations/tenant/0001_01_01_000000_create_users_table.php`), and
`SetTenantUrlDefaults` already makes the reset-link notification's `route('password.reset',
...)` resolve correctly inside a queued/synchronous send. The only real gap was both POST
routes (`password.email`, `password.store`) having no rate limiting at all - added
`throttle:6,1`, matching later upstream Breeze/Fortify defaults.

**Mail** stays on the `log` driver in local `.env` deliberately (so password-reset testing
doesn't need a real mailbox), but both `.env` and `.env.example` now carry the full `smtp`
template (host/port/user/pass/encryption) ready to flip on for staging/production - see the
comment block directly above `MAIL_MAILER` in either file.

**Login with Google** (`app/Http/Controllers/Auth/SocialiteController.php`, `laravel/socialite`
v5.31) is deliberately registered as **central** routes in `routes/web.php`
(`/auth/google/redirect/{tenant}`, `/auth/google/callback`), not nested under `/{tenant}` like
the rest of `auth.php`. Google OAuth needs one fixed, pre-registered redirect URI; a path-based
`{tenant}` segment in that URI would mean allow-listing every tenant's callback individually in
Google Cloud Console, which doesn't scale as tenants self-provision. Instead `redirect()` stashes
the originating tenant's slug in the session before handing off to Google, and `callback()` reads
it back and calls `tenancy()->initialize($tenant)` **directly** rather than the usual
`InitializeTenancyByPath` middleware (there's no `{tenant}` path segment on this route for that
middleware to read) - and directly rather than `tenancy()->run()` too, which would `end()` (revert
to the central DB connection) before returning, causing the 'web' middleware group's post-response
session save to write the new login to the *central* `sessions` table instead of the tenant's,
silently dropping the login on the very next request. `'auth'` was added to
`config/tenancy.php`'s `reserved_slugs` so no tenant can ever collide with this prefix.

Per explicit decision: Google sign-in **auto-creates** a tenant user on first login (matched
after that by `google_id`, falling back to email for a first-time link to an admin-created
account), rather than requiring the email to already exist. The new account is assigned a new,
deliberately minimal **`Viewer`** role (`.view`-only across every module, added to
`RolePermissionSeeder` - existing tenants provisioned before this change need
`php artisan tenants:seed --class=RolePermissionSeeder --tenants=<id>` run once to gain it) rather
than any of the existing operational roles, since none of those are read-only. An admin upgrades
the account to a real role from the Users module once they know who signed in. New
`users.google_id` column (nullable, unique) via `database/migrations/tenant/
2026_09_03_090000_add_google_id_to_users_table.php` - **existing tenants need `php artisan
tenants:migrate` run once to pick it up**, same as the seeder step above.

A real bug was caught by the new test suite (`tests/Feature/Auth/SocialiteLoginTest.php`), not
just written to pass one after the fact: the auto-create branch originally left `is_active` out
of the `User::create()` array, relying on the `users` table's DB-level `default(true)`. Eloquent's
`create()` only populates the in-memory model with what's actually passed to it - it doesn't
re-read DB-computed defaults - so the very next line's `if (! $user->is_active)` check saw `null`
(falsy) on that same just-created instance and rejected the brand-new user as deactivated. Fixed
by setting `'is_active' => true` explicitly rather than trusting the column default to be visible
in-memory. A second, non-security bug from the same test run: `email_verified_at` wasn't in
`User`'s `#[Fillable(...)]` attribute list, so it was silently dropped by mass assignment -
Google-authenticated users were being created with no `email_verified_at`. Added
`'email_verified_at'` and `'google_id'` to the `Fillable` list.

Full suite green (185/185) after adding the new Socialite tests and updating `RoleIndexTest`'s
hardcoded role count (9 -> 10, for the new `Viewer` role) - both re-checked after the `is_active`/
`email_verified_at` fixes above, not just once at the end. `route:cache` re-verified clean (this
project's own mandatory pre-push check, see the 2026-09-01 entry below for why) before clearing it
back for local dev.

Still needed before this is live: a real Google Cloud OAuth 2.0 Client (Web application) with its
Authorized redirect URI set to exactly `GOOGLE_REDIRECT_URI`, and `GOOGLE_CLIENT_ID`/
`GOOGLE_CLIENT_SECRET` filled into the deployed `.env` - the "Sign in with Google" button on the
login page stays hidden (`@if (config('services.google.client_id'))`) until that's done, so nothing
half-works in the meantime.

**2026-09-01 — Multi-tenancy (path-based), operator console, subscription enforcement.**

Not in the original roadmap: this app became multi-tenant. A first, subdomain-based
attempt (stancl/tenancy, `<slug>.erp.aradhyaenterprise.com`) reached 84/84 green tests
and was deployed, but broke production - `routes/web.php` looped over
`config('tenancy.central_domains')` registering `Route::get('/', ...)->name('home')`
once per domain, three routes all named `home`. `route:list`/live dispatch tolerate
duplicate names scoped to different domains; `route:cache`'s serialization does not,
and that step was never actually run locally before deploying. Reverted via
`git revert -m 1` (non-destructive, full history kept), then rebuilt **path-based**
instead (`/{tenant-slug}/...` on the single domain) - structurally can't repeat that
bug (one route table, no per-domain loop) and `route:cache` is now a mandatory local
check before every push to master. `App\Services\Tenancy\SlugPathTenantResolver`
resolves the `{tenant}` path segment against a `slug` column rather than the tenant's
UUID primary key, so the UUID never appears in a URL; the UUID stays the real
`getTenantKeyName()` for database/cache/filesystem naming, unchanged.

Path identification surfaced two problems domain-based tenancy never had: Livewire's
own AJAX update endpoint is registered outside `routes/tenant.php` by its own service
provider, so by default every Livewire interaction after the first page load hit an
endpoint tenancy was never initialized for - fixed with `Livewire::setUpdateRoute()`
re-registering it under `/{tenant}`. And `{tenant}` being a required route parameter
broke every existing `route()`/`redirect()->route()` call in the app (none of them
pass it, since they predate the tenancy split) - fixed with
`App\Http\Middleware\SetTenantUrlDefaults` registering the current tenant as a URL
default. Separately, `config('tenancy.filesystem.asset_helper_tenancy')` (on by
default) rewrites the `asset()` helper's root during tenancy init, and Laravel's
`@vite()` directive calls `asset()` internally for CSS/JS URLs - every tenant page's
stylesheet 404'd until that was turned off (nothing in this app uses `asset()` for
actual per-tenant files, so nothing was lost).

The pre-existing single production database (`aradhyaenterpris_erp`, containing
`admin@aradhyaenterprise.com` and all real business data) was reconnected without a
data copy: `App\Console\Commands\LinkExistingDatabaseAsTenant`
(`tenants:link-existing`) inserts a `tenants` row directly via `DB::table()` (bypassing
`Tenant::create()`'s event pipeline, which would otherwise try to `CREATE DATABASE` on
something that already exists) with `tenancy_db_name` pointed at the existing database
and `tenancy_create_database=false`. Verified locally against a real mirror of the
situation before ever running it for real: `tenants:migrate` correctly reports
"Nothing to migrate" (the migration filenames are unchanged from before the
central/tenant split, already recorded as run), row/table counts unchanged, the
existing admin + Super Admin role resolve correctly through the tenant-switched
connection. Idempotent (checked by slug and by database), so it was safe to run once
via a temporary step in `.gitlab-ci.yml`'s `deploy_production` job, then remove that
step again immediately after confirming it worked - not left as a permanent no-op.

**Operator console** (`/operator/login`, `/operator/tenants`): a second, entirely
separate auth realm for central staff, using the `central_users` table
(`App\Models\Central\CentralUser`, previously scaffolded but never wired to anything)
via a new `operator` guard in `config/auth.php`. Built as plain controllers + Blade
forms (`App\Http\Controllers\Operator\*`), not Livewire - Livewire's update route is
wired to require the `/{tenant}` prefix, which a central-only page can never provide.
The isolation from tenant data isn't a permission check that could have a bug:
operator routes never sit behind `InitializeTenancyByPath`, so no tenant database
connection is ever established during an operator request - there is structurally
nothing to query. Gated by `CentralUser.is_operator` (a billing-owner-only
`central_users` row has no console access), via `App\Http\Middleware\EnsureOperator`.
First account seeded via `SEED_OPERATOR_EMAIL`/`SEED_OPERATOR_PASSWORD` env vars
(`OperatorUserSeeder`), mirroring `AdminUserSeeder`'s pattern - deliberately no
hardcoded default password, unlike that seeder's `'password'` fallback, since this
account can see every tenant's metadata.

**Subscription enforcement**: new `tenants.subscription_ends_at`, separate from
`trial_ends_at` (the one-time free-trial window). `Tenant::isReadOnly()` (previously
defined but never called anywhere) now actually means something - past-due
subscriptions flip a tenant read-only, checked live on every request from the date
(no cron needed to "notice" expiry). Enforced through the existing `Gate::before`
Super Admin bypass in `AppServiceProvider`, extended to deny every ability not ending
in `.view` when read-only - including for the tenant's own Super Admin, since the
whole point is that it can't be bypassed by the top role. A tenant flipping read-only
can still log in and see everything (`isReadOnly()`'s original docblock: "losing
access to your own stock ledger because an autopay failed is not an acceptable
failure mode for an accounting system"), just can't write.

Two real, previously-latent bugs found while wiring the read-only enforcement, neither
specific to this feature:
- **`Gate::before` ordering.** `spatie/laravel-permission` registers its own
  `Gate::before` (in its package provider's `boot()`) that returns `true` outright the
  moment `checkPermissionTo()` succeeds - short-circuiting Gate resolution before any
  later-registered `before` callback runs at all. This app's own `Gate::before` was
  silently never firing for exactly the case that matters (a legitimately-permitted
  user). `Gate::after` can't fix this either - it can only fill in a still-null
  result via `??=`, never override an already-decided `true`. Fixed by moving this
  app's `Gate::before` registration from `AppServiceProvider::boot()` to
  `register()` - every provider's `register()` phase runs before any provider's
  `boot()` phase, so this now reliably lands in Gate's before-callback list ahead of
  Spatie's, regardless of inter-provider boot ordering.
- **Unauthenticated redirects 500'd outside full tenant context.**
  `Illuminate\Foundation\Configuration\ApplicationBuilder::withMiddleware()`
  unconditionally defaults to `redirectGuestsTo(fn () => route('login'))` before this
  app's own `bootstrap/app.php` callback even runs, and nothing here had ever
  overridden it. Since `login` now requires a tenant path parameter, this 500'd for
  any unauthenticated request where that parameter wasn't available - confirmed this
  already affected plain tenant routes too (e.g. `/{tenant}/dashboard` while logged
  out), not just the new operator ones, depending on middleware ordering. Fixed with a
  guard/context-aware override in `bootstrap/app.php` built from `tenant()->slug`
  directly, not dependent on `SetTenantUrlDefaults` having already run.
- Also found in the same pass: `EnsureUserIsActive` (appended globally to the `web`
  middleware group, so it also runs on operator routes) called the ambient
  `Auth::user()` rather than `Auth::guard('web')->user()`. `Authenticate` middleware
  calls `Auth::shouldUse($guard)` on successful authentication, so by the time this ran
  on an operator route the default guard had silently become `operator`, resolving a
  `CentralUser` with no `is_active` column at all (null, which is falsy) and wrongly
  logging every operator out on their very first authenticated request.

Verified end-to-end over real HTTP (not just the PHPUnit test client, and not just
`route:list`/`tinker`): operator login, the tenant list rendering the real linked
tenant, +1 month/+1 year subscription extension actually persisting, and read-only
enforcement confirmed from both directions - `Gate::allows()` returning `false` for a
write ability while `true` for `.view`, and the actual Blade `@can`-gated "New
Warehouse" button disappearing for the tenant's own Super Admin once
`subscription_ends_at` was set to the past. Full suite still 84/84, `route:cache`
still succeeds - both re-checked after every change in this entry, not just once at
the end.

**2026-08-29 — Customer creation from a GST Registration Certificate PDF.**

New capability, not a fix: `/customers/gst-import` (button next to "+ New Customer" on the
Customers list, `customers.create`-gated like the manual form) lets a user upload a customer's
Form GST REG-06 PDF instead of typing in GSTIN/name/address by hand. Upload -> extract -> editable
preview -> confirm, the same shape as Sprint 4's Excel import: nothing is written to the `customers`
table until the human confirms the preview screen, and every extracted field stays editable there.

`GstCertificateParser` (`app/Services/Import/`) reads the PDF via `smalot/pdfparser` (new composer
dependency — pure PHP, no system binary, same rationale as `maatwebsite/excel` for the Excel side).
Two things about this specific government form made naive line-adjacency parsing unsafe, found by
extracting real certificates with `pdftotext` before writing any parsing code: every certificate
carries a diagonal watermark whose individual letters extract as their own short lines interleaved
with real content, and the "Constitution of Business" value physically extracts several lines after
its label (in the middle of the Address block), not immediately after it like every other field.
Fixed respectively by filtering out bare 1-3 letter fragments when scanning forward from a label, and
by matching "Constitution of Business" against a fixed list of the form's known options anywhere in
the document rather than by position. Every field degrades to `null` + a warning shown on the preview
screen rather than guessing when it can't be found — GSTIN format/checksum failures and a
GSTIN-vs-address-state mismatch are surfaced the same way. A GSTIN already on file blocks creation
(checked at preview time and again inside the create transaction) with a link to the existing
customer, rather than silently creating a duplicate.

Added `App\Support\Gstin` (state code, PAN, and a modulus-36 check-digit validator) as a
general-purpose helper, not GST-certificate-specific — usable anywhere a GSTIN needs validating.
The check-digit algorithm was verified against three real, independently-issued GSTINs (VM & Sons'
and Aradhya Enterprises' own certificates, plus 7 Days Organic's from a supplier invoice) before
being written, since getting this wrong would silently pass or fail every real GSTIN.

Verified two ways given this sandbox's earlier browser/PHP execution limitations still apply:
the exact deployed extraction regex logic was run standalone (outside Laravel) against the real
`pdftotext`-extracted text of both `VM & Sons.pdf` and `AradhyaEnterprise.pdf` — every field matched,
including the address composition and the empty-warnings case for a clean valid certificate — and the
`Gstin` checksum class was likewise run standalone against the three real GSTINs above. Both real
certificates were copied into `tests/Fixtures/gst-certificates/` and `tests/Unit/GstinTest.php` /
`tests/Feature/CustomerGstImportTest.php` were written covering the parser, the full upload-to-create
Livewire flow, duplicate-GSTIN blocking, and permission gating.

**2026-08-30 — actually run, and a real bug found in the process.** A later session had full
PHP/Composer/MySQL access (no device-bridge limitation) and ran all of this for the first time:
`composer require smalot/pdfparser` (v2.12.5 — its `getText()` output is confirmed compatible with
the `pdftotext` reading order the parser was designed against), then the full suite. `GstinTest`
failed immediately — not a logic bug but a tooling one: this project's actual installed PHPUnit is
**12.5.33** (composer.json says `^11.5`, but the lock file resolved higher), and PHPUnit 12 removed
docblock `@dataProvider` entirely in favor of the `#[DataProvider('method')]` attribute. Fixed in
`GstinTest`; any future data-provider test in this repo must use the attribute form.

With that fixed, running the parser against the user's actual certificate file
(`F:\AradhyaEnterprise\GSTInfo\VM&Sons.pdf`, confirmed MD5-identical to the checked-in fixture)
surfaced a genuine defect the "verified standalone" pass above had missed: `inlineField()` read a
label's value only up to the first newline, so a value that wraps onto its own line before the next
field's label — "Building No./Flat No.: MAIN BAZAR...BACK SIDE" continuing with "KRISHNA GALI" on
the next line — was silently dropped, with no warning raised. This is exactly the kind of silent
data loss the parser's own design principle says not to allow. Fixed by having `inlineField()` pull
in continuation lines until it hits a "Label:" line, a numbered form field, or a watermark fragment.
That stop condition had to be loosened once already during fixing: the two real certificates render
the form's numbered fields differently ("6. Date of Liability" with a space vs. "6.Date of Liability"
without one), so the stop pattern is `^\d{1,2}\.` rather than requiring a space or end-of-string after
the dot — otherwise a field near the end of the table (e.g. PIN Code) would swallow everything after
it up to the next real label line, which on one certificate is most of the rest of the document.
`CustomerGstImportTest` was strengthened with an assertion on "KRISHNA GALI" specifically so this
class of regression fails loudly instead of passing on a weaker substring check. Full suite: 84/84
passing, 325 assertions. Feature is code-complete and verified; still needs committing (composer.json,
composer.lock, and all the GST-import files listed above are uncommitted as of this entry).


**2026-08-25 — `SevenDaysNaturalParser` fixed to handle a second real-world file layout; "Execute 360 India" supplier seeded.**

A real supplier file (`revised order Aradhya entr.xlsx`, sheet banner still reads "7 days natural--price list") could not be usefully imported via the UI: the Upload & Parse step never threw an error, but the preview always showed "Confirm Import (0)" with the button disabled, because every row's order quantity silently parsed to `0`. Root cause: this file is a narrower **8-column** variant of the format `SevenDaysNaturalParser` was hardcoded for (S.NO, PRODUCT NAME, UNIT, BOX QTY, MRP, SS-WITH GST, DIST-WITH GST, RETAILER-WITH GST, END CUSTOMER, ORDER, VALUE — 11 columns) — it drops DIST-WITH GST / RETAILER-WITH GST / END CUSTOMER entirely, which shifts ORDER from column J (index 9) to column G (index 6) and VALUE from K (index 10) to H (index 7). The old purely-positional `mapRow()` kept reading index 9/10 (out of range → always empty), and also silently misattributed the real ORDER column's numbers into `distributor_price` (via the old fixed index 6) — a second, quieter bug that would have written wrong distributor pricing onto any newly-created product.

Fixed by locating DIST-WITH GST / RETAILER-WITH GST / END CUSTOMER / ORDER / VALUE by header text (case-insensitive) instead of a fixed index — S.NO..SS-WITH GST (index 0-5) stay positional, since that part of the layout is identical across both known variants and the header text there has known typos (`PORODUCT NAME`) that make name-matching unreliable. ORDER/VALUE fall back to the original fixed indices (9/10) if their header can't be found (losing order data entirely is worse than a stale guess); the other three fall back to `null`/absent rather than guessing a position (misattributing a numeric column is worse than a null). Verified against the real file: 117 product rows parsed, 27 with real non-zero order quantities, and `calculated_value` matched `stated_value` exactly for every one of them (the file's own `SUMPRODUCT`/`SUM` footer formulas independently confirm the math). `distributor_price`/`retailer_price` now come back `null` for this file instead of the previous garbage values.

**Caveat worth knowing for next time:** `ImportService`/`ImportWizard` apply `SevenDaysNaturalParser` to *every* uploaded file regardless of which supplier is selected — there is no per-supplier parser dispatch. This file happens to be a variant of the same template, so the fix above covers it, but a genuinely different file shape from a different supplier would hit the same class of silent-zero-quantity bug. Not addressed now since it wasn't the reported problem — worth a proper per-supplier (or format-fingerprinting) parser dispatch if/when a real, differently-shaped supplier file shows up.

Separately, seeded the `Execute 360 India` supplier (contact Om Suri, GSTIN AA270826059621, Panvel/Raigarh address, Mumbai/Maharashtra/410206) directly via `Supplier::firstOrCreate()` against the real dev DB — this is live business data, not demo data, so it was not added to `DemoDataSeeder`. Also note: **local XAMPP MySQL was not running** when this session started (`mysqld.exe` had to be started via `mysql_start.bat` before any DB write/read worked) — worth checking first if "nothing loads" / a form silently fails in this environment again.

**2026-09-02 — Smart Sales Order Entry: paste a shorthand order, review matches in an editable modal, confirm to invoice. Real PDF + Excel added to the existing print-only invoice.**

New screen at `/sales/smart` (`SmartSalesEntry` Livewire component): paste an order the way a distributor actually writes it down — one `<product> <qty>pc` per line, blank lines between groups, an optional group label (`Serums`, `Gummies`) as the first line of a group — click Place Order, and every line shows up in an editable review modal (product picker via the existing `x-searchable-select`, quantity, live price/GST/amount) before Confirm Sale calls the same `SalesService::createSale()` every other sale already goes through. No new Sale/SaleItem/pricing/GST logic — this is a new front door onto the existing GST-invoicing pipeline, not a parallel one.

New `app/Services/SmartSalesParser.php` does the free-text → product matching, composing (not modifying) `App\Services\Import\ProductMatcher`: split on blank lines, treat a group's first line as a category label if it has no trailing quantity, apply a small synonym table (`dtan`→`de tan`, `vit c`→`vitamin c`, `spf`→adds `sunscreen`, common typo fixes), append the label to the query, then match. **Verified live against the `aradhya` tenant DB that this label-appending is load-bearing**: bare fragments like `Kojic acid`, `Shilajit`, `Acne` score `new` (no match) from `ProductMatcher` alone — too short relative to full catalog names for `similar_text` — but resolve cleanly once the section label is appended (`kojic acid serum` → 84%). A second fallback (`wordOverlapCandidates()`, tried only when `ProductMatcher` returns `new`) scores by whether every significant word the user typed appears somewhere in a candidate name, which catches short fragments `similar_text`'s contiguous-substring scoring misses entirely (`de tan scrub` vs `De Tan Face Scrub Tube 100ml`, `acne serum` vs `Acne Face Serum 30ML`). Both layers spot-checked end to end against the real catalog with a realistic 22-line order: all 22 lines resolved to a sane candidate.

**Found and fixed while building this:** 21 products in the real `aradhya` catalog were `is_active = true` with their `name` literally equal to their SKU (`AE-SH-ROSEMARY`, `AE-SERUM-1`, `AE-SCRUB-2`, ...) — leftover fixtures from `ReproduceInvoiceAe0001Seeder`, each a duplicate of a real, properly-named catalog product, still sellable and competing in every fuzzy-match/product-picker across the app. Deactivated (`is_active = false`) after confirming with the user; `sale_items` on the historical `AE/26-27/001` invoice still reference these rows by id so that invoice is unaffected. **Also found:** the `settings` table has essentially no `company_*`/`bank_*` rows populated for this tenant (only `company_state_code`) — every invoice, including this one, currently prints the `config('app.name')` fallback ("Aradhya ERP") instead of the real business name/address/GSTIN/bank details shown on the reference invoices. Flagged to the user, not fixed here (would mean writing real bank account details into `settings` on their say-so) — `/settings` → General is where that needs to happen before any of this is used for a real customer-facing invoice.

Added real file generation, which didn't exist before (the only "PDF" was browser print-to-PDF): `barryvdh/laravel-dompdf` (new dependency — plain `dompdf/dompdf` was not installed even transitively) renders the *same* `resources/views/sales/invoice.blade.php` used for print, now also reachable as `GET /sales/{sale}/invoice/pdf`; `app/Exports/SaleInvoiceExport.php` (same `FromArray`/`WithEvents`/`WithTitle` shape as the existing `PurchaseOrderExport`) reproduces that layout as an `.xlsx` at `GET /sales/{sale}/invoice/excel`. **dompdf gotcha worth remembering:** the invoice view's `font-family: Arial, Helvetica, sans-serif` rendered the ₹ symbol as `?` in the PDF — dompdf's Arial/Helvetica substitution font lacks the glyph, its bundled DejaVu Sans doesn't. Fixed by adding `'DejaVu Sans'` first in the stack; browsers don't have that font installed so print/on-screen rendering is unaffected, dompdf matches it literally. Verified by rendering an in-memory (unsaved) `Sale`/`SaleItem`/`Customer` against the real product catalog — confirms the fix without touching real data, consuming a real invoice number, or moving real stock.

New `tests/Feature/SmartSalesEntryTest.php` (5 tests) covers: parser resolution of section-labelled short fragments against real catalog names, full place-order→confirm→Sale-created flow via `Livewire::test()`, confirm being blocked while a line is unresolved and unblocking once fixed, the Excel export carrying values not formulas, and the PDF rendering to real `%PDF` bytes. Full suite: 89/89 passing.

**2026-09-02 — Smart Sales Order Entry: target-amount auto-suggestion added as a second way to build the order.**

Same screen (`/sales/smart`), same review modal from the entry above — the ask was "enter a total amount, get an auto-suggested order with almost equal quantity based on available stock," reusing the paste-text flow's preview rather than a separate screen. New `app/Services/SalesOrderPlanner.php` does the allocation, deliberately *not* by extending or parameterising the existing `OrderPlannerService` (purchases): that service requires the buyer to tick specific products first and then spends the budget unevenly by rupee (cheap items get more pieces); this one has no product list to start from — it plans directly off whatever a warehouse has in stock — and the confirmed interpretation of "almost equal quantity" (asked and confirmed with the user, not assumed) is literal: every eligible in-stock product gets the *same* quantity N, each individually capped at its own stock, with N chosen by trying N=1,2,3... and keeping whichever total lands closest to the target (ties keep the lower N, i.e. prefer undershoot). Verified against the real `aradhya` warehouse (30 products currently have stock rows there): target ₹50,000 → all 30 get 8pc each, ₹47,536 total; target ₹5,00,000 → every product saturates at its own stock ceiling once N passes it (quantities fan out from a shared 8 to each product's individual max), ₹2,56,867 total — confirms the "equal until stock runs out, then capped" shape works as intended, not just in the two/three-product unit tests.

Rate used for target-matching is `PricingService::listedPrice()` (the customer tier's GST-inclusive figure) — same reasoning `OrderPlannerService` uses for purchase targets: a budget is spoken in the inclusive number the rate list quotes, not the tax-exclusive figure a line stores. `SalesOrderPlanner::plan()` only ever returns `{product, quantity}` pairs; nothing about price or tax is decided here, so — same as the pasted-text path — `SalesService::createSale()` re-derives everything when the plan is actually confirmed.

UI: a second card on the Smart Order Entry screen, "Or auto-suggest by amount" — target amount + an optional category filter (reuses the same category-dropdown pattern as `QuickInvoice`/`SmartOrderBuilder`) + an "Auto-Suggest Order" button. Auto-suggested rows get their own blue "Auto-suggested" badge in the review modal (alongside the existing green "Matched" / amber "Check match" / red "Select a product" badges from the text-parsing path) but are otherwise identical editable rows — same product-picker override, same quantity input, same Confirm Sale gate. 4 new tests (2 unit tests on the planner's equal-quantity/stock-capping behaviour and empty-stock edge case, 2 on the Livewire `autoSuggestOrder()` flow including validation). Full suite: 93/93 passing.

**2026-09-02 — Production incident: "This cache store does not support tagging" on Smart Sales Entry. Root cause is `CACHE_STORE=database`, not the new screen.**

Reported against `smart-sales-entry.blade.php`, but the actual cause is environment-wide, not specific to that view: `config/tenancy.php`'s `CacheTenancyBootstrapper` (`bootstrappers` list, line ~44) tags *every* `Cache::` call app-wide with `tenant{id}` for isolation between tenants — required, since without it a cached value like `setting:company_name` would be one flat key shared across every tenant's database, and Tenant B could be served Tenant A's company name/GST settings on a cache hit. Tagging only works on `array`, `redis`, or `memcached` stores; both `.env`'s `CACHE_STORE=database` (confirmed set explicitly, not just the `config/cache.php` fallback) and the `file` driver throw this exact exception on the first tagged `Cache::` call once a tenant is initialized. `Setting::get()` (`app/Models/Setting.php`) is what triggers it here — `PricingService`/`OrderPlannerService` both call `Setting::get()` on every priced line, so any page pricing a sale or purchase (not just Smart Sales) hits this the moment it's requested for real.

**Why 93/93 passing tests never caught this:** `config/tenancy.php` deliberately runs zero bootstrappers when `APP_ENV=testing` (comment at line ~37 explains why — no per-tenant DB/cache/disk exists in the sqlite `:memory:` test setup), so `CacheTenancyBootstrapper` — and this bug — never executes in the test suite at all. `phpunit.xml` also independently forces `CACHE_STORE=array` for tests, which would have masked it even if bootstrappers did run. This class of bug is only reachable through a real tenant-initialized HTTP request or console command — worth remembering before assuming "tests are green" means an env-dependent path is fine.

**Fix applied locally**, verified end-to-end (real `.env` → `config/cache.php` → `CacheTenancyBootstrapper` → `Setting::get()`, no override): `CACHE_STORE=database` → `CACHE_STORE=array` in `.env`. No PHP redis extension, `predis`/phpredis composer package, or reachable Redis server exists in this dev environment (checked), so `redis` isn't a same-session option here even though `.env` already carries `REDIS_HOST=127.0.0.1`/`REDIS_CLIENT=phpredis` boilerplate and `claude.md`'s own deployment diagram names Redis as the intended production cache — that boilerplate was never wired to anything running. `array` has no cross-request persistence (every `Setting::get()` re-hits the DB each request) but that's a single cheap indexed lookup, not a real cost, and is strictly better than the current crash.

**Correction — production's `.env` isn't hand-edited at all.** `deploy_production` in `.gitlab-ci.yml` regenerates the whole `.env` from a heredoc template on *every* push to `master` (it only preserves `APP_KEY` from the existing file — everything else, `CACHE_STORE` included, is overwritten). That template had `CACHE_STORE=database` hardcoded (line ~101), so a manual server-side edit would have been silently reverted by the next deploy. Fixed at the actual source: `.gitlab-ci.yml`'s `CACHE_STORE=database` → `array`. This ships automatically the next time `master` is pushed — no server access needed, unlike what this entry originally said.

**2026-09-02 — Public homepage rebranded as "AI Powered ERP"; Smart Sales/Smart Purchase surfaced as headline features.**

Extended the existing `resources/views/marketing/home.blade.php` (kept its ledger-themed design rather than replacing it) — updated `<title>`/meta description and eyebrow copy, added an "AI-Powered" section between the hero and "How it works" with feature cards for Smart Sales (paste-an-order / auto-suggest-by-amount) and Smart Purchase, and a new `#smart` nav link. No backend change; purely marketing copy + a new CSS block (`.mk-ai-grid`, `.mk-ai-card`).

**2026-09-02 — Smart Sales auto-suggest: added a multi-select product picker so a specific hand-picked set of products can be targeted instead of always the whole catalog/category.**

`SmartSalesEntry` gained `selectedProductIds` (array) + `productPickerSearch`, rendered as a chip-and-checkbox multi-select dropdown (same visual language as the rest of the screen, client-searchable via Alpine). `autoSuggestOrder()` now queries `whereIn('id', $selectedProductIds)` when any are picked, falling back to the previous category-filtered whole-catalog query when none are. New tests cover both paths (`test_auto_suggest_with_hand_picked_products_only_allocates_across_that_selection` alongside the existing whole-catalog test).

**2026-09-02 — UI bug: product-search dropdown rendering clipped/cramped, reported via screenshot on the Sale creation screen.**

Two compounding causes, both CSS: (1) the Product column in `sale-form.blade.php`'s items table had no minimum width, so the dropdown panel inherited a cramped parent width; (2) the items table wrapper used `overflow-x-auto`, and by CSS spec setting `overflow-x` to anything but `visible` forces `overflow-y` to `auto` too even when never set explicitly — which clips any absolutely-positioned descendant (the dropdown panel) trying to render outside the wrapper's bounds. Fixed both: added `min-w-[220px]` to the Product `<th>`, dropped `overflow-x-auto` from the wrapper. Found and fixed the identical pattern pre-emptively in the new `smart-sales-entry.blade.php` review-modal table (same wrapper shape, same risk), with a comment explaining why the wrapper is a plain `<div>` and not `overflow-x-auto`. No browser available in this session to visually confirm — verified by Blade-syntax-checking only (`Blade::compileString()`); flagged to the user as needing a real look.

**2026-09-02 — Production incident: invoice PDF/Excel download crashed with `InvalidArgumentException: The filename and the fallback cannot contain the "/" and "\" characters`.**

`InvoiceController::pdf()`/`excel()` used `$sale->invoice_number` (formatted like `AE/26-27/001`) verbatim as the `Content-Disposition` download filename; Symfony's `HeaderUtils::makeDisposition()` rejects `/` and `\` in that value outright, so every invoice download 500'd — not just this one sale, every sale, since every invoice number contains a `/`. Fixed with a `downloadFileName()` helper that swaps `/` and `\` for `-` before handing the name to `Pdf::download()`/`Excel::download()`. Two regression tests added: one reproduces the exact crash by asserting the raw invoice number throws at the same call site, the other proves the sanitised name downloads cleanly (HTTP 200 + correct `Content-Disposition`) for both formats. Full suite: 97/97 passing. Deployed via pipeline `2812612593` on `master` — confirmed `test`/`deploy_production` both `success` after two earlier polling attempts gave false/ambiguous results (a short-SHA API filter that doesn't reliably match, then a list-ordering race between two separate calls) that were caught and corrected before being reported as done.

**2026-09-02 — Invoice header formatting: company/buyer address blocks were dumping raw GST-certificate field labels instead of clean prose.**

Reported via screenshot against the live invoice: both Aradhya Enterprises' own `company_address` setting and the "V.M & Sons" customer's `address` column had been typed straight off a GST certificate, label included (`"Building No./Flat No.: OPP 90\nRoad/Street: KHARASIYAN WALI GALI\n..."`), so the invoice rendered one raw labelled line per `<br>` instead of the flowing "90, Kharasiyan Wali Gali, Arya Samaj Mandir, ..." style of the reference invoice PDF. `GstCertificateParser` itself was checked and does *not* produce this shape (only `District:` ever gets a label) — this was data typed directly into the free-text Settings/Customer address box, not an import-pipeline bug.

Fixed at render time rather than by hand-editing data (no production DB access from this session, and a render fix also protects any other customer with the same habit): new `app/Support/AddressFormatter::flow()` strips a recognised GST-certificate label prefix off each line, drops empties, collapses a value that exactly repeats the line before it (City and District are frequently the same place), and joins what's left with `, `. Applied to both the seller box and buyer box in `sales/invoice.blade.php` (replacing `nl2br(e(...))`) and to the matching two spots in `SaleInvoiceExport.php` (replacing an ad-hoc `str_replace("\n", ', ', ...)` that had the same label-leaking problem). Also added two new optional Settings fields the reference invoice needed that this app had no column for at all — `company_owner` ("(RAJNI MEHRA)" prefix before the address) and `company_pincode` (the reference address ends "... Amritsar, Punjab - 143006"; there was no pincode field to source that from). Both are additive and opt-in — blank by default, nothing rendered until filled in via Settings → General. 5 new unit tests on `AddressFormatter::flow()` plus one full-render regression test (reproduces the exact raw label text from the screenshot, asserts the labels don't leak into the rendered HTML and the owner/pincode render correctly). Full suite: 103/103 passing.

**2026-09-02 — Products list: added a "Qty Available" column and checkbox multi-select bulk delete.**

`ProductIndex::render()` now eager-loads `withSum('stockBalances', 'quantity')` (new `Product::stockBalances()` relation) so the list shows total stock across every warehouse per product in one query, colour-coded the same way `StockBalanceIndex` already does (red at zero, amber at-or-under `reorder_level`, plain otherwise) rather than inventing a new convention. Multi-select: a `selected` array + per-row/"select all on this page" checkboxes (gated behind `products.delete`, matching the existing single-row Delete button's permission), a `bulkDelete()` action, and a bulk-action bar that only appears once something is selected. Selection intentionally scoped to the current page and cleared on search/page change rather than tracked across the whole result set, to keep "select all" unambiguous. 6 new tests (`ProductIndexTest`) cover the stock sum (including the zero-stock/no-`StockBalance`-row case), bulk delete leaving unselected rows alone, the permission gate, and selection clearing on search. Full suite: 109/109 passing.

Both of the above committed as `656a00f` and deployed via pipeline `2813603306` — confirmed `test`/`deploy_production` both `success` by resolving the exact pipeline ID from a single `?ref=master&per_page=5` call rather than a short-SHA filter (the class of ambiguity documented in the cache-tagging incident above).

**2026-09-02 — Products list: added sortable column headers and advanced filters (category/status/stock level), directly to answer "search products with 0 quantity".**

Sorting: new reusable `<x-sortable-th>` component (click a header to sort by it, click again to flip direction; an arrow indicator shows the active column/direction) — built as a shared component rather than one-off markup since the user asked for sorting "in grids" generally, so it's ready to drop onto another index screen later without rebuilding it. `ProductIndex::sortByColumn()` whitelists sortable keys (`name`, `sku`, `category`, `mrp`, `selling_price`, `stock`, `status`) against a `SORTABLE_COLUMNS` const, and `render()` re-validates `$sortBy`/`$sortDirection` again before use — defence in depth, since Livewire public properties aren't fully protected from being set directly by a crafted request even without a `wire:model` binding on them. Category sorts via a correlated subquery (`orderBy(Category::select('name')->whereColumn('id', 'products.category_id'))`) rather than a join, avoiding join/pagination-count interaction entirely. Qty Available sorts on the existing `withSum` alias directly (`orderBy('stock_balances_sum_quantity', ...)`) — MySQL and sqlite both allow ordering by a SELECT-list alias even though, as below, neither allows filtering on it via `HAVING` without a real aggregate present.

Filtering: Category and Status dropdowns are straightforward `where()`s. The Stock Level dropdown (All / Out of stock / Low stock / In stock) is the one that answers the literal ask — first attempt used `havingRaw()` against the `withSum` alias and failed in sqlite (`HAVING clause on a non-aggregate query` - sqlite, unlike MySQL, refuses `HAVING` on a query it doesn't consider to contain a real aggregate function, and a correlated-subquery `SELECT` column doesn't count even though `SUM()` appears inside it) - this surfaced immediately via the test suite, not in production, specifically because the tests use sqlite. Rewritten as a plain `WHERE` against a repeated raw scalar subquery (`coalesce((select sum(quantity) from stock_balances where stock_balances.product_id = products.id), 0)`) instead of the `withSum` alias, which is portable across both drivers and doesn't depend on aggregate detection at all. "Out of stock" deliberately treats a product with zero matching `StockBalance` rows (never had opening stock recorded) the same as one with an explicit `0` balance — both are `coalesce(..., 0) <= 0` — since a user searching "0 quantity" means both. "Low"/"In stock" compare against each product's own `reorder_level`, matching the colour convention the Qty Available column and `StockBalanceIndex` already use. A "Clear filters" link appears once any filter is active, and the empty-state message distinguishes "no products at all" from "no products match these filters." 11 new tests cover every sort column, the category-name subquery sort, all three stock-level bands (including the no-`StockBalance`-row case), and filter reset. Full suite: 120/120 passing. Committed `e3a4668`, deployed via pipeline `2814017949` (`test`/`deploy_production` both `success`).

**2026-09-02 — Products list: added a page-size selector; fixed a real checkbox desync bug; blocked deleting products/bulk-deleting products that still have stock.**

Page size: `perPage` property (10/25/50/100, whitelisted the same defence-in-depth way `sortBy`/`sortDirection` already are — an invalid value falls back to 10 instead of erroring), a "Show [n] per page" control next to the pagination links, changing it resets to page 1.

**Checkbox bug (user-reported via screenshot: every row showing checked while the header "select all" checkbox showed unchecked).** Root cause: `selectAll` was a separately-tracked boolean only ever changed by clicking the header checkbox itself — checking rows individually (or any path other than that one click) updated `$selected` correctly but left `$selectAll` stale, so the header stopped reflecting reality the moment a user selected rows any other way. Removed `$selectAll` entirely; the header's checked state and its own click behaviour are now both derived fresh from `$selected` vs. the current page's ids on every render (`$allSelected = count($pageProductIds) > 0 && count(array_diff($pageProductIds, $this->selected)) === 0`) — makes this class of desync structurally impossible rather than patching the specific case reported.

**Stock guard on delete.** Neither `delete()` nor `bulkDelete()` checked stock before deleting — mirrors the existing `CategoryIndex::delete()` pattern (blocks deleting a category still assigned to products) applied to the same problem for products: `Product::withSum('stockBalances', 'quantity')` before delete, and if quantity available > 0, the delete is refused with `session()->flash('error', ...)` naming the product and its exact quantity (e.g. `Cannot delete "Rosemary Shampoo" — it still has 12 units in stock.`) instead of silently orphaning stock history. `bulkDelete()` is all-or-nothing, same as the single-row guard: if *any* selected product still has stock, the whole batch is refused and every blocking product is named in one message, rather than partially deleting and leaving the user to figure out which ones didn't go through.

7 new tests: select-all reflecting individually-checked rows, delete blocked/allowed by stock level (single and bulk), per-page bounds and reset-to-page-1. Full suite: 127/127 passing.

**2026-09-02 — Sorting, filtering, and pagination rolled out to every remaining list page in the app, following the pattern already proven on Products; three real "in use" delete guards added along the way.**

Asked to review every list/grid page and add sorting/filters/pagination "where required," with a recommendation on whether a proper datatable library was worth adopting. **Recommendation given and followed: no client-side JS datatable.** This app is 100% server-driven Livewire with zero JS grid dependencies today; DataTables.net/Tabulator/AG Grid would mean two competing DOM-ownership systems fighting each other for something Livewire's own pagination already does natively. Instead extracted the two genuinely reusable pieces from the Products work: `app/Livewire/Concerns/HasSortableColumns.php` (the click-to-sort toggle/whitelist-validate logic - each component still declares its own `sortBy`/`sortDirection`/`SORTABLE_COLUMNS`/`sortByColumn()`, only the risky toggle mechanics are centralised, to sidestep trait/property-default collisions) and `resources/views/components/per-page-select.blade.php` (the "Show N per page — N products" control, extracted verbatim from `product-index.blade.php`). `ProductIndex` itself was refactored onto both first, as a correctness proof - its full 24-test suite stayed green through the refactor before touching anything else. `<x-sortable-th>` (already existed) needed no changes.

**Audited via 3 parallel Explore agents** covering all 13 `*Index.php` Livewire components, then applied consistently:
- **Simple CRUD grids** (Category, Customer, Supplier, Warehouse, User, Role) - all had `search` + fixed `orderBy('name')` + hardcoded `paginate(10)`, no dropdown filters. Added sortable headers, per-page selectors, and per-entity filters (status everywhere; type on Customers; role on Users). `RoleIndex` is the one deliberate exception - `RolePermissionSeeder` seeds 9 roles and a tenant realistically never has many more, so it keeps its existing plain `->get()` with no `WithPagination` at all; only sortable headers were added there (the trait's `toggleSort()` now guards its `resetPage()` call behind `method_exists($this, 'resetPage')` specifically so it works for this non-paginated consumer too). **Naming collision caught before it shipped**: `UserIndex` already had `public bool $is_active` and `public ?string $role` as create/edit **form fields** - new list filters there are `statusFilter`/`roleFilter`, never reusing the form's own property names (a regression test - `test_status_and_role_filters_do_not_leak_into_the_edit_form_fields` - proves opening the edit modal doesn't clobber the active filter state or vice versa).
- **Inventory grids** (`StockBalanceIndex`, `BatchIndex`, `StockMovementIndex`) - each already had 2-5 custom filters (warehouse/expiry/type/date-range) via `WarehouseAccessService`-scoped queries, but zero test coverage and no way to sort or resize pages. Added sortable headers (preserving each grid's sensible default order - Stock Balance still defaults to most-recently-updated, Batches to soonest-expiring, Movements to newest-first), per-page selectors, and a "Clear filters" control on `StockMovementIndex` specifically (it has the most filters of any grid - 5 - and previously no way to reset them together). Baseline tests added for the pre-existing filters too, not just the new sort/per-page bits, since this was the first coverage these three components ever got.
- **Transaction grids** (`SaleIndex`, `PurchaseOrderIndex`) - added sortable headers plus a `fromDate`/`toDate` filter on `sale_date`/`order_date` that didn't exist before (mirrors the shape `StockMovementIndex` already had - invoices/POs are naturally searched by date range).
- **Excluded**: `ReportsIndex` is a static 7-card menu (no query, no pagination at all) linking to 6 separate report screens - a different UX class, out of scope.

**Cross-entity sort mechanics worth remembering:** a relationship column (Customer/Supplier/Product name shown on a row that belongs-to it) sorts via a correlated subquery - `orderBy(Customer::select('name')->whereColumn('id', 'sales.customer_id'), $direction)` - same pattern `ProductIndex` already used for Category, now reused for Sale→Customer, PurchaseOrder→Supplier, StockBalance→Product/Warehouse, Batch→Product/Warehouse. A `withCount`/`withSum` alias (Category's `products_count`, Role's `users_count`/`permissions_count`, StockBalance's `stock_balances_sum_quantity`) sorts fine via plain `orderBy('alias', $dir)` - both MySQL and sqlite allow ordering by a SELECT-list alias. A value computed from **two** aggregates (Customer/Supplier "Balance" = sold/received minus paid) needed `orderByRaw("(COALESCE(a,0) - COALESCE(b,0)) {$dir}")` instead, since no single alias holds the difference.

**Three new "in use" delete guards**, bundled in because the pattern already existed for 3 of 8 deletable entities (`CategoryIndex` blocks a category with products, `RoleIndex` blocks Super Admin + roles with users, `ProductIndex` blocks a product with stock) and real FK relationships confirmed the other three needed it too, not just for consistency: `stock_balances`/`stock_transfers`/`stock_movements`/`purchase_orders` all reference `warehouse_id`; sales/payments reference `customer_id`; purchase orders reference `supplier_id` - deleting any of these before today either threw a raw DB constraint error or silently orphaned history.
- `CustomerIndex::delete()` now blocks if the customer has any `sales` or `payments` rows, naming the customer.
- `SupplierIndex::delete()` now blocks if the supplier has any `purchase_orders`, naming the supplier.
- `WarehouseIndex::delete()` now blocks if the warehouse has stock (new `Warehouse::stockBalances()` relation added), naming the warehouse - same shape as the Product stock guard.

11 new test files (`CategoryIndexTest`, `CustomerIndexTest`, `SupplierIndexTest`, `WarehouseIndexTest`, `UserIndexTest`, `RoleIndexTest`, `StockBalanceIndexTest`, `BatchIndexTest`, `StockMovementIndexTest`, `SaleIndexTest`, `PurchaseOrderIndexTest`), proportionate depth rather than exhaustive per page - `ProductIndexTest`'s 24 tests already prove the shared toggle/whitelist mechanics, so each new file covers roughly 4-7 tests: one sort-toggle check, one per new filter, one per-page check, one per delete guard where applicable. Full suite: 179/179 passing (up from 127). Not yet pushed.

---

## Current Business Context

The initial business has approximately 100+ product SKUs and an inventory purchase target around ₹3 lakh.

The existing supplier order is maintained in Excel.

The ERP should replace manual inventory tracking and make supplier order uploads a fast, reliable workflow.

The goal is not to build a huge ERP immediately.

Start with:

**Inventory + Purchase + Sales + Payments + Profit + Reports**

Then add intelligent search and AI.

---

## Deployment

Production:

`https://erp.aradhyaenterprisecom`

Expected architecture:

```text
Cloudflare / SSL
↓
Nginx
↓
Laravel
↓
PHP-FPM
↓
MySQL
↓
Redis
```

Never expose MySQL publicly.

Use environment variables for production credentials.

---

## Project Memory Rules

Claude should remember and update:

- Important architecture decisions
- Database design decisions
- Business rules
- Completed sprints
- Current sprint
- Pending work
- Known issues
- Security decisions
- UX decisions
- Deployment decisions
- Important supplier/import rules

Do not store temporary debugging details as long-term memory.

When a new decision changes an existing rule, update project memory and clearly identify the new decision.

---

## Current Development Rule

Before beginning a major implementation:

1. Inspect the existing code.
2. Inspect the relevant database structure.
3. Identify affected modules.
4. Explain the implementation approach when significant.
5. Implement incrementally.
6. Run relevant tests.
7. Verify no regression.
8. Update project memory with important decisions.

Never assume an existing implementation without inspecting it first.

---

## Project Vision

The long-term ERP experience should follow:

**SEARCH → UNDERSTAND → ACT → HISTORY → INSIGHT**

The ERP remains deterministic and reliable underneath.

AI enhances discovery and decision-making without controlling core transactions.

The most important priorities are:

**Simple + Fast + Reliable + Secure + Auditable + Intelligent + Easy Excel Import**
