# 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 13 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` is still unpopulated for all 138 products in the real tenant data
  (invoices print "—" until it's filled) - the tooling to fix this in bulk now exists
  (Products page: HSN column/filter + multi-select "Set HSN code" action, 2026-09-07
  entry below), but entering the actual codes is a business data-entry task, not
  something to guess at in code.
- E-invoice / IRN generation is not implemented.
- E-way bill / GSP integration not implemented (parked 2026-10-13). Idea: supplier
  e-way bills generated against our GSTIN currently only surface as an SMS from
  NIC-GSTN; no way in the ERP to see or reconcile them against Purchase Orders.
  Two integration paths: direct NIC e-way bill API (free, but needs invoice-volume
  threshold + static IP whitelisting + digital cert) or a GSP/ASP provider (ClearTax,
  Masters India, Alankit, Cygnet - paid, much faster to integrate). Planned shape: a
  scheduled job polls "e-way bills against my GSTIN", matches each to a PO by supplier
  GSTIN + value + date, and flags unmatched ones in a new GST reconciliation report.

---

## 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-29 (later same day) — Near-miss: almost overwrote an already-correct seeder from a month earlier because prior art wasn't checked first.**

The user supplied the actual master "7 Days Natural" rate list (117 products,
"rate list-7 days.pdf" - the file claude.md's own "Excel Import" section already describes)
and asked for a seed script covering whatever's still missing locally. Without first
checking whether this had already been done, a brand-new `SevenDaysNaturalRateListSeeder`
was written from scratch, transcribing all 117 rows raw from the PDF, and run against the
local aradhya tenant.

**`git status` immediately flagged the new file as `M` (modified), not untracked** - a
`SevenDaysNaturalRateListSeeder` already existed, committed 2026-08-27
(`2906e7d feat(products): seed 7 Days Natural rate list catalog (117 items)`), and it was
materially better than the just-written replacement:
- Cross-referenced GST rates against a real invoice via a companion
  `ReproduceInvoiceAe0001Seeder` (Shampoo/Gummies at 5%, everything else 18%) - the freshly
  written version left every `gst_rate` at 0, since the rate list itself has no GST column
  and nothing else was checked against.
- Populated `box_quantity`, explicitly for `OrderPlannerService`/Smart Order Builder to
  allocate against - a real downstream need the from-scratch rewrite didn't know existed.
- Normalized names (Title Case, leading "-" stripped) and matched existing products
  case-insensitively via `update()` on a match - keeping the catalog corrected on re-run,
  rather than the rewrite's raw-PDF-formatting names (`"- STRETCH MARK CREAM  100GM"`,
  ALL CAPS) plus `firstOrCreate` (leave-alone on any match).

**And the original had already been run against this exact local database** - all 117
products were present with the `notes = 'Seeded from 7 Days Natural rate list.'` marker
and correct `box_quantity`/`gst_rate`, confirmed by querying before touching anything
further. The task the user asked for was already fully done a month earlier; there was
nothing left to build.

Because the rewrite's raw-formatted names didn't match the original's cleaned-up names even
case-insensitively (a leading "-" or ALL-CAPS vs Title Case defeats a plain case-insensitive
compare), running it had already created **51 genuine duplicate products** (ids 190-240,
all `notes IS NULL`, all timestamped to that one run) before this was caught. Reverted the
seeder file (`git checkout --`) and deleted exactly those 51 rows by id range + the
`notes IS NULL` marker, verified back to the pre-existing correct state (117 rate-list
products + the 50 from the invoice-derived seeder two entries below + other base data = 189
total, matching what was there before this detour started).

**The lesson, for next time**: check for existing seeders/prior art (`git log --follow` on
the file path being about to write, or at minimum a `Glob`/directory listing of
`database/seeders/`) before writing a new one from a user-supplied document - especially
when the request is phrased as "add the products from this file," which reads like a fresh
task but may already have been solved. No code change resulted from this entry; it exists
purely as the record of the near-miss and the cleanup, per this log's own stated purpose.

**2026-09-30 — Notifications module completed: full inbox page, cached + visibility-aware polling, and a real bug fix — the daily alert command never ran inside a tenant.**
Sprint 8 shipped the bell dropdown and nine `database`-channel notification classes; the spec's
"Notifications" module never got a page of its own. User asked to finish it using **local caching +
polling instead of Node/websockets** (no Redis, no socket server on this host).

- **`/notifications` (`NotificationIndex`, route `notifications.index`)** — the signed-in user's own
  inbox: All/Unread/Read, type filter, text search, mark read/unread, delete, Mark all read, Clear
  read (never touches unread), page-size selector. Deliberately *not* permission-gated (every role
  has an inbox); every query goes through `auth()->user()->notifications()`, so a foreign id is
  simply a no-op (covered by a test). Sidebar link added; the bell gained "View all notifications".
- **Polling:** `wire:poll.visible.30s` on bell and page — hidden tabs stop polling. A closed bell no
  longer queries the list at all (only the count); the list loads when opened. If a poll sees the
  unread count *rise* it dispatches `notification-arrived`, which shows a 6s toast.
- **Local cache** (`NotificationService::unreadCount()`/`forgetUnreadCount()`): unread count per user,
  60s TTL, invalidated immediately by a `NotificationSent` listener
  (`ForgetUnreadNotificationCount`, auto-discovered) and by every read/unread/delete action. **The
  store is built with `Cache::build(['driver' => 'file', ...])`, not `Cache::store()`, on purpose:**
  stancl's `CacheTenancyBootstrapper` forces cache *tags* onto every named store, and file/database
  drivers don't support tags (this is the same root cause as the 2026-09-02 "does not support
  tagging" incident, and `.env` here has `CACHE_STORE=array`, which caches nothing across requests).
  `build()` is a real method on the base manager so it bypasses the tag-forcing `__call`. The path is
  under `storage_path()`, which the filesystem bootstrapper already suffixes per tenant (verified in
  tinker: `storage/tenant<uuid>/framework/cache/notifications`), and the tenant key is also in the
  cache key. If this app ever moves to Redis, this can become a plain tagged `Cache::remember`.
- **Bug fixed: `app:generate-business-alerts` did nothing for tenants.** The scheduler runs on the
  central connection, where none of the ERP tables exist (since the Sprint 11 split). The command now
  detects "not in a tenant" and walks every `isUsable()` tenant itself (one tenant failing is reported
  and skipped, not fatal). It still works unchanged under `tenants:run` and in tests. **Not yet
  exercised against real tenant data** — running it creates real queued notifications, so it was left
  for the user to trigger: `php artisan app:generate-business-alerts`.
- **Also fixed:** low/out-of-stock alerts were sent to every `inventory.view` user regardless of
  warehouse restriction, leaking other warehouses' stock levels; now filtered through
  `WarehouseAccessService::canAccess()` (tested).
- Tests: `tests/Feature/NotificationInboxTest.php` (10 tests); full suite 382/382. The suite disables
  tenancy bootstrappers, so the tenant-cache behaviour above was checked separately in tinker.
- Not done: user notification preferences/muting, email/push delivery, per-day digest.

**2026-09-29 — Import Order preview's Match column is now the same searchable picker as everywhere else; a real "7 Days" product catalog seeder for local dev.**

Two small, unrelated fixes requested together after the PDF parser fix above went live:

1. **The Match dropdown in `ImportWizard`'s preview table was a plain HTML `<select>`**
   with `<optgroup>`s ("Suggested matches" / "All products") - fine for a handful of rows,
   unusable once a real invoice like the PI-370 one puts 50+ options in every row's list.
   Replaced with the same `<x-searchable-select>` component every other product picker in
   this app already uses (`SaleForm`, `StockTransferForm`, etc.). That component takes a
   flat `id`/`name` list with no native grouping concept, so the "New Product"/"Skip this
   row" pseudo-options and any fuzzy-match suggestions are flattened into one combined,
   searchable list per row instead (suggestions prefixed "★ Suggested:" so they're still
   visually distinct without a real `<optgroup>`). No backend change needed -
   `x-searchable-select` writes through `$wire.set()`, which fires the exact same Livewire
   `updated()` lifecycle hook a native `wire:model` select already did.
2. **Local dev's `products` table had zero real product names** - `DemoDataSeeder` only
   ever created synthetic placeholders ("AE-SERUM-1", "AE-CREAM-DETAN-100", etc.), so testing
   the PDF/Excel import matcher locally was meaningless: every real invoice row came back
   "New Product" regardless of whether the app's matching logic actually worked. New
   `SevenDaysProductCatalogSeeder` (`php artisan tenants:seed
   --class=SevenDaysProductCatalogSeeder --tenants=<id>`) adds the real 50-product "7 Days
   Organic" catalog (verbatim names/rates/GST from the PI-370 invoice fixture) to a tenant's
   product table, idempotent by name via `firstOrCreate`. Run once against the local
   "aradhya" tenant database (139 -> 189 products).
   **Important scope note**: this only touched the LOCAL XAMPP MySQL copy of the aradhya
   tenant's data (`erp_aradhyaenterprise` on localhost) - a separate, stale local database
   from whatever the actually-deployed production server at erp.aradhyaenterprise.com has.
   This session has no access to the real production database at all, so nothing here
   changed what the user sees on the live site; it only makes *local* testing of the import
   feature meaningful going forward.

Full suite green (359/359) after the dropdown change; ImportWizardTest specifically
re-verified since it drives the exact component that changed.

**2026-09-28 (later same day) — PurchaseInvoicePdfParser rewritten against a real invoice; the first version's regex never matched anything.**

The user tried the real upload the moment it shipped and got zero rows - every counter on
the preview screen read 0. The session that built the original parser had explicitly
flagged this as a real risk (no actual PDF file was available to test against, only the
user's pasted rendered content) and it turned out to matter: this time the user gave a real
local file path (`C:\Users\...\Downloads\PI 370 ARADHYA ENTERPRISES (1).pdf`), which turned
out to be directly readable from this session (same machine), so the actual
`smalot/pdfparser` extraction could finally be inspected instead of guessed at.

**What the real extraction actually looks like, vs. what was assumed:**
1. **Column order is not the rendered visual order.** PDF text extraction follows the
   document's internal drawing order, and this invoice's is `{sl.no}{description}<TAB>
   {amount}{per-unit}{rate}{quantity} {unit}{gst rate}%` - Amount comes before
   Rate/Quantity/GST, the reverse of how the table reads left-to-right on screen.
2. **The Sl. No. has no separator from the description at all** - e.g. the real text is
   literally `"17 Days Natural Beard Softener Cream..."` (sl.no `1` + `"7 Days Natural..."`),
   and because every product in this catalog is itself branded "7 Days...", a naive
   leading-`\d+` capture would misparse sl.no `1` as `17`. Fixed by tracking the expected
   next sl.no and matching that specific numeral's string length (with a small forward
   lookahead so one skipped number doesn't derail everything after it), never a greedy
   digit-run.
3. **Rate and Quantity are also concatenated with no separator** - e.g. `"48.94144"` is rate
   `48.94` + quantity `144`. Exploited rather than fought: a rate is always exactly two
   decimal places, so matching `\d+\.\d{2}` non-greedily naturally stops in the right place
   and leaves the quantity as the remaining digits.

Re-verified end to end against the real file: all 50 line items parsed, zero invalid, zero
value-mismatches, and the row-level totals reconcile exactly against the invoice's own
printed footer (3,013 PCS / ₹3,83,552.15 subtotal) - not just internal consistency.

**Added a permanent fixture-backed test** (`tests/Feature/PurchaseInvoicePdfParserTest.php`,
`tests/Fixtures/purchase-invoices/7-days-organic-pi-370.pdf`), the same pattern
`CustomerGstImportTest`/`GstCertificateParser` already established for real GST-certificate
PDFs - kept permanently rather than discarded, specifically because this parser was wrong
twice before a real file existed to test against, and a fixture is what would have caught it
immediately. **Note**: like the existing GST-certificate fixtures (which already contain the
user's own real GSTIN/address unredacted), this new fixture contains real data too,
including the supplier's real bank account number from the invoice's bank-details block -
flagged to the user rather than assumed acceptable, since that's a third party's financial
detail rather than the user's own business data.

Full suite green (359/359, the +1 being this new permanent test) after the fix.

**2026-09-28 — Purchase Order GST breakdown (matching the sales invoice format), plus PDF-invoice import and "edit a PO via file upload".**

User attached a real supplier Proforma Invoice PDF and asked for four things: (1) confirm
a PO edit form exists, (2) create/edit a PO from a PDF or Excel upload, (3) make sure
inventory (including batch tracking) updates correctly either way, (4) make "our invoice
generation" match that PDF's format. Investigated before building anything, since #2 and
#3 could easily have collided with an explicit existing rule:

- **#1 was already done** - `PurchaseOrderEdit`/`purchases.edit` already lets you rewrite
  quantities/rates/lines on an `open` PO with zero goods receipts against it (enforced in
  `PurchaseService::updatePurchaseOrder()`, re-checked under a row lock). Nothing to build;
  just pointed the user at the existing "Edit" button on the PO show page.
- **User confirmed, before any code**: (a) uploads should keep the existing Excel-import
  safety checkpoint - a PDF/Excel upload creates/updates a PO only, inventory still moves
  only through the existing Goods Receipt screen (which already handles batch tracking
  correctly) - not skip straight to updating stock; (b) "edit via upload" only needs to
  work for the same `open`+`unreceived` POs the manual edit form already allows, not
  already-received ones.

**GST breakdown added to Purchase Orders** - `purchase_orders` gained
`cgst_total`/`sgst_total`/`igst_total`/`round_off`; `purchase_order_items` gained
`hsn_code` (snapshotted from the product master at line-creation time, same reasoning as
`SaleItem`), `disc_percent`/`disc_amount`, and `cgst_amount`/`sgst_amount`/`igst_amount`.
`PurchaseService::createPurchaseOrder()`/`updatePurchaseOrder()` now run the exact same
`GstService::summarise()`/`apportion()`/`roundOff()` pipeline `SalesService` already uses -
extracted into two new private helpers (`resolveLine()`, `summariseLines()`) shared by both
methods. Inter/intra-state is decided by `GstService::isInterStateForCode()` (existing
`isInterState(Customer)` refactored to delegate to it, zero behavior change) comparing the
company's state code against `Gstin::stateCode($supplier->gstin)` - `Supplier` has no
`state_code` column of its own, but a valid GSTIN's first two digits already are one, so no
schema change was needed there. `PurchaseOrder` gained `isInterState()`/`taxSummary()`
mirroring `Sale`'s. **This changes the saved total for every new/edited PO** (now rounded to
the nearest rupee, like a Sale) - caught two knock-on test failures on the first full-suite
run: `PurchaseOrderPrintTest` (a test-created `PurchaseOrderItem` with no `hsn_code`
snapshot needs the print view to still fall back to `$item->product->hsn_code`, fixed in the
template - the real-world case this protects is every PO created before this migration,
which will have a null snapshot forever) and `SmartPurchaseOrderTest` (its preview-vs-saved
delta assertion widened from 0.05 to 1.0, since the live preview deliberately doesn't apply
the same rounding the saved order now does).

**`purchases/order.blade.php` redesigned to match the reference invoice** - amount in
words, the rate-wise GST summary table, a Round Off row, IGST vs CGST+SGST display, bank
details block, and Disc %/Disc Amt columns (shown only when at least one line actually has
a discount, keeping the common no-discount case clean) - all reusing existing `Setting`
keys and the existing `AmountInWords` support class, no new settings needed. Manual entry
forms (`PurchaseOrderForm`/`PurchaseOrderEdit`) were deliberately left unchanged - discount
input UI wasn't asked for and real supplier invoices for this business don't carry one, so
`disc_percent`/`disc_amount` just default to 0 there; the columns exist and work end-to-end
for whatever does populate them (PDF import, or a future manual UI).

**PDF import**: new `PurchaseInvoicePdfParser` (`app/Services/Import/`), modelled on the
"7 Days Organic Private Limited" invoice layout, reusing `smalot/pdfparser` - already a
project dependency (`GstCertificateParser` uses it for GST certificates), not a new one.
**Caveat, unlike `GstCertificateParser`**: this was NOT verified against a real
`smalot/pdfparser` text extraction of an actual invoice file - no real PDF was available to
the session that wrote it, only the user's pasted rendered content - so its row regex was
only checked against real invoice TEXT LINES copied verbatim into a throwaway unit test
(`parseText()` split out from `parse()` specifically to make that possible without a real
file). Table-shaped PDF extraction is inherently fragile; **the first real upload is this
parser's actual test** - it fails safe (an unmatched line is just not read as a row, never
guessed at), and every parsed row still lands on the existing human review screen before
anything is created, per the Excel Import Safety Rule. `ImportService` now takes both
parsers and picks by file extension; `ImportWizard`'s upload accepts `.pdf` alongside
`.xlsx`/`.xls`, and its preview table shows an editable GST% column only when the parser
actually supplied one (Excel rows still don't carry one - see the existing 2026-08-19 "no
GST-rate column" note above).

**"Edit via upload"**: `ImportService::confirmIntoExisting()` (new) merges the reviewed
rows onto an existing PO's lines by product (quantities add, matching
`updatePurchaseOrder()`'s own dedupe rule) instead of creating a new PO -
`ImportWizard`'s preview step shows a "Create new / Add to an existing open order" choice
whenever the chosen supplier+warehouse has at least one `open`, unreceived PO, listed via a
new `editablePurchaseOrders()` query using the identical rule `PurchaseOrderEdit` already
enforces. Subject to the exact same guard either way, since `updatePurchaseOrder()` itself
throws if the target PO stopped being editable in the meantime (e.g. received elsewhere
mid-review) - now caught in `ImportWizard::confirmImport()` and surfaced as a normal
validation error instead of a 500.

Full suite green (361/361) after these changes, including three throwaway
`PurchaseInvoicePdfParserTest` cases run against the real invoice text - discarded after
passing, per this project's stated convention. `route:cache`/`view:cache` both re-verified
clean.

**2026-09-27 — Smart Supplier Return: return warehouse inventory straight to a supplier, no Purchase Order required, with a debit note that can be emailed.**

User's own request, arriving right after Smart Stock Movement, for the same paste-a-list
UX applied to a different problem: the *existing* supplier-return feature
(`PurchaseService::createReturn()`) is 100% PO-bound — every line must trace back to a
specific `PurchaseOrderItem`, enforced by NOT NULL foreign keys on `purchase_returns`/
`purchase_return_items`. That's the right shape when a return corrects a specific delivery,
but it can't express "we're sending this stock back to the supplier" when it isn't tied to
one receipt. Rather than loosen the PO-bound schema (which `Supplier::receivedValue()`/
`balance()`/`ledgerEntries()`/`activityTimeline()` and the PO-show UI all assume is always
present), this is a **new, separate** `SupplierReturn`/`SupplierReturnItem` model - the
existing PO-bound return, reachable from a specific PO's show page, is untouched.

Two decisions were confirmed with the user before building, since both affect the supplier
ledger's correctness:

1. **`return_type` is a per-return choice: `credit` or `replacement`.** A `credit` return is
   a real debit note - it reduces `Supplier::balance()` immediately
   (`receivedValue()`/`ledgerEntries()` now also sum `supplierReturns()->where('return_type',
   'credit')`). A `replacement` return only moves stock out; the balance is untouched,
   because the supplier owes goods, not money, and the replacement will raise the payable
   normally as an ordinary Goods Receipt when it arrives - including it in the balance now
   would double-count once that happens. `activityTimeline()` shows both types for
   visibility (replacement at ₹0, since it's not a financial event), but `ledgerEntries()`
   (the Statement of Account) only lists `credit` ones.
2. **Valued at cost, not a PO line.** There's no PurchaseOrderItem to borrow a unit cost
   from, so `SupplierReturnService::createReturn()` prices every line off
   `StockBalance.average_cost` at the moment of return - same reasoning as
   `StockTransferService`'s source-warehouse costing. Tax is a flat `line_total *
   tax_rate/100` with no CGST/SGST/IGST split, deliberately matching the existing
   `PurchaseReturn`'s own shape (`Supplier` has no `state_code` column the way `Customer`
   does, so there's no reliable inter/intra-state signal to split on for either purchase-side
   return type).

**A printable Debit Note is new** (`resources/views/purchases/debit-note.blade.php`,
`SupplierDebitNoteController`) - this app had a customer-side Credit Note
(`sales.credit-note`, added 2026-09-07) but nothing on the purchase side. Mirrors the credit
note's structure (seller block, party block, line items, amount in words, declaration) minus
the GST rate-wise summary table, since there's no interstate split to summarise.

**Emailing the debit note is this app's first outbound email-with-attachment.** Every prior
`app/Notifications/*` class uses the `database` channel only (in-app notifications); nothing
here previously sent mail to an *external* party. `SupplierDebitNoteNotification implements
ShouldQueue`, sent via `Notification::route('mail', $supplier->email)->notify(...)` (a
`Supplier` isn't a `Notifiable` model), with the debit note PDF rendered at send time
(inside a queued job, so a slow PDF render never blocks the request) and attached via
`MailMessage::attachData()`. `SupplierReturn.emailed_at` records the last send for the
show page's "Send by Email" / "Re-send by Email" button.

**Paste-list entry reuses `SmartSalesParser` unchanged** - the exact same pattern
`StockTransferForm` already added the day before, copy-pasted onto `SupplierReturnForm`
with the one real difference being valuation: since a return is always priced (there's no
optional "generate an invoice" toggle to hide behind, unlike a stock transfer), the item
table's rate/tax/amount columns are unconditional here.

No new permission - reused `purchases.return` (create) and `purchases.view` (read), same
reasoning as Smart Stock Movement reusing `inventory.transfer`. New routes:
`purchases.returns.index` / `.create` / `.show` / `.debit-note` / `.debit-note.pdf`, plus a
"Supplier Returns" link added to the Purchase Orders index page's action bar.

Verified with a throwaway `Livewire::test()` suite (credit return's stock+balance math,
replacement return leaving balance untouched, paste-list parsing/folding, an over-return
correctly blocked with the whole `DB::transaction` rolled back, and `Notification::fake()`
confirming the queued email + `emailed_at` stamp) - discarded after passing, per this
project's stated convention. `route:cache` re-verified clean before clearing it back for
local dev.

**2026-09-26 — Smart Stock Movement: batch (multi-product) warehouse transfers, with an optional GST bill priced at the distributor or retailer tier.**

User's own request: move several products between two warehouses in one document instead
of the existing single-product Transfer modal, and optionally generate a bill for the
receiving warehouse valued at a chosen price tier. Two design decisions were confirmed with
the user up front rather than assumed, because both have real accounting/compliance
consequences:

1. **The generated bill is NOT a Sale.** Moving stock between a business's own warehouses
   isn't revenue, so it must never appear in Sales/Profit reports. It's its own document —
   `StockTransferBatch` (header) + `StockTransfer` lines (the existing single-transfer model,
   extended) — with its own numbering series (`transfer_challan_series_prefix` setting,
   default `STO`, via a new optional `$seriesOverride` param on
   `InvoiceNumberService::next()` so it shares the FY-keyed `invoice_series` counter table
   without consuming numbers from the sales invoice sequence).
2. **Always intra-state (CGST + SGST only, never IGST).** `Warehouse` carries no GST
   state/GSTIN of its own (unlike `Customer`, which is what `GstService::isInterState()`/
   `placeOfSupply()` key off) — user explicitly chose not to add those fields rather than
   extend `Warehouse`+`GstService` to support inter-state transfers, so
   `StockTransferService::createBatch()` hardcodes `$interState = false` when calling
   `GstService::summarise()`/`apportion()`. If warehouses in different states ever need this,
   that's the piece to revisit.

**`App\Services\StockTransferService::createBatch()`** is the new write path, deliberately
shaped like `SalesService::createSale()`: resolve every line (product + quantity, and price
if invoicing) before touching stock, then per line call the existing
`InventoryService::recordMovement()` twice (`transfer_out` at source, `transfer_in` at
destination) referencing the `StockTransfer` line row — exactly what
`InventoryService::transfer()` already does for a single product, just looped and wrapped in
one `StockTransferBatch` header. `batch_number` (e.g. `STB-000001`) is assigned after insert,
same reasoning as PO numbers (avoids guessing the id before the row exists); `challan_number`
is only issued when an invoice is requested.

**Pricing without a Customer**: `PricingService::listedPrice()`/`unitPrice()` (Customer-keyed)
were refactored with zero behavior change into thin wrappers over two new column-based
methods, `listedPriceForColumn()`/`unitPriceForColumn()`, which is what the transfer form
calls directly via `columnForTierName('distributor'|'retailer')` — there's no Customer
standing in for the receiving warehouse, so the tier is picked explicitly on the transfer
form instead of derived from `Customer.type`.

**No new permission** — reused the existing `inventory.transfer` (create) and `inventory.view`
(read) permissions rather than adding `inventory.transfer_batch`, since bulk transfer is the
same underlying capability as the single-product Transfer modal it sits next to; no
`RolePermissionSeeder` change was needed. New routes: `inventory.transfers.index` /
`.create` / `.show` / `.invoice` / `.invoice.pdf`, plus a "Smart Stock Movement" entry point
added next to the existing "Transfer Stock" button on the Inventory page.

**Known limitation, matching the existing single-product transfer's own gap, not a new
one**: batch-tracked products are moved at the warehouse-aggregate level only —
`InventoryService::transfer()` never took a `ProductBatch` param either, and properly
supporting it here would mean find-or-create matching `ProductBatch` rows across two
warehouses, which risks leaving the batch table inconsistent with `stock_balances` if done
partially. Left as a follow-up, not guessed at.

Verified end-to-end against the `demo` tenant via tinker (not `php artisan test` — this
project's tests wipe the dev DB): a real invoiced batch transfer priced correctly (2 units at
the distributor tier, 18% GST split into equal CGST/SGST, round-off to the rupee, stock moved
at both warehouses), a non-invoiced batch transfer, three guard-clause exceptions (same
warehouse, insufficient stock, missing tier when invoicing) each correctly rolled back the
whole `DB::transaction` with no orphan `StockTransferBatch` row, and the DomPDF invoice
rendered successfully. `route:cache` re-verified clean (this project's mandatory pre-push
check) before clearing it back for local dev.

**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.

**2026-09-05 — Party Ledger (Statement of Account) for Customers and Suppliers, first delivery from a broader accounting/trade-documentation study.**

The user asked for a wide slate of accounting/trade features (quotations, delivery challans, GST reports, party ledgers, chart of accounts, trial balance, trading account, P&L, balance sheet, cash flow, ratio analysis) - studied against the actual codebase first (not built blind) and written up as a gap analysis with a 4-phase roadmap. The headline finding: five of those asks (ledger accounts, cash flow, trading account, P&L, balance sheet) aren't five features, they're one thing - double-entry bookkeeping - and are roughly as large as the rest of the app combined, with a real alternative (export to Tally/whatever software already does the business's books) worth deciding on explicitly rather than defaulting into building from scratch. Phase A (trade documentation - quotations, PO/GRN printing, supplier bill fields, GST report exports, party ledger) was recommended as the safe starting point: additive, no new write paths, reuses patterns already proven across ten sprints. This entry is the first Phase A item.

**Party Ledger is the same shape as `CustomerShow`/`SupplierShow`'s existing `activityTimeline()` (Sprint 8) with one deliberate difference: signed debit/credit pairs and a running balance instead of a single signed "amount" in a feed.** New `Customer::ledgerEntries()`/`Supplier::ledgerEntries()` sit right next to `activityTimeline()` on the same models. `PartyLedgerController` (new, mirrors `InvoiceController`'s exact shape - `abort_unless(can(...))`, a plain Blade view, a PDF twin via the same `Barryvdh\DomPDF` facade, the same slash-safe `downloadFileName()` helper) builds the running balance and opening-balance-as-of-`?from=` from those entries; one shared view, `resources/views/reports/party-ledger.blade.php`, serves both party types (a `Customer` and `Supplier` statement differ only in accounting direction, not layout), styled identically to `sales/invoice.blade.php` (same dompdf-safe table CSS, same `.actions`/print/PDF header bar) rather than inventing a second document style. New routes `customers.ledger(.pdf)` / `suppliers.ledger(.pdf)`, gated by the same `customers.view`/`suppliers.view` permissions the Show pages already use - a statement is exactly as sensitive as the party record it summarises, no new permission needed. A "Statement of Account" link sits next to the existing "Record Payment" button on both Show pages.

**The one real subtlety: a Supplier ledger cannot reuse `activityTimeline()`'s "list the PO at `po->total`" shape, because `Supplier::balance()` is receipt-based, not order-based** (Sprint 3's deliberate design - a payable only exists for what was actually received, not what was ordered). `activityTimeline()` already has this exact inconsistency (it lists a PO's full `total` even when only partially received, so its own line items don't actually sum to `balance()` for a partial order) but that was fine for a human-scannable feed; it is not fine for a document titled "Statement of Account" whose whole point is that the numbers add up. `Supplier::ledgerEntries()` instead lists one row per `GoodsReceipt`, valued the same way `receivedValue()` sums it (`received_quantity * unit_cost * (1 + tax_rate/100)` per item, grossed up from the tax-exclusive `unit_cost` `PurchaseService` stores), so the ledger and `balance()` can never silently disagree. Dr/Cr follows standard bookkeeping direction rather than one flat sign convention for both party types: a customer (debtor) is debited on a sale, credited on a return/payment; a supplier (creditor) is credited on a receipt, debited on a return/payment - so `running = Σ(debit - credit)` reads directly as `balance()` for a customer (positive = Dr = they owe us) and as `-balance()` for a supplier (negative = Cr = we owe them), which is exactly the sign a real creditor account is supposed to carry, not an inconsistency to paper over.

**Verified with a temporary `RefreshDatabase` + `InteractsWithTenant` Feature test** (`tests/Feature/PartyLedgerTest.php`, 6 tests / 17 assertions) built directly against real rows (no factories exist beyond `UserFactory`, matching how every prior sprint verified) rather than through `SalesService`/`PurchaseService`, since this only reads existing rows and isn't exercising either write path: a customer with two sales, a return, and a payment reconciled its ledger's running total to `Customer::balance()` exactly; a supplier with a **partially received** PO (100 ordered, 60 received) reconciled to `Supplier::balance()` at the receipt-based value (₹444 payable), proving the receipt-based-not-order-based fix actually holds and isn't just asserted in a comment; the HTTP layer confirmed 403 without `customers.view`, a rendered "800.00 Dr" / "444.00 Cr" closing balance matching the model-level number exactly, a working PDF download (`content-type: application/pdf`), and that a `?from=` date window correctly folds earlier entries into the opening balance while excluding them as visible rows. Full suite: 194/194 passing - no regression anywhere else.

**Departure from the established "discard sprint verification tests" convention, on purpose:** every prior sprint's `Livewire::test()` suite was deleted once green, per this project's own stated rule (only Breeze's scaffolding is meant to stay committed). `PartyLedgerTest.php` was kept instead - it isn't testing UI interaction (the thing the convention was written for), it's asserting that a financial statement's closing balance always equals the balance already shown everywhere else in the app, which is exactly the "important workflow" Architecture Principle #11 asks for permanent coverage of, and a regression here (an invoice statement that doesn't foot correctly) is a materially worse failure mode than a broken sort toggle. Flagged explicitly rather than silently changing house style - if this should go back to the discard convention, say so.

**Not done yet, deliberately out of scope for this delivery:** Purchase/Sale Quotations, PO/GRN printing, Supplier Bill fields, and GST report exports are the rest of Phase A from the roadmap study and were not touched here. Nothing was pushed to `master` - this is local only, pending review.

**2026-09-05 — GST Reports (rate-wise, HSN-wise, B2B register, B2C summary), second Phase A delivery.**

Picked as the next roadmap item because the study rated it the single highest business value on the list (it's what has to be typed into the GST portal every month) while staying pure-reporting, no-new-write-path - the same risk profile as the Party Ledger above. **Explicitly not a one-click GST-portal upload file** - that needs the government's own JSON/offline-tool schema, out of scope - but the same numbers organised exactly the way GSTR-1/GSTR-3B ask for them, so a return can be prepared or checked from it directly.

New `App\Livewire\Reports\GstReport` (route `/reports/gst`, `reports.view`/`reports.export` - no new permission, matching every other report) follows Sprint 7's own reporting shape to the letter: no new Service, direct Eloquent queries in the component, `ArrayExport` reused unchanged for each of four exports. One page, four sections, consolidated the same way Sprint 7 folded ~15 spec bullets into 6 pages rather than one skinny page per breakdown:

- **Rate-wise summary** - `sale_items` grouped by `tax_rate` across the date range, summing the exact `cgst_amount`/`sgst_amount`/`igst_amount` `GstService` already stored per line at invoice time (2026-08-26 GST Invoicing entry) - this report never recomputes tax, only regroups what was already correctly split.
- **HSN-wise summary** - grouped by `sale_items.hsn_code`, with a `COALESCE(NULLIF(...), 'Not specified')` bucket surfaced as an amber banner naming the exact unit count affected, rather than silently omitting rows - directly actionable against the GST Invoicing entry's known gap ("`products.hsn_code` exists but is unpopulated").
- **B2B register** - invoice-wise, one row per `Sale` whose `customer.gstin` is set, exactly what GSTR-1's B2B table needs.
- **B2C summary** - deliberately *not* invoice-wise (unregistered customers are reported as an aggregate, not individually, in GSTR-1's B2C(Others) table) - grouped by place of supply + rate instead, `COALESCE(NULLIF(sales.place_of_supply,''), customers.state, 'Unknown')` so a sale with no explicit place-of-supply still lands somewhere named rather than vanishing from the total.

`ReportsIndex`'s card grid gained one entry, no other navigation to touch (only `reports-index.blade.php` and the dashboard link to specific report routes at all).

**Verified with a committed Feature test** (`tests/Feature/GstReportTest.php`, 5 tests / 14 assertions, kept for the same reason `PartyLedgerTest.php` was - this is compliance-facing arithmetic, not UI behaviour): a B2B sale (18%, intra-state, HSN present) and a B2C sale (5%, intra-state, HSN blank) both reconciled the rate-wise summary's taxable/tax totals exactly against `Sale.subtotal`/`Sale.tax_total` (₹1,500 / ₹205); a third sale dated one day into the next month was asserted absent from an August-only query, proving the date filter actually excludes rather than just defaulting correctly; the B2B/B2C split was asserted by customer `gstin`, not by any invoice-level flag (the registered customer's invoice number appears in the B2B table, the unregistered one's does not, appearing only inside the aggregated B2C row instead); the blank-HSN line was asserted both to bucket under "Not specified" *and* to drive the correct affected-quantity count in the banner text; permission gating (403 without `reports.view`) and a real Excel download (`assertFileDownloaded()` via `Livewire::test()`) both passed. Full suite: 199/199 passing - no regression anywhere else.

**Not done yet:** the true GSTR-1 "B2C Large" split (interstate B2C invoices over ₹2.5L reported separately from the regular B2C(Others) aggregate) was deliberately left as a documented simplification rather than guessed at - flag if this business's invoice sizes ever make that distinction matter. Purchase/Sale Quotations, PO/GRN printing, and Supplier Bill fields remain the rest of Phase A. Nothing pushed to `master` - local only, pending review, same as the Party Ledger entry above.

**2026-09-05 — Sale Quotations, third Phase A delivery: an estimate stage in front of Sales, with a one-click "Convert to Sale."**

Picked as the next roadmap item on value (rated High, same tier as GST Reports, and the highest-value item still unbuilt in Phase A). Unlike the Ledger/GST work this one is a genuine new write path, not a report - the first Phase A item that creates data rather than only reading it, so it got the most deliberate design pass of the three.

**New `sale_quotations`/`sale_quotation_items` tables (`database/migrations/tenant/2026_09_05_*`) and `App\Services\SaleQuotationService`, structured as a deliberately thinner sibling of `SalesService`, not a copy of it.** `createQuotation()` reuses `PricingService::priceLine()` and every one of `GstService`'s `summarise()`/`apportion()`/`roundOff()` calls exactly as `SalesService::createSale()` does, so a quoted line's price and tax split is arithmetically identical to what the real sale will show later - proven, not just asserted, by a test that reconciles both against hand-computed numbers. What it deliberately drops: no `InventoryService` call anywhere (a quote must never move stock or touch `stock_movements` - verified directly), no COGS/batch resolution (which physical batch fulfils an order is a decision for whenever the sale actually happens, not whenever the quote was written), and numbering is the simple post-insert `QTN-000001` pattern (Sprint 3's PO/return-number precedent), not `InvoiceNumberService`'s FY-locked series - a quotation isn't a statutory GST document and doesn't need a gapless sequence. A `SaleQuotation` is create-and-cancel only, no edit, matching `Sale`'s own immutability (Sprint 5) and `PurchaseReturn`'s never-mutate-a-financial-document precedent - correcting a quote means cancelling it and writing a new one.

**"Convert to Sale" never writes a `Sale` server-side from the quotation directly - it redirects to `SaleForm` prefilled instead**, the same review-before-commit shape the Excel Import Safety Rule and Smart Sales Entry already established for any suggested/parsed line list: stock levels, batches (deliberately left unselected even for a batch-tracked product - the quote may be weeks old) and pricing might all have moved on since the quote was written, so nothing commits until a human looks at the prefilled form and hits Save. Implemented via Livewire's `#[Url(as: 'fromQuotation')]` on `SaleForm::$quotationId` rather than a raw `request()->query()` read - real component state, testable with `Livewire::withQueryParams()` instead of needing a full HTTP round trip to exercise. On a successful `save()`, if a `quotationId` is present and the quotation is still convertible, `SaleQuotationService::markConverted()` links `converted_sale_id` and flips status - checked defensively (`isConvertible()` again at this point, not trusted from mount time) so a quotation cancelled or converted by someone else in the interim doesn't get double-linked; the sale itself is already committed by then regardless, so this is a small accepted race (worst case: a redundant but real, easily-cancelled sale) rather than something worth cross-service locking for at this business's scale.

**Printable quotation (`resources/views/sales/quotation.blade.php`, via new `QuotationController`) mirrors `sales/invoice.blade.php`'s exact dompdf-safe layout and `.actions`-bar convention** rather than inventing a second document style - same company-letterhead block, same table CSS - with one explicit difference: a footer line stating "This is a price estimate, not a tax invoice," since reusing the invoice's visual language could otherwise make a quote look like a binding GST document. New routes/permissions (`quotations.view/create/cancel`) follow the existing per-module permission shape exactly, assigned to Sales Manager (full) and Sales User (view/create, no cancel) in `RolePermissionSeeder`, `view`-only to Accounts User and Viewer. Discovery: since this app has no persistent sidebar (Sprint 8's command palette + dashboard tiles are the only navigation), a "Quotations" button was added next to "New Sale" on the Sales index, plus a command-palette "New Quotation" quick action and search group - the same two places every other module surfaces itself.

**Verified with a committed Feature test** (`tests/Feature/SaleQuotationTest.php`, 7 tests / 25 assertions, kept for the same financial-correctness reason as the Ledger/GST tests): a 3-unit line at a GST-inclusive ₹118 listed price reconciled to an exact ₹300 taxable / ₹54 tax / ₹354 total, proving the shared pricing/tax code path; `StockMovement::count()` stayed exactly 0 after quotation creation; cancelling a non-draft quotation throws; the full convert flow (`Livewire::withQueryParams(['fromQuotation' => $id])->test(SaleForm::class)`) prefilled customer/warehouse/items correctly, and after `save()` the resulting `Sale` had the right line quantity while the quotation flipped to `converted` with the correct `converted_sale_id`; a quotation already `converted` before the link is followed degrades to a blank form rather than double-converting or crashing; permission gating and both the print view and PDF download (`content-type: application/pdf`) passed. **One real setup gap the test suite itself caught**: every write/print action 403'd until the test user was explicitly attached to the warehouse via `WarehouseAccessService` (Sprint 10's non-Super-Admin restriction defaults to zero warehouses, not all) - not a bug in the new code, but a reminder that this project's own warehouse-scoping rule applies to test fixtures too, not only production users. Full suite: 206/206 passing - no regression anywhere else.

**Not done yet:** PO/GRN printing and Supplier Bill fields are the two remaining Phase A items. A parallel "Purchase Quotation" (RFQ before a PO) was scoped in the original roadmap study but not built here - lower rated value than this (Medium vs High) since there's currently one supplier (7 Days Natural) to quote against, not several to compare. Nothing pushed to `master` - local only, pending review, same as the two entries above.

**2026-09-05 — Supplier Bill fields, fourth Phase A delivery, plus one unplanned but well-justified follow-through: the "Supplier payment due" notification now uses a real due date when one exists.**

Picked next on value (rated Med-High, the highest remaining unbuilt Phase A item; PO/GRN printing stayed Medium and unbuilt). The roadmap study's framing held exactly: "payables are recognised at goods-receipt value, with no supplier invoice number, bill date or due date captured anywhere."

**The three new fields (`supplier_bill_number`, `supplier_bill_date`, `due_date`) live on `GoodsReceipt`, not `PurchaseOrder`** - a deliberate call, not the obvious one. `Supplier::balance()` has been receipt-based since Sprint 3 (a partially received PO only owes for what actually arrived), which the Party Ledger work (above) leaned on directly; a partially received PO can just as easily receive several separate supplier bills across several receipts, so a bill's identity belongs with the receipt it was issued against, not the order as a whole. `PurchaseService::receiveGoods()` gained three trailing optional params (all nullable, all default `null`) rather than a new method - every existing call site (`PurchaseOrderShow::saveReceive()` before this change, any import/service path) keeps working unmodified. New `GoodsReceipt::isOverdue()` is explicitly scoped to what the data actually supports: a plain `due_date < today` check, documented as unable to say a bill is *unpaid* (no per-bill payment allocation exists - `SupplierPayment` links to `purchase_order_id`, not `goods_receipt_id`) - cross-referencing the supplier's own balance/statement is still how you'd know if an overdue-flagged bill is actually settled.

**`Supplier::ledgerEntries()` (Party Ledger, above) got a one-line addition**: a receipt's particulars now append `— Bill {number}` when a supplier bill number was recorded, so the statement of account and the new field connect immediately rather than needing the PO detail page open in a second tab.

**Unplanned follow-through, decided worth doing because it directly closes a gap this project already flagged as a known limitation**: Sprint 8's `GenerateBusinessAlerts::duePayables()` used a stated 30-day-since-oldest-order-date heuristic specifically "because `Sale`/`PurchaseOrder` have no due-date/payment-terms field to check against" - documented as an assumption, not a permanent design choice. Now that a real due date can exist (on `GoodsReceipt`, exactly the fields just added), `hasADuePayable()` checks for the earliest recorded `due_date` across a supplier's receipts first, and only falls back to the 30-day heuristic when none exists anywhere - so a supplier nobody has started recording real bill dates for behaves exactly as before (verified explicitly, not just assumed), while one with a real overdue due date now gets flagged correctly even on a brand-new order the heuristic alone would never catch for another month.

**Verified with a committed Feature test** (`tests/Feature/SupplierBillTest.php`, 9 tests / 19 assertions, kept for the same reason as the other three Phase A tests - this drives a real notification and a real accounts-payable field, not just UI): bill number/date/due date round-tripped through `receiveGoods()` correctly; `isOverdue()` was checked true (past date), false (future date), and false (no date) as three separate cases rather than trusting one happy path; the ledger particulars change was asserted directly; the alerts logic was proven on all three paths explicitly - fires from a real overdue due date on a same-day order (proving the heuristic alone could not have caused it), does *not* fire while a real due date is still in the future (proving it's a date comparison, not merely "has a due date"), and still fires via the untouched 30-day path when no due date was ever recorded (regression proof the old behaviour survives); the `PurchaseOrderShow` receive-modal flow was driven through `Livewire::test()` end to end including the `after_or_equal` validation rejecting a due date before the bill date. Full suite: 215/215 passing - no regression anywhere else.

**Not done yet:** PO/GRN printing (the last remaining Phase A item - a printable/PDF version of the purchase order and a consolidated ordered-vs-received-vs-pending audit report) was not built in this pass. Nothing pushed to `master` - local only, pending review, same as the three entries above.

**2026-09-05 — PO/GRN Printing, fifth and final Phase A delivery: a printable purchase order plus an ordered-vs-received-vs-pending audit report.**

Closes out Phase A entirely (Party Ledger, GST Reports, Sale Quotations, Supplier Bill fields, and now this - all five items from the original roadmap study, all in one day).

**Printable PO (new `PurchaseOrderPrintController`, `resources/views/purchases/order.blade.php`) mirrors `InvoiceController`/`QuotationController`'s exact shape** (`show()`/`pdf()`, same `abort_unless(can(...))` + `WarehouseAccessService::canAccess()` double-check, same dompdf-safe standalone-document Blade pattern, same slash-safe `downloadFileName()`) with one structural difference the document itself demands: the roles are reversed from an invoice or quotation - the company is the **buyer** here, the supplier is who the document is addressed to. Also simpler than the sales documents in one respect: `PurchaseOrder` carries only `subtotal`/`tax_total`/`total`, no CGST/SGST/IGST interstate split (that field never existed for purchases), so the tax block is a single "Tax" line rather than the sales invoice's conditional inter-state/intra-state branching. HSN comes from `Product::hsn_code` directly (unlike `SaleItem`/`SaleQuotationItem`, `PurchaseOrderItem` never snapshotted its own HSN - not changed here, out of scope). New routes `purchases.print` / `purchases.print.pdf` under the existing `purchases.view` permission (no new permission, matching every other document-print route added this session); a "Print / PDF" link sits next to the existing "Download Excel" button on `PurchaseOrderShow`.

**The audit report (new `App\Livewire\Reports\PurchaseFulfillmentReport`, route `/reports/purchase-fulfillment`, `reports.view`/`reports.export`) reports at the order-*line* level, not the order level** - deliberately more granular than `PurchasesReport` (Sprint 7), because "ordered vs received vs pending" is a per-product question: a PO with five lines can be fully received on three and still pending on two, and an order-level status ("partially received") would hide exactly the detail this report exists to surface. One shared `filteredQuery()` (a real SQL join to `purchase_orders`, not `whereHas`, specifically so `order_date` can drive both the row listing's `ORDER BY` and the aggregate totals from the same builder via `clone`) backs three things: the paginated line list, a totals strip (ordered/received/pending quantity and pending value), and the Excel export - the same clone-a-shared-query shape `PurchasesReport` already established. `statusFilter` (pending/partial/fulfilled) is computed with `whereColumn('received_quantity', '<', 'ordered_quantity')`-style comparisons rather than a stored status column, since fulfillment state is always derivable from the two quantities already on the row and a denormalised third column would just be one more thing that could drift out of sync.

**A second panel, "Receipts in Range," lists the `GoodsReceipt`s themselves (receipt number, date, supplier bill number, due date, overdue flag) - the direct payoff of the Supplier Bill fields added earlier today.** This panel is deliberately **not** filtered by `statusFilter`: it answers "what physically arrived and what was it billed as," a different question from "which order lines are still outstanding," so a receipt for an otherwise-fulfilled line still shows here regardless of which fulfillment filter is selected on the line table above it. `ReportsIndex`'s card grid gained one entry alongside the existing Purchases report.

**Verified with two committed Feature tests** (`tests/Feature/PurchaseOrderPrintTest.php`, 4 tests, and `tests/Feature/PurchaseFulfillmentReportTest.php`, 7 tests - kept for the same reason as all four prior Phase A deliveries: this drives real financial documents and a real accounts-payable-facing report, not just UI). The print test covers the rendered document content, a working PDF download (`content-type: application/pdf`), permission gating, and warehouse-access gating (a user with `purchases.view` but no warehouse assignment still gets 403 - Sprint 10's rule applying to a fourth document type now). The report test seeds five orders (fulfilled, partially received with a supplier bill attached, fully pending, cancelled, and one dated outside the queried month) and proves the totals reconcile exactly (30 ordered / 14 received / 16 pending / ₹1,600 pending value) while cancelled and out-of-range orders contribute nothing. **One real mistake the tests themselves caught during writing**: the first draft asserted `assertDontSee('PO-FUL')` etc. against the whole rendered page for each status filter, which failed - not because the filter was broken, but because the *test's own assumption* was wrong: the Receipts-in-Range panel legitimately keeps showing a PO's receipt regardless of the line table's status filter, by design (see above), so a page-wide text-absence assertion was asserting something the feature never promised. Fixed by switching those three assertions to `assertViewHas('items', fn ($items) => ...)`, checking the actual filtered collection the component built rather than scraping page text - a more precise (and correct) way to test a filter than string-matching a page that legitimately contains the "excluded" text elsewhere for an unrelated reason. Full suite: 226/226 passing (up from 215), 717 assertions - no regression anywhere else.

**Phase A is now fully built.** Nothing pushed to `master` - all five entries above are local only, pending review. Next, if wanted: Phase B (Sale Order two-stage workflow + Delivery Challan) or the standalone Tally-export-vs-build-full-double-entry-books decision flagged in the original roadmap study, whichever the user rates higher value.

**Follow-up fix, same day: new tenant migrations from this session were never applied to the real `aradhya` tenant database.** The Feature test suite uses `RefreshDatabase` against sqlite in-memory, which runs every migration fresh every time - so all four deliveries above tested green without ever touching the actual live tenant schema. The three new tenant migrations (`sale_quotations`, `sale_quotation_items`, the `goods_receipts` supplier-bill columns) sat unapplied until the user hit a live `Table 'erp_aradhyaenterprise.sale_quotations' doesn't exist` error on `/aradhya/quotations`. Fixed by running `php artisan tenants:migrate --tenants=<id>` for both real tenants (`aradhya` and `demo`, kept in sync so they don't drift) - purely additive (two new tables, three new nullable columns), no data at risk. Also picked up one unrelated pending migration (`add_google_id_to_users_table`, 2026-09-03, predates this session) that had the same problem. **Lesson for every future sprint in this project: passing tests confirm the code is correct, not that the live tenant database has been migrated - `tenants:migrate` still has to be run by hand after any new tenant migration, the same way it would in production.**

**2026-09-05 — Smart Quotation Entry: the same paste-the-order screen Sales already has (`/sales/smart`), added to Quotations (`/quotations/smart`) on direct user request.**

User-requested, not a roadmap pick - "Add same smart order option we are using here [sales/smart]" to the quotation-create page. New `App\Livewire\Sales\SmartQuotationEntry` is a close structural copy of `SmartSalesEntry`, retargeted at `SaleQuotationService::createQuotation()` instead of `SalesService::createSale()`: paste order text (or set a GST-inclusive target amount to auto-suggest one), review every matched/unmatched line in an editable modal, Confirm Quotation only then writes anything. **`SmartSalesParser` and `SalesOrderPlanner` are reused completely unchanged** - both already operate generically on `Product`/`Customer`/a stock-quantity array, with no `Sale`-specific assumption anywhere in either class, so no fork of the parsing or allocation logic was needed, only of the Livewire component and view around them. Renamed the sale-specific vocabulary so the screen can't be mistaken for booking a real sale: `placeOrder()` → `buildQuotation()`, `confirmSale()` → `confirmQuotation()`, "Invoice date" → "Quotation date" (plus a new "Valid until" field, matching `SaleQuotationForm`'s own field), "Confirm Sale" → "Confirm Quotation". Auto-suggest still caps suggested quantities by warehouse stock even though a quotation never reserves any of it - kept identical to the sales version's behavior rather than redesigned, since diverging business logic between the two screens was not what was asked for; the estimate simply stays realistic about what could actually be fulfilled.

New route `quotations.smart`, registered directly after `quotations.create` and before `quotations/{quotation}` - same ordering reasoning as every other literal-vs-wildcard route in this file (Laravel matches equal-segment-count routes in registration order, so a wildcard registered first would swallow `/quotations/smart` as `{quotation}=smart`). Same `quotations.create` permission as the plain form, no new permission. The quotation index's single "New Quotation" button became two: "+ Smart Quotation Entry" (primary) and "New Quotation" (secondary, the existing line-by-line form) - mirroring exactly how the Sales index already presents "+ Smart Order Entry" ahead of "New Sale".

**Verified with a committed Feature test** (`tests/Feature/SmartQuotationEntryTest.php`, 5 tests, 29 assertions - kept for the same reason as every other write-path test this session, not the "discard after green" convention): paste-text build-then-confirm produced a `draft`-status `SaleQuotation` with the reviewed lines and a `QTN-` number; `StockMovement::count()` stayed at its pre-quotation baseline both after a plain paste and after an auto-suggested quotation (the baseline, not zero, because `openingStock()` in test setup is itself a real movement - the point being the *quotation* added none); Confirm stays blocked with an unresolved line, same as `SmartSalesEntryTest`'s equivalent case; the route 403s without `quotations.create`. Parsing/planner mechanics themselves were deliberately not re-tested here (already covered by `SmartSalesEntryTest` against the exact same, unmodified classes) - re-proving unchanged code would be redundant, not additional coverage. Full suite: 231/231 passing (up from 226), 746 assertions. No new migration - this is Livewire/route/view only, so (unlike the entry above) there was nothing to run `tenants:migrate` for. Nothing pushed to `master`.

**2026-09-05 — In-header column filters: every list page's "search box + dropdowns above the table" bar replaced with a filter row built into the table header itself, on direct user request (with a working reference screenshot from another app).**

Two new shared Blade components carry the whole rollout: `resources/views/components/th-filter.blade.php` (a plain `wire:model.live.debounce.300ms` text input, no icon - for free-text columns: name, phone, city, SKU, invoice/PO/quotation number, batch number) and `resources/views/components/th-date-range-filter.blade.php` (two native date inputs sharing one cell, for a Date column's from/to range). Every converted list page's `<thead>` gained a second `<tr>` of these, one cell per column (an empty `<x-th-filter />` for a non-filterable numeric/computed column keeps the grid aligned); the old top-of-page filter bar is gone, reduced to just the page's action buttons; "Clear filters" moved into the last header cell, shown only when a filter is active. 12 pages converted: Customers, Suppliers, Warehouses, Categories, Products, Users, Sales, Purchase Orders, Sale Quotations, Batches, Stock Balances, Stock Movements. (`RoleIndex` was deliberately left alone - it never had a filter bar to begin with, an unpaginated list with nothing to convert.)

**Two rounds of direct user correction reshaped the pattern mid-rollout, both addressed by revisiting every already-converted page, not just the one in front of the user at the time:**

1. *"MRP, Selling Price, Quantity not showing the filter"* (caught on Products, live in the browser) - the first pass had left three numeric columns with an empty filter cell by design ("not filterable"), but Qty Available's cell was supposed to carry the stock-bucket filter and had silently been left blank by an oversight, and the user wanted MRP/Selling Price filterable too, not exempted. Fixed by adding `mrpFilter`/`sellingPriceFilter` (plain LIKE-text against the decimal value) and restoring the missing stock-bucket cell.
2. *"I want that filter like Status where the data type is ENUM the filter should be dropdown"* - the first pass had made every column a free-text LIKE search, including status/type/role/stock-bucket/expiry-bucket/movement-type columns that only ever hold a handful of fixed values. Rule applied everywhere: a column backed by a real fixed vocabulary (a boolean-derived active/inactive, a status/type enum column, a bounded admin-curated list like warehouse/category/role) gets a dropdown (`resources/views/components/th-select-filter.blade.php`, a plain `<select>`); a column that's genuinely open-ended text (name, phone, product/customer/supplier lookup by name) stays free text. Concretely: `Customer.typeFilter`/`statusFilter`, `Supplier/Warehouse/Category.statusFilter`, `Product.categoryFilter`/`statusFilter`/`stockFilter`, `User.roleFilter`/`statusFilter`, `Sale/PurchaseOrder/SaleQuotation.statusFilter`, `Batch.expiryFilter` all became dropdowns with the query switched from `LIKE '%...%'` back to an exact `=` match (a dropdown only ever emits a canonical value, so LIKE's partial-match flexibility was never buying anything there and only risked a false-positive substring match). `Batch/StockBalance/StockMovement.warehouseFilter` and `StockMovement.productFilter` specifically went back to the pre-existing `<x-searchable-select>` combobox rather than a plain `<select>` - a pre-existing regression test (`SearchableSelectRolloutTest`, written in an earlier sprint specifically to catch a Blade-shorthand-swallows-the-template class of bug) already asserted those exact columns render that exact component, and a plain `<select>`/text box would have been a real UX downgrade for a 100+-SKU product list, not just a test failure to silence.

**One structural bug the second correction's test failures surfaced, invisible until then: the entire filter row lived inside the table, which never rendered at all on a genuinely empty list** (`@if ($x->isEmpty()) <x-empty-state /> @else <table>...@endif` skips the whole table, header row included, whenever there are zero records - not just zero *matching* records). `SearchableSelectRolloutTest` caught this by seeding no data at all and asserting the filter markup was present; the bug reasoning applied identically to all 12 converted pages, not just the 3 the test happened to cover, since every one of them shared the same `@if(empty) ... @else <table>@endif` shape. Fixed everywhere by restructuring to the standard pattern: `<table><thead>` (both rows, unconditional) `<tbody>@forelse ($items as $item) <tr>...@empty <tr><td colspan="N"><x-empty-state .../></td></tr>@endforelse</tbody></table>`, with the pagination bar below wrapped in `@if ($items->isNotEmpty())`. This is a genuine UX fix, not just test-satisfying: before it, filtering a list down to zero results left no way to see or clear the active filter without a full page reload, since the "Clear" button lived in the now-vanished header row.

**Verified per-page throughout, then as one pass at the end.** Three background sub-agents were used for the bulk of the mechanical page-by-page conversion (Suppliers/Warehouses/Categories; Products/Users; Sales/Purchase Orders/Sale Quotations), with the Inventory pages (Batches/Stock Balances/Stock Movements) and Customers done directly as the hand-verified reference implementation the agents were told to mirror exactly. Two of the three agents hit a session rate limit mid-task and stopped with their assigned pages only partially finished (code written, `pint`/tests never run) - both were resumed and finished directly rather than re-dispatched. One unrelated but real bug surfaced and was fixed while converting `StockMovementIndex`: `orderBy($column, $sortDirection)` alone isn't a stable sort when two rows tie on that column (e.g. two stock movements recorded in the same second), so a secondary `orderBy('id', $sortDirection)` tiebreaker was added - proven necessary by reverting the surrounding change and confirming the "defaults to newest first" test only failed with the tiebreaker absent, not because of anything else in the same edit. Full suite: **238/238 passing, 770 assertions** (up from 226/746 before this entry). Nothing pushed to `master` - all of the above is local only, pending review.

**Same-day follow-up, twice: every dropdown filter converted from a native `<select>` to the searchable combobox (`x-searchable-select`), not just the ones with long lists.** First round (Products, live in the browser): "For category it is becoming very lengthy" - Category's dropdown was still a plain `<select>`, unwieldy once there are more than a handful of categories to scroll through; switched to the same `x-searchable-select` component already used for Warehouse. Second round: "rest two still looking tight" (Status, Stock level) - a native `<select>`'s closed-state text does not reliably respect `text-overflow: ellipsis` across browsers, so a narrow column (Qty Available, Status) hard-clipped the placeholder text ("Any stock l...") instead of truncating it cleanly, no matter how much padding was added. Rather than patch each column's placeholder text to fit, `th-select-filter.blade.php` was rebuilt to render `x-searchable-select` internally for every enumerated-column filter, uniformly - it already truncates its selected-label correctly (a real `<span class="truncate">`, not a native control's internal rendering). Its API changed from a slot of `<option>` tags to an `:options` array/collection prop (matching `x-searchable-select`'s own shape), so every call site across all 10 files using it was updated to pass `:selected="$xxxFilter"` and an inline `:options="[['id' => ..., 'name' => ...], ...]"` array (or, for `User.roleFilter` and `StockMovement.typeFilter`, a `->map()` over the existing `$roles`/`$types` view data rather than a hand-written list). `Batch.expiryFilter`, which had never gone through the shared component (it shares a table cell with the "Clear" button), was converted the same way directly. Verified: full suite still 238/238, 770 assertions - a component-internals + call-site swap, no query logic changed anywhere. Nothing pushed to `master`.

**Same-day, third follow-up: a missing filter on Categories, plus every list page's near-empty top area redesigned.** "Check here product search is missing" - the Products (count) column on Categories had no filter cell at all (it's a computed `withCount('products')` alias, filtered the same way `ProductIndex`'s stock-bucket filter already handles a non-`withSum`-alias case: a scalar subquery repeated inline via `whereRaw`, since a SELECT alias can't be referenced in WHERE, only ORDER BY - `coalesce((select count(*) from products where products.category_id = categories.id and products.deleted_at is null), 0) like ?`, respecting the same soft-delete scope `withCount` itself applies). "Adjust the header, it is taking too much space, improve design" - every converted list page left the topbar's `<h1>` slot (`resources/views/components/topbar.blade.php`) completely empty, since no Livewire full-page component had ever supplied one; combined with the page's own action-button row sitting `justify-end` below it, the two stacked into a wide band of visually dead space above the table. Discovered (and proved with a real HTTP-level test, not just a Livewire-component test, since `Livewire::test()` never renders the surrounding layout) that Livewire's global `component_layout` wrapping (`config/livewire.php`) *does* honor a top-level `<x-slot name="header">` inside a full-page component's own Blade view, extracting it into the layout's `$header` the same way `<x-app-layout>` does for the non-Livewire Blade pages (`profile/edit.blade.php`) - just never used anywhere in this app until now. Added a one-line `<x-slot name="header">Page Name</x-slot>` to the top of all 13 full-page list views (the 12 already converted to in-header filters, plus `RoleIndex`, which turned out to have a literal empty `<div></div>` placeholder left over from exactly this gap) - the topbar's height doesn't change (it's a fixed `h-16`), but the previously-blank left side now carries the page's name, so the same vertical space reads as intentional instead of wasted.

Verified: `CategoryIndexTest` gained `test_products_filter_narrows_by_the_displayed_count` and `test_page_title_renders_in_the_topbar` (the latter a real `$this->get()` HTTP request asserting the exact `<h1 class="text-lg font-semibold ...">Categories</h1>` markup - proof the slot mechanism actually works end to end, not just that the component method runs). Full suite: **240/240 passing, 774 assertions**. Nothing pushed to `master`.

**2026-09-06 — Same-day, fourth follow-up: the last two individually-reported blank filter columns (Suppliers/Customers Balance) generalized into a full sweep of every remaining unfilterable column across every list page.** Three consecutive reports in a row - Category dropdown truncation, Category Products count missing a filter, then "From here balance filter also missing" on Suppliers - were the same underlying gap recurring, so rather than wait for a fourth report, every list page was audited for blank filter cells and the pattern completed in one pass.

**Balance (Supplier, Customer)** - both computed the same way as Categories' Products count: the exact `withSum`/subquery formula already driving the displayed value, repeated as a raw expression in `whereRaw("{$expr} like ?", [...])` since a SELECT alias can't be referenced in WHERE. Supplier balance is `(receipts received-value) - (payments)`; Customer balance is `(completed sales total) - (payments)` - both scoped identically to how `Supplier::balance()`/`Customer`'s own balance display already compute it, so the filter and the shown number can never disagree.

**Warehouse, Total, Profit (Sales, Purchase Orders, Sale Quotations)** - three transactional list pages each had one or more numeric/relationship columns with an empty filter cell. `warehouseFilter` matches via `whereHas('warehouse', ...)` (a real lookup, not a dropdown - these pages don't curate a bounded list the way Batches/Stock Balances do); `totalFilter`/`profitFilter` are plain LIKE against the direct `total`/`profit_total` columns. Where the filtered column is the last one in the row (no separate blank Actions cell), its filter `<th>` was restructured to carry both the input and the "Clear filters" button together in one flex row, rather than adding a 14th column just for Clear.

**Quantity, Cost, Value, Balance, Created By (Batches, Stock Balances, Stock Movements)** - the three Inventory pages' remaining blank numeric cells. Stock Balance's `valueFilter` matches `round(quantity * average_cost, 2)` via `whereRaw`, mirroring `StockBalance::stockValue()`'s exact formula for the same reason as Balance/Products-count above. Stock Movement's `createdByFilter` uses `whereHas('createdBy', ...)` against the recording user's name.

**One real test-authoring bug caught and fixed while verifying `StockMovementIndexTest`'s new filters, worth recording since it cost real debugging time**: the first version asserted `assertSee('Product A')->assertDontSee('Product B')` per filter, which failed - not because any filter was broken (confirmed independently via `viewData('movements')`, which showed the correct row set every time), but because `assertDontSee('Product B')` can never pass on this particular page: the Product-filter dropdown (`x-searchable-select`) always embeds every active product's name as literal JSON in the markup (`{"id":"2","label":"Product B"}`) regardless of which row-level filter is applied, so "Product B" is always textually present even when its table row correctly isn't. The failure's own PHPUnit wording ("does not contain") was the tell - that phrasing belongs to a failed `assertDontSee`/`assertStringNotContainsString`, not a failed `assertSee`, and pointed straight at which assertion actually broke once read carefully. Fixed by switching every sub-assertion in that test to `assertViewHas('movements', fn ($movements) => $movements->count() === N && ...)`, checking the actual filtered collection instead of scraping rendered HTML - the same lesson the Purchase Fulfillment Report entry above already recorded once, now reconfirmed on a different page: a page that legitimately renders an "excluded" value somewhere else on the page (a dropdown, a summary line) makes `assertSee`/`assertDontSee` an unreliable way to test a row-level filter, and `assertViewHas` against the underlying query result is the more precise tool.

Verified: `SupplierIndexTest`, `CustomerIndexTest`, `SaleIndexTest`, `PurchaseOrderIndexTest`, a new `SaleQuotationIndexTest` (this index had zero tests before), `BatchIndexTest`, `StockBalanceIndexTest`, and `StockMovementIndexTest` each gained one test covering their new filter(s), built with deliberately non-overlapping fixture values (e.g. 400 vs 100, not 150 vs 50) so a LIKE `%term%` search can't accidentally match both rows. Full suite: **248/248 passing, 804 assertions** (up from 240/774). Nothing pushed to `master`.

**2026-09-06 — Phase B: Sale Order two-stage workflow + Delivery Challan.** User picked this over the other roadmap option flagged at Phase A's close (Tally export). Planned with the user via an explicit plan-approval pass (design decisions below were called out and approved before any code was written) rather than decided unilaterally, since it touches stock timing, a new permission namespace, and document numbering - all things worth getting sign-off on rather than guessing.

Sales had no equivalent of the purchase side's Purchase Order → Goods Receipt split: `SalesService::createSale()` is single-shot, creating the tax invoice and deducting stock in the same call - correct for counter/retail sales (untouched by this work), but with no way to record "the customer has placed a firm order, we're shipping it in two truckloads three days apart, and each truckload needs its own GST-compliant Delivery Challan before the goods leave the building." This phase adds that middle stage: **Sale Order** (`App\Models\SaleOrder`/`SaleOrderItem`, the commitment - mirrors `PurchaseOrder`'s fulfillment machinery: `ordered_quantity`/`delivered_quantity` per line, status `open → partially_delivered → delivered`, cancellable while `open`/`partially_delivered` exactly like `PurchaseOrder::isCancellable()`) → **Delivery Challan** (`DeliveryChallan`/`DeliveryChallanItem`, the real stock-moving dispatch event - mirrors `GoodsReceipt`, one or more per order) → each challan can then be **invoiced** into a real `Sale`, reusing the existing tax-invoice engine (`InvoiceNumberService`, `InvoiceController`) completely unchanged.

**Four design decisions, called out to the user before implementation:**
1. **Stock moves at Delivery Challan time, not at invoice time** - mirrors `GoodsReceipt` exactly (the stock event, not a later bill) and is what makes "two-stage" real: warehouse staff can trust available stock the moment a challan is made, not only once someone invoices it. New `StockMovement` type `'delivery'` (negative qty) - confirmed `stock_movements.type` has no DB-level enum, so no migration was needed for the new value, same as every other type string in this table.
2. **One `Sale` per `DeliveryChallan`, tracked via a nullable `invoiced_sale_id` on the challan** - exact mirror of `SaleQuotation.converted_sale_id`/`markConverted()`. Avoids a second "invoiced_quantity" tracking dimension alongside "delivered_quantity," and matches real GST practice where each dispatch typically gets its own tax invoice.
3. **Header/item schema mirrors `SaleQuotation`/`SaleQuotationItem`** (full GST breakdown, price tier, place of supply, computed via the same `PricingService`/`GstService` calls `SalesService::createSale()` uses) **while the fulfillment/status machinery mirrors `PurchaseOrder`/`PurchaseOrderItem`.** Batch selection at delivery time follows `SalesService::createSale()`'s existing selection logic (pick an existing batch, not create one - goods are leaving, not arriving); unit cost is captured on the challan item at the moment of dispatch (from the batch or `StockBalance::average_cost`) and copied straight into the resulting `SaleItem` at invoice time rather than re-derived, so cost reflects what it was when the stock actually left.
4. **New permission namespace `sale_orders.*`** (`view`/`create`/`deliver`/`cancel`), not a reuse of `sales.*`. Precedent check: Quotations already get their own namespace separate from `sales.*` even though a quotation converts into a sale - Sale Order is the same kind of relationship, so it follows that precedent over the Purchases one (where Goods Receipt has no namespace of its own, just `purchases.receive`). Invoicing a challan reuses the existing `sales.create` permission directly. Wired in: Super Admin/Admin get all four; **Sales Manager** gets all four (mirrors its existing full `quotations.*`); **Sales User** gets `view`+`create` only, not `deliver` (dispatch is a stock-moving action, kept manager-tier - mirrors the same asymmetry Sales User already has on quotations, which also lacks `.cancel`); **Accounts User**/**Viewer** get `view` only.

**Explicitly out of scope for this pass** (each with a direct existing precedent deferred the same way): editing a Sale Order's lines after creation, cancelling/reversing an individual challan once made, converting a Quotation directly into a Sale Order, and a standalone printable Sale Order document (the Delivery Challan *does* get one - `App\Http\Controllers\DeliveryChallanPrintController`, `resources/views/deliveries/challan.blade.php`, structurally identical to `PurchaseOrderPrintController` - it's the document that travels with the goods, without a GST-rate/amount column since a challan isn't a tax invoice).

**New `App\Services\SaleOrderService`** (mirrors `SaleQuotationService`'s pricing shape + `PurchaseService`'s fulfillment shape): `createSaleOrder()`, `cancelSaleOrder()`, `deliver()` (locks each `SaleOrderItem`, validates against `pendingQuantity()`, records the `'delivery'` movement, recomputes order status), `invoiceChallan()` (rebuilds the GST math from the challan's already-captured unit cost/price, creates the `Sale`+`SaleItem`s **without calling `InventoryService`** since stock already moved, sets `invoiced_sale_id`). New Livewire components `SaleOrderIndex`/`SaleOrderForm`/`SaleOrderShow` under `App\Livewire\Sales` (index gets the same in-header-filter treatment as every other list page this session; show mirrors `PurchaseOrderShow`'s Receive-Goods-modal shape for its "Record Delivery" modal, plus an Invoice button per un-invoiced challan). Wired into the command palette (`CommandPalette.php`/blade): a "New Sale Order" quick action and a search group, both permission-gated, matching every other document type already there.

**Verified with `tests/Feature/SaleOrderTest.php` (14 tests) and `tests/Feature/DeliveryChallanPrintTest.php` (4 tests)**, mostly direct `SaleOrderService` calls (matching `SupplierBillTest`'s balance of direct-service-call tests plus a couple of Livewire-driven ones for the actual form/modal wiring) rather than all-Livewire: order creation leaves stock and `StockMovement` count untouched; partial delivery moves stock, writes a `'delivery'`-typed movement referencing the challan, and flips status to `partially_delivered`; a second delivery completes it; over-delivery and missing-batch-selection are rejected; invoicing a challan produces a `Sale` with correct total/cost/profit and **`StockMovement::count()` unchanged by the invoice step** (the core claim of decision #1, checked directly rather than assumed); double-invoicing the same challan is blocked; cancel is allowed pre-delivery-completion and blocked after; the `SaleOrderForm`/`SaleOrderShow` Livewire flows (create → deliver → invoice) work end to end; permission gating (`sale_orders.create`/`.deliver`) and warehouse-access gating (Sprint 10's rule, same as every other write path this session) are enforced; the index page renders and its status filter narrows correctly. Full suite: **266/266 passing, 855 assertions** (up from 248/804). Nothing pushed to `master` - local only, same as every prior entry.

**2026-09-07 — Housekeeping: removed a stale status memo; extended the AI assistant's tool coverage to Sale Orders and Quotations.**

`Erp smart purchase order builder.md` (repo root) was a leftover session-status note from 27-Aug-2026, written by a device-bridge session that dropped before its commit step - it described `OrderPlannerService` as still carrying a lopsided-allocation bug, tests as unrun, and the work as uncommitted. Checked against the actual repo before touching anything: none of that was still true. A later session (git log shows commit `9e937f4`, same day, 22:43) had already applied the corrected least-funded allocation, and `SmartPurchaseOrderTest` (15 tests) passes clean today; that commit is on `master` and already pushed to `origin/master`, so the feature has been live in production the whole time. The memo just never got deleted afterward. Removed it (`git rm`) rather than leave a stale note that could mislead a future session into "fixing" already-shipped, already-correct code.

**Separately, picked up the AI business assistant** (built in Sprint 9, documented above at line 61) - live in code with 11 tools, an audit log, and a full tool-use loop, but never actually exercised: `ANTHROPIC_API_KEY` is still blank in `.env` and `ai_queries` has zero rows locally. Rather than chase down a real API key (the user's call, not something to do unprompted) or touch the untested CommandPalette search, the user picked the lowest-risk, most concretely-scoped gap: the 11 tools predate three modules built since Sprint 9 - Sale Orders, Delivery Challans, and Sale Quotations - so the assistant currently cannot answer even a simple question about any of them.

Added `BusinessToolService::searchSaleOrders()` and `searchQuotations()` (now 13 tools total), matching every existing sibling tool's shape exactly: same permission-check-first pattern (`sale_orders.view` / `quotations.view`), same `term`/`status`/`dateFrom`/`dateTo` filter shape as `searchPurchases`/`searchSales`, same flat mapped-array return shape. **Deliberately not warehouse-scoped** - `searchSales`/`searchPurchases`, the closest sibling tools, aren't either (only the physical-stock tools - `getInventory`, `getLowStockProducts`, `getExpiringProducts` - call `accessibleWarehouseIds()`), so matching those immediate siblings was judged more important than independently deciding document-search tools should be scoped, which was never this change's call to make. No Delivery Challan tool was added on its own - matches how Goods Receipt already has no tool of its own either, folded into `searchPurchases`'s status field; `SaleOrder.status` (`open`/`partially_delivered`/`delivered`) plays the same role. `searchQuotations` also surfaces `is_expired` (via the existing `SaleQuotation::isExpired()`), since "which quotations are about to lapse" is a real question this makes answerable for the first time.

**Found and noted, not fixed: the entire AI-assistant subsystem (`AssistantService`, `BusinessToolService`, `AssistantChat`) had zero test coverage before this change**, despite claude.md's own Sprint 9 entry claiming the degraded "no API key" path was "provably unaffected" - grepped the full `tests/` tree for any reference to those three classes or `ai_queries`/`ai.use` and found none. Fixing that gap wholesale was out of scope for "extend tool coverage," so `tests/Feature/BusinessToolServiceTest.php` (8 tests) covers only the two new tools - permission denial, term/status/date-range filtering, expiry flagging - not a retrofit of the pre-existing 11. Full suite: **274/274 passing, 873 assertions** (up from 266/855). Nothing pushed to `master`.

**2026-09-07 — Credit note document for sale returns**, picked from the "Not done yet" list (line 527 above: "No credit-note document for `SaleReturn` — the record exists, the print view does not").

New `App\Http\Controllers\CreditNoteController` (`show()`/`pdf()`, no `excel()` - deliberately narrower than `InvoiceController`'s three exports, since an Excel workbook of a credit note wasn't asked for and isn't a GST requirement the way the PDF/print is) and `resources/views/sales/credit-note.blade.php`, structurally a close mirror of `sales/invoice.blade.php` - same seller block, same line-items table shape, same rate-wise tax-summary table - with the differences a credit note actually needs: titled "CREDIT NOTE" not "TAX INVOICE", its details block cites **"Against Invoice No.: {X}, dated {Y}"** (a real GST requirement - Rule 53 of the CGST Rules requires a credit note to reference the original invoice it corrects), and no bank-details block (a credit note reduces what's owed, it isn't asking to be paid).

**The one real design problem this surfaced: `SaleReturn`/`SaleReturnItem` were never built to reprint their own GST split.** Unlike `Sale`/`SaleItem` (which store `cgst_amount`/`sgst_amount`/`igst_amount` per line, so `Sale::taxSummary()` just re-buckets already-stored numbers), `SalesService::createReturn()` only ever persisted a flat `subtotal`/`tax_total`/`total` on the `SaleReturn` header - the CGST-vs-SGST-vs-IGST split that produced those totals was computed once, in memory, and thrown away. Rather than a migration to add three new columns and backfill/rewrite `createReturn()`, added `SaleReturn::taxSummary()` (and a delegating `isInterState()` that reads `$this->sale->isInterState()`, since a return was always taxed the way its original sale was) that **recomputes the identical bucketed split via `GstService::summarise()`** from the still-stored `line_total`/`tax_rate` on each `SaleReturnItem` - the exact same call `createReturn()` itself made to arrive at the stored header totals in the first place, so the recomputed bucket totals reconcile with them exactly (asserted directly in the test, not assumed). Zero schema change, and the same "reuse the real query, never let a printed number drift from what's stored" principle every other print document in this app already follows.

**New `credit_note_declaration` setting** (`GeneralSettings`, mirrors `invoice_declaration` exactly - same `STRING_SETTINGS` map entry, same textarea field on the settings page) since a credit note's legal declaration text is naturally different from a tax invoice's and business owners may want to edit it independently. A "Credit Note" link was added next to each return listed on `SaleShow` (`sales.view`-gated, same permission as the invoice link beside it - no new permission was needed since a credit note is exactly as sensitive as the invoice it corrects).

**Verified with `tests/Feature/CreditNoteTest.php` (5 tests)**: the recomputed GST split reconciles with the stored header totals and correctly splits CGST/SGST 50/50 intra-state; the printed document shows the credit note number, the referenced invoice number, the customer, the HSN code, and the return's own notes; PDF download works (`content-type: application/pdf`); permission gating and warehouse-access gating (Sprint 10's rule, same as every other print document this session) are enforced. Full suite: **279/279 passing, 891 assertions** (up from 274/873). No new migration - `Setting` is a plain key/value table, nothing to run `tenants:migrate` for. Nothing pushed to `master`.

**2026-09-07 — HSN code backfill tooling**, the last item on the "Not done yet" list (line 525-526 above). Picked with the user's "just pick the next item yourself going forward" instruction now in effect for a bare "continue" - see `feedback_erp_continue_autonomy` in cross-session memory.

**Checked the real local tenant database before writing any code, since this is fundamentally a data problem, not a missing-feature problem**: 138 of 138 products have no HSN code, and `hsn_code` was never even wired into the create/edit form - there was no way to set it for a single product, let alone in bulk. Also checked whether the Excel import pipeline (`SevenDaysNaturalParser`/`ProductMatcher`) already captures HSN from the supplier's rate list and just wasn't persisting it - it doesn't; HSN was never touched anywhere in that pipeline either. **Deliberately did not invent or guess any actual HSN codes** - a wrong HSN on a GST invoice is a compliance problem, not a UX one, and that's a business fact only the user can supply.

**Design call: no new schema.** Considered a category-level default HSN (23 categories vs. 138 products, since HSN is usually consistent per product type) cascading down to products that leave their own `hsn_code` blank, but that adds a second field, a fallback-resolution rule threaded through three services (`SalesService`, `SaleQuotationService`, `SaleOrderService` all separately snapshot `hsn_code` from the product at line-creation time), and a "which value actually applies" question for a business owner to hold in their head - for a problem that already has a much simpler answer: `ProductIndex` already has a full checkbox multi-select mechanism (`bulkDelete()`) and an existing category filter. Filtering to one category, selecting all, and applying one HSN code closes that whole category in a single action - the same 23-actions-not-138 efficiency win, with zero new schema and zero changes to any of the three sale-side services (they already read `$product->hsn_code` correctly; once it's populated, every downstream invoice/quotation/order snapshot is correct for free).

**What was built**: `hsn_code` added to the product create/edit form for the first time (single-product path, `ProductIndex.php`/blade, next to Box quantity); a new **HSN column** on the products grid with a `missing`/`set` dropdown filter (a fixed two-state vocabulary, so `x-th-select-filter` per this session's established filter-sweep rule, not free text - searching by partial HSN digits isn't a real workflow the way searching by name/SKU is); and a new **bulk "Set HSN code" action** (`bulkSetHsn()`, mirrors `bulkDelete()`'s selection/permission/modal shape exactly) that applies one code to every checked row. The checkbox column itself was previously gated to `products.delete` only - widened to `@canany(['products.update', 'products.delete'])`, since a user with only the update permission (the one `bulkSetHsn()` actually requires) could never have reached a bulk action before this, the same class of oversight the Sprint 10 warehouse-access audit found elsewhere in this app.

**Verified with 5 new tests in `tests/Feature/ProductIndexTest.php`** (a file that had no coverage of the create/edit save flow at all before this, same as it had none for bulk-delete permission edge cases until this pass): editing a product persists its HSN; the missing/set filter narrows correctly; bulk-set applies to every selected product and leaves others untouched; an empty code is rejected with a field error, not silently applied; the action is 403'd without `products.update`. Full suite: **284/284 passing, 906 assertions** (up from 279/891). No migration needed - `hsn_code` already existed as an unused column. Nothing pushed to `master`.

**2026-09-07 — Tally export**, the other half of the decision flagged at Phase A's close (line 1848 above) - picked over E-invoice/IRN generation (the only other item left on the "Not done yet" list) because E-invoice needs real government GST-portal API credentials that don't exist yet and isn't something buildable end-to-end without the user's involvement, while this is a pure data-preparation task with everything it needs already recorded in the app.

**Design call, made the same way the GST Report (line 1791 above) already made it for GSTR filing: prepared numbers in the shape the destination expects, not a one-click automated push.** A literal Tally XML voucher import was considered and rejected - it risks silently creating wrong ledgers, or duplicate/mismatched parties, in the user's real Tally company if a customer or supplier name doesn't match their existing chart of accounts exactly, which this app has no way to know. Instead, `App\Livewire\Reports\TallyExportReport` (new report, `reports.view`/`reports.export` - no new permission, reuses exactly what `GstReport` already gates on) produces four flat CSV/XLSX registers - Sales, Purchase, Receipts, Payments - in the plain date/voucher-type/party/amount shape Tally's own **Import Vouchers** Excel/CSV wizard expects, so the accountant maps party names to their existing ledgers interactively on the way in, the same as they would with a register handed to them on paper.

**The one real modeling question this raised: what *is* a "purchase" for accounting purposes in this app?** Not the `PurchaseOrder` - Phase A and Phase B both already established that an order is a commitment with no financial event of its own. The actual bill is the `GoodsReceipt` (specifically, the supplier-bill fields Phase A added to it). So the Purchase Register is built from `GoodsReceiptItem`, grouped back up to one row per receipt (a single receipt can cover several order lines at different GST rates, so taxable/tax are summed per receipt via a join back to `purchase_order_items.tax_rate` for the rate, since neither `GoodsReceipt` nor `GoodsReceiptItem` stores a tax split of its own - purchases in this app never did the CGST/SGST/IGST breakdown sales get). Each row's voucher number prefers the supplier's own bill number when one was recorded, falling back to the internal receipt number otherwise - matching how the credit note entry above already established "prefer the real business document number when one exists."

**Verified with `tests/Feature/TallyExportReportTest.php` (6 tests)**: the sales register reconciles exactly against the real `Sale.total` GstService computed (not a hand-calculated literal - the first draft hard-coded an expected total and got the GST-inclusive-price-conversion arithmetic wrong, caught immediately by comparing against the actual stored value instead) and correctly excludes a sale dated outside the queried range; the purchase register groups a multi-rate receipt into one row, uses the supplier bill number over the internal receipt number, and its taxable/tax figures reconcile against the raw quantity×cost×rate math; receipts and payments show the correct party and amount; every one of the four exports downloads as a real Excel file; `reports.export` is enforced separately from `reports.view` (a view-only user can see the registers on screen but not export them, matching every other report in this app). Full suite: **290/290 passing, 916 assertions** (up from 284/906). No new migration - every source table already existed. Nothing pushed to `master`.

**Both items flagged at Phase A's close are now resolved**: Phase B (Sale Order + Delivery Challan, 2026-09-06) and Tally export (this entry). The only remaining flagged gap is E-invoice/IRN generation, blocked on real GST-portal credentials the user hasn't set up yet - not something to build speculatively ahead of that.

**2026-09-07 — Sale Order line editing, closing one of the four items explicitly deferred at Phase B's close (line 1899 above).** With every explicitly-flagged claude.md item now resolved, picked between the four Phase B leftovers and a CommandPalette fuzzy-search upgrade that had been floated as an option earlier but never chosen - and passed on the CommandPalette idea specifically because it would mean scoring every product/customer/supplier name in PHP on every debounced keystroke, a real performance risk at scale for a feature nobody had actually asked for, versus this item, which had a directly precedented, already-proven implementation to mirror (`PurchaseService::updatePurchaseOrder()`) and closes a real gap: today, a typo'd quantity or wrong product on a Sale Order has no fix short of cancelling the whole order and starting over.

**`SaleOrderService::updateSaleOrder()` mirrors `updatePurchaseOrder()`'s exact boundary and shape** - editable only while `status === 'open'` and no `DeliveryChallan` exists yet (checked once on entry, re-checked under a row lock inside the transaction against a stale read), merges two submitted lines for the same product into one, deletes a line that's been removed from the submission, and re-derives the header from the edited lines. The one place it couldn't be a literal copy: `PurchaseOrderItem` carries no per-line GST split to recompute, but `SaleOrderItem` does (`cgst_amount`/`sgst_amount`/`igst_amount`), so `updateSaleOrder()` re-runs the same `GstService::summarise()`/`apportion()`/`roundOff()` sequence `createSaleOrder()` already uses.

**A real bug caught during review, before any test ran, not by one**: the first draft indexed the apportioned per-line tax array (`$perLineTax`) with a manually incremented loop counter (`0, 1, 2...`), copying the convention `createSaleOrder()` uses - safe there, because that method builds its lines into a plain sequential array (`$resolved[] = ...`). This method builds `$merged` keyed by product id instead (`$merged[$productId] = ...`, needed for the same-product-line-merging behavior), and `array_map`/`GstService::apportion()` both preserve whatever keys their input array already has - so `$perLineTax` came back keyed by product id too, not sequential position. Re-reading the two source methods side by side caught the mismatch before writing a single test; fixed by indexing `$perLineTax[$productId]` directly, and the multi-rate test added afterward (two lines at 18% and 12%, asserting each line's own CGST/SGST independently) exists specifically to keep this class of bug caught automatically next time, not just this once.

**New `sale_orders.update` permission** (not a reuse of `.create`) - `PurchaseOrder`, the functional analog this mirrors, keeps create and update as genuinely separate permissions (`purchases.create`/`purchases.update`), unlike `Quotations`, the namespace analog, which has no update capability at all to borrow a permission from. Wired in at the same tier as `sale_orders.deliver` (Super Admin/Admin/Sales Manager; withheld from Sales User, keeping order amendment manager-tier the same way dispatch already is). New `App\Livewire\Sales\SaleOrderEdit` (route `/sale-orders/{id}/edit`) reuses `SaleOrderForm`'s plain quantity/price line-editor markup rather than `PurchaseOrderEdit`'s box-stepping one, since sale-side ordering was never box-quantity-driven the way purchasing is; an "Edit" link appears on `SaleOrderShow` only while `status === 'open'` and no challan has been recorded yet, the same visible condition the service enforces.

**Verified with 8 new tests added to `tests/Feature/SaleOrderTest.php`** (now 22 tests total for this feature): totals and quantities recompute correctly on update; two lines at different GST rates apportion CGST/SGST independently and correctly (the test written specifically to catch the indexing bug class above); submitting the same product twice merges into one line and a dropped line is deleted; editing is blocked once any delivery has been recorded and once cancelled; the Livewire edit form saves and redirects; the edit route enforces `sale_orders.update` and warehouse access separately. Full suite: **298/298 passing, 932 assertions** (up from 290/916). Nothing pushed to `master`.

**2026-09-07 — Quotation → Sale Order conversion, a second Phase B leftover closed in the same sitting.** Reconsidered value, not just risk, before picking between the three remaining Phase B items: a standalone printable Sale Order document is low-risk (a fourth mirror of the same `PurchaseOrderPrintController` pattern used three times already this session) but genuinely low-value too - a Sale Order is an internal commitment nobody hands to anyone, unlike the Delivery Challan (the real physical document) or the eventual Invoice (the real GST document). This item was picked instead because it closes a real gap: today, a customer accepting a quotation for phased bulk delivery means re-keying the entire order by hand into a fresh Sale Order form, when "Convert to Sale" already solves exactly this problem for the direct-sale path.

**Mirrors `SaleForm`'s existing quotation-conversion mechanism almost exactly** - `#[Url(as: 'fromQuotation')]` binds the quotation id as real, testable component state (not read from `request()->query()` directly); `prefillFromQuotation()` in `mount()` checks `quotations.view` and `isConvertible()`, degrading a stale or already-converted link to a blank form with a flash error rather than crashing; the resulting order is only committed on `save()`, so the user still reviews pricing before anything is written, the same review-before-commit shape Smart Sales Entry and the Excel import pipeline already established. The one place it's simpler than `SaleForm`'s version: no `batch_id` field to leave blank, because a Sale Order never picks a batch at order time in this app's design - that happens at delivery (Phase B) - so there's nothing analogous to carry over.

**One real schema question this raised: `SaleQuotation.converted_sale_id` is a foreign key to `sales` specifically, so it cannot point at a `sale_orders` row.** Rather than a polymorphic column (a bigger, riskier change touching every existing read of `converted_sale_id`) or skipping the status update entirely (which would let the same quotation later be converted to *both* a Sale Order and a direct Sale - a real double-booking risk, not a hypothetical one), added a second nullable `converted_sale_order_id` column (new migration, applied to both real tenants) and a sibling `SaleQuotationService::markConvertedToOrder()` method alongside the existing `markConverted()` - same guard, same "leave the quotation's status as whatever it already became if something else converted or cancelled it in the meantime" reasoning, different target column. `SaleQuotationShow` now shows "Convert to Sale Order" (secondary button) beside the existing "Convert to Sale" (primary), each gated by its own permission (`sale_orders.create` / `sales.create`), and the converted-to line shows whichever one actually happened.

**Verified with 3 new tests in `tests/Feature/SaleOrderTest.php`** (now 25 total for this feature): the form prefills every field from the quotation and, on save, both creates the order correctly and marks the quotation converted with `converted_sale_order_id` set (and `converted_sale_id` left null - proof the two paths don't cross-contaminate); a no-longer-convertible quotation degrades to a blank form instead of crashing, the same guarantee `SaleForm` already had; a quotation converted to a Sale Order this way is then correctly rejected if someone tries the direct-to-Sale conversion link on the same quotation afterward - the specific double-booking scenario the new column exists to prevent. Full suite: **301/301 passing, 947 assertions** (up from 298/932). Migration applied to both real tenants (`aradhya`, `demo`) via `tenants:migrate`. Nothing pushed to `master`.

**2026-09-07 — Bug fix: a soft-deleted product with orphaned stock crashed `/inventory` with "Attempt to read property 'name' on null".** User-reported (a real 500 error screenshot from the live `aradhya` tenant), traced to `stock_balances` row id 52 (product_id 139, "Debug GST Product", qty 10) - `Product` uses `SoftDeletes`, and `StockBalance::product()`/every sibling relation loads it through Eloquent's normal query, which silently excludes a trashed row rather than erroring, so `$balance->product` was `null` and the blade's unguarded `$balance->product->name` threw.

**Root cause turned out not to be a live application bug.** Both real deletion paths in `ProductIndex.php` (`delete()` and `bulkDelete()`) already correctly check `stock_balances_sum_quantity > 0` before allowing a delete - grepped the whole codebase for every `Product::`/`$product->delete()` call site and found no other path. A soft-delete is just an `UPDATE deleted_at`, so it never touches a DB foreign-key constraint either. The "Debug GST Product" name and the fact that reaching this state at all requires bypassing the UI guard entirely (direct tinker/DB access, not a code path a user can reach) both point to leftover damage from an earlier debugging session, not a bug this app's real code can reproduce under normal use.

**Swept the whole codebase for the same unguarded-relation pattern rather than patching only the one crashing page**, per this session's established practice once a bug shape is confirmed systemic: grepped every `->product->` access across every Blade view and `BusinessToolService` (the AI tool layer) and found **30+ unguarded call sites** across Stock Balances, Stock Movements, Batches, the Inventory and Purchase Fulfillment reports, three Show pages (Purchase Order, Sale, Sale Order, Sale Quotation), five printable documents (PO, Invoice, Quotation, Delivery Challan, Credit Note), and three AI tools (`getInventory`, `getLowStockProducts`, `getExpiringProducts`). Fixed every one with a consistent pattern: `$item->product?->name ?? "Deleted product (#{$item->product_id})"` for a single display value, or an `@if ($x->product)` block plus `$x->product &&` guards on any comparison logic that reads a second property (e.g. `reorder_level`-based low-stock coloring). **One real mistake caught mid-fix and corrected before it shipped**: the first pass on several files used `$item->product->name ?? "fallback"` - which still crashes, because `??` only guards the *final* expression being null, not an intermediate `->` access on a null object; PHP throws on `$item->product->name` itself before `??` ever runs. Every occurrence needed the null-safe `?->` operator specifically (`$item->product?->name ?? "fallback"`), not plain `??`. **Live inventory/product data is never hidden** - a row for a deleted product still renders, now labeled "Deleted product (#id)" and (on Stock Balances specifically) flagged in red as needing reconciliation, rather than silently disappearing from a report a business owner needs to trust.

**Deliberately did not touch the underlying orphaned data itself** (the one `stock_balances` row, its one `stock_movements` row, and one `purchase_order_items` row, all referencing product 139) - restoring or deleting real records in the live tenant database is a data decision for the user, not something to do unprompted while fixing a display bug. Reported the specifics back to them for a decision once MySQL (found stopped locally mid-investigation, unrelated to this fix) is back up.

**Verified with 15 new regression tests**: one per already-existing Inventory test file (`StockBalanceIndexTest`, `StockMovementIndexTest`, `BatchIndexTest`) reproducing the exact reported crash (soft-delete a product with real stock recorded against it, assert the page still returns 200 and shows the fallback label), plus a new `tests/Feature/DeletedProductRegressionTest.php` (12 tests) covering every other surface from the sweep - three Show pages, two reports, five print documents, and the two AI tools most likely to be asked about inventory. Full suite: **316/316 passing, 977 assertions** (up from 301/947). Nothing pushed to `master`.

**2026-09-07 — Bug fix: confirming an Excel import batch with an untouched fuzzy-matched row threw SQLSTATE[23000] on `matched_product_id`.** A production incident, same shape as the deleted-product sweep above (a real crash reported from the live tenant, root-caused before writing any fix). `ImportWizard::loadRowsIntoState()` left `select_value` blank for a `'fuzzy'` row - the `<select>` has no blank option, so the browser silently rendered "+ New Product" as selected while the bound Livewire property stayed `''`; `confirmImport()`'s `(int) $state['select_value']` then silently turned that blank string into `0`, a product id that doesn't exist, and the `matched_product_id` foreign key rejected it.

**Fix pre-seeds `select_value` with the top fuzzy candidate** (`(string) $candidates[0]['product_id']`) instead of leaving it blank - mirrors how `SmartSalesParser` already seeds a fuzzy line's `product_id` elsewhere in this app, so a fuzzy row now starts pointing at the same best-guess match the buyer sees highlighted, still theirs to override via the dropdown. `confirmImport()`'s cast was hardened regardless of this fix's root cause: `select_value` is now only ever trusted as `'skip'` or a genuine positive numeric id, anything else (blank, non-numeric, a stale value from an older component instance) falls back to `'new'` rather than reaching `(int)` at all - the fix closes the reported crash, the hardening closes the whole class of it. Separately wrapped the confirm flow's row-updates-then-`ImportService::confirm()` sequence in one `DB::transaction()` - previously two unguarded steps, so a failure partway through could leave earlier rows in the batch already committed with no purchase order ever created, an inconsistent half-applied import with no way back except starting over.

**Verified with a new `tests/Feature/ImportWizardTest.php` (2 tests)**: a real fuzzy-matched row (a typo'd product name scoring above `ProductMatcher`'s threshold) confirms cleanly using the pre-seeded candidate, reuses the existing product rather than creating a duplicate, and writes the correct ordered quantity to the resulting `PurchaseOrder`; a second test forces `select_value` blank directly (simulating any other path that could still produce one, not just the one this fix closes) and asserts `confirmImport()` still never writes an invalid product id. Full suite: **318/318 passing** (up from 316/977 - two new tests, assertion count in the same run as the entry below). Nothing pushed to `master`.

**2026-09-07 — Smart Purchase Order Builder gains a free-text paste-order entry, completing the paste-order pattern across all three write paths it now exists on (Sales since 2026-09-02, Quotations since 2026-09-05, Purchases here).** Until now `SmartOrderBuilder` only had the budget-first path (tick products, name a target spend, `OrderPlannerService::plan()` proposes quantities) - a buyer who already has the order written out (their own shorthand list, or a supplier's WhatsApp message) had no faster way in than typing it line by line into the plain `PurchaseOrderForm`.

**`SmartSalesParser` is reused for the new `placeOrder()` entry point almost unchanged** - the same reasoning already established when Quotations got this treatment: the parser has nothing sale-specific in it, it fuzzy-matches free text against the product catalog generically, the same way `ProductMatcher` already serves both the Excel-import pipeline and Sales. **One real bug in it surfaced by this reuse, not before**: `extractQuantity()`'s regex has a trailing optional unit group, and a line with no unit typed at all (a purchase-side shorthand like "Beard softner cream 40" - Sales/Quotations text nearly always carries a unit, purchase text often doesn't) makes PHP's `preg_match` omit `$m['unit']` from the match array entirely rather than setting it to `''`; the old `$m['unit'] !== ''` check assumed it was always at least present, and the previous two callers' real-world input just never happened to exercise the omitted-key case. Fixed with an `isset`-safe `! empty($m['unit'])` check - a one-line fix to shared, already-tested code, caught by the new caller's test data rather than a regression in the old ones.

The two entry points (`placeOrder()` from pasted text, `plan()` from a ticked budget list) now converge on one shared shape: both replace `$draftLines` wholesale and open the same review modal, rather than the component carrying two parallel line-list representations. `$lines`/`$planned` were renamed/restructured to `$draftLines`/`$showModal` to hold the richer per-line shape a parsed row needs (`raw_text`, `status`, `candidates`) that a planned row never did - the planned path just maps its output into the same shape with `status: 'auto'` and no candidates, so one `pricedDraftLines()`/`unresolvedCount()` pair now gates Generate for either origin, instead of one gate per path.

**The review modal gained line-level editing that the budget path never needed**: `addLine()`/`removeLine()` for a manually added or dropped row, `pickCandidate()` to accept one of a fuzzy row's suggestions without opening the searchable-select, and a `reviewSearch` filter (`visibleLineIndices()`, a pure navigation computed property - it never reorders or drops a row, only which are shown) for a long pasted order. `unresolvedCount()` (a line missing a resolved product or a positive quantity) blocks Generate with a named error rather than silently dropping unresolved lines, the same "review before commit" discipline Smart Sales Entry established. The box-stepper (`addBox`/`removeBox`) keeps working identically on a freshly-parsed line, since it only ever reads `product_id`/`quantity` off whichever row it's given, regardless of which entry point produced it - `targetAmount` was correspondingly dropped from `rules()`/`validationAttributes()` since it's now only relevant to the budget path, not a form-wide requirement.

**Verified with 3 new tests added to `tests/Feature/SmartPurchaseOrderTest.php`** (now 18 tests total for this feature): pasting two shorthand lines parses and generates a correct `PurchaseOrder` with the right quantities; a line with no catalog match blocks Generate with a `draftLines` error and zero orders created, then resolves once a fallback product is picked via the dropdown and generates successfully; the box stepper still rounds a hand-typed partial-box quantity up to a full box on a parsed (not planned) line, proving the shared stepper logic doesn't assume its line came from `plan()`. Full suite: **321/321 passing, 1006 assertions** (up from 318 after the Import Wizard fix above). Nothing pushed to `master`.

**2026-09-07 — AI business assistant: retrofit test coverage for the entire subsystem, closing a gap flagged twice already (Sprint 9's own "provably unaffected" claim, then explicitly re-flagged and left unfixed on the day the two newest tools were added).** With every canonical "Not done yet" item now either done (HSN tooling) or blocked on the user (E-invoice needs real GST-portal credentials, the last remaining flagged gap), this was the next concretely-scoped, fully buildable item: `AssistantService`, `BusinessToolService`'s original 11 tools, and `AssistantChat` had zero automated coverage despite being live, real code a user can hit today.

**No real Anthropic API call is ever made** - `Http::fake()` stands in for `api.anthropic.com`, shaped exactly like the real Messages API's `content`/`stop_reason` contract `AnthropicClient`/`AssistantService` already parse (a `tool_use` block with `id`/`name`/`input`, or a plain `text` block with `stop_reason: 'end_turn'`), so the tool-use loop itself is exercised end to end without needing `ANTHROPIC_API_KEY` set anywhere, live or in CI.

**New `tests/Feature/AssistantServiceTest.php` (7 tests) covers the loop mechanics** `BusinessToolServiceTest.php` was never scoped to prove: the degraded no-API-key path returns the configured message without an HTTP call ever being made (`Http::assertNothingSent()` - the literal claim Sprint 9 asserted without a test); a plain text reply short-circuits after one round; a `tool_use` round genuinely calls the real `BusinessToolService::searchProducts()` (not a stub) and the next request's `tool_result` content is asserted to carry that real product's name and count; a tool a user lacks permission for returns its denial message as a normal `tool_result` rather than throwing; an unrecognised tool name from the API is reported back as `"Unknown tool: ..."` rather than crashing (`AssistantService::callTool()`'s `method_exists` guard, untested until now); hitting the 5-round cap (`MAX_TOOL_ROUNDS`) without a final answer returns the "wasn't able to finish" fallback; a failed API response surfaces as the `AiUnavailableException` message, not a raw HTTP error; and conversation history is forwarded ahead of the new question in the exact order `AssistantChat::send()` builds it. Every case also asserts what landed in `ai_queries` (`success`, `intent` from the first tool called) - the audit trail this subsystem's whole design depends on for after-the-fact review, also untested before this.

**New `tests/Feature/AssistantChatTest.php` (9 tests) covers the Livewire layer**: `mount()` 403s without `ai.use` (component-level and route-level, both checked separately per this project's defense-in-depth convention); `isAvailable()` reflects the configured/unconfigured state correctly; a blank question adds nothing and calls nothing; a real question appends both turns and clears the input; tool names called are deduplicated on the assistant's turn; a second question forwards the first turn as history; and the 20-per-minute `RateLimiter` throttle (`ai-assistant:{userId}`, previously untested) genuinely blocks a 21st call from ever reaching `AssistantService` - asserted via `Http::assertSentCount(20)` after 21 attempts, not just the throttle message's text.

**`tests/Feature/BusinessToolServiceTest.php` extended from 8 tests (searchSaleOrders/searchQuotations only) to 19**, one permission-denial-plus-correctness group per remaining tool: `searchProducts` (name/SKU/barcode), `searchPurchases`/`searchSales` (term/status-or-minTotal/date-range, each reconciled against a real `PurchaseOrder`/`Sale` created through the real service), `getInventory`/`getLowStockProducts`/`getExpiringProducts` (all three proven warehouse-scoped with a two-warehouse fixture - a restricted user's tool call must not see the other warehouse's stock, the same rule every human-facing inventory page already enforces), `getProductHistory`/`getSupplierHistory`/`getCustomerHistory` (not-found handling plus a reconciled balance/movement), `getProfitReport` (defaults to this month, reconciles against a real sale's stored `subtotal`/`profit_total`), and `getOutstandingBalances` (its two independent permission checks proven separately - a Purchase Manager sees `payables` but the `receivables` key is absent entirely, not just empty, and vice versa for a Sales Manager).

**Deliberately not touched**: `AssistantChat`'s Blade view (typing indicator, message bubbles) and a genuine live-API round trip remain unexercised - the former is presentation-only with nothing to assert beyond what render() already proves by not throwing, the latter still needs a real `ANTHROPIC_API_KEY`, the same dependency that made this a documented gap rather than a bug in the first place. Full suite: **348/348 passing, 1116 assertions** (up from 321/1006, +27 tests across the three files above). Nothing pushed to `master`.

**2026-09-07 — This whole session's accumulated work (everything above from 2026-09-05 onward) pushed to `master` as one commit, and a real production deployment gap found and fixed in the same sitting: `deploy_production` in `.gitlab-ci.yml` never ran `tenants:migrate`.**

**The gap**: the deploy script's only database steps were central `php artisan migrate --force` and a plain `php artisan db:seed --class=RolePermissionSeeder --force`, with no tenant-context step at all. This app is one central database plus one separate physical database per tenant (`config('tenancy.database')`), and `RolePermissionSeeder` calls `Role::findOrCreate()`/`Permission::findOrCreate()` against whichever connection is active - it was written to run inside a tenant's own database (exactly how `TenantBootstrapper::runSeeders()` already runs it, wrapped in `$tenant->run(...)`, at first-provisioning time), not the central one. Run centrally the way the deploy script called it, it was seeding permission/role rows into a database no tenant's users ever authenticate against - a silent no-op for every deploy since this pipeline existed. Two consequences, both real: (1) every tenant migration this session added (`sale_quotations`, the Supplier Bill columns, `sale_orders`, `delivery_challans`, `converted_sale_order_id`) would have gone live in code without their tables existing in any production tenant database - the exact `Table doesn't exist` incident already hit once before, 2026-09-05, and fixed by hand that time; (2) every permission this project has ever added to `RolePermissionSeeder` across every sprint - `quotations.*`, `sale_orders.*`, `ai.use`, and everything before - has likely never actually been synced into an *existing* tenant's database by any deploy, only into a brand-new tenant at the moment it's provisioned (which runs the seeder file's current contents fresh, so a new signup today is unaffected).

**Fix**: two lines in `.gitlab-ci.yml`, right after the existing central `migrate --force` -

```
php artisan tenants:migrate;
php artisan tenants:seed --class=RolePermissionSeeder --force;
```

`tenants:migrate` needs no flags here - `config/tenancy.php`'s `migration_parameters` already bakes in `--force => true` and the correct tenant migration path, and it iterates every tenant in the central `tenants` table automatically (no hardcoded tenant list to keep in sync as new tenants sign up). `tenants:seed --class=RolePermissionSeeder --force` is the direct stancl/tenancy equivalent of what `TenantBootstrapper` already does manually via `$tenant->run()` - runs the seeder inside every tenant's own connection instead of the central one. Because `RolePermissionSeeder` is fully idempotent (`findOrCreate` throughout) and seeds the seeder file's *entire* current permission list every time, not just what changed, this single fix is retroactive: the next deploy will backfill every historically-missing tenant migration and every historically-unsynced permission in one pass, not only this session's new ones - confirmed locally by running both commands against both real local tenants (`tenants:migrate` reported "Nothing to migrate" - already caught up locally from this session's manual runs - and `tenants:seed` completed cleanly with no errors on either).

**`PlanSeeder` was deliberately left as a plain central `db:seed`** - its own docblock states it runs against the central database only (the plan catalogue is a platform-level concept, not a per-tenant one), so wrapping it in `tenants:seed` would have been the same class of context mistake in the opposite direction.

**Not verified end-to-end**: the actual production SSH deploy step itself was not (and cannot be, from here) executed - this fix is verified by running the exact same two artisan commands locally against real tenant databases and by reading `config/tenancy.php`/`TenantBootstrapper.php` to confirm the command choice matches this app's own established tenant-seeding pattern, not by observing a live deploy. Worth watching the next production deploy's pipeline log for these two new lines succeeding.

**2026-11-13 — One invoice may carry products of more than one brand (user decision: one bill to the customer, not one invoice per brand).** Smart Sales Entry refused a 7 Days Natural + Trycone order with "This document mixes products from different brands". The user was offered "split into one invoice per brand" or "one invoice, both brands" and chose the second. **This changes the 2026-10-17 rule "a document belongs to exactly one brand" for sales (invoices) only**; quotations, sale orders, delivery challans, purchases and supplier returns still refuse to mix brands (`BrandContext::resolveForProducts`).
- **Data:** a mixed invoice has `sales.brand_id = NULL`; every line keeps its brand in `sale_items.brand_id` (migration `2026_11_13_100000`, backfilled from the invoice's brand). `sale_items.round_off_share` carries the invoice's rupee round-off on its largest line (0 elsewhere), so each brand's lines add up to exactly the billed total. `SaleItem::creating` gives a line written without a brand its invoice's brand (challan invoices write lines directly). **Any one-time data fix that inserts `sale_items` with `DB::table()` must set `brand_id` itself**, or the GST and profit reports lose that line in a brand view.
- **Rule: documents appear under every brand they contain; money counts only each brand's lines.** `Sale::scopeForBrand` lists a mixed invoice under each of its brands (sales list, search, dashboard, GST B2B, sales report). `SalesFigures` (brand filter or per-brand breakdown), `PartyBalanceService` (what a customer owes a brand), GST rate/HSN/B2C summaries and profit by product count a mixed invoice by its lines; one-brand invoices still use the invoice's own totals, so existing figures did not move. Profit report BA salary: a mixed invoice counts with that brand's part of its total.
- **Tally export** with a brand picked lists only that brand's own invoices (`Sale::forBrandOnly`) and says how many mixed invoices were left out: a mixed invoice is one voucher and is exported only under All brands, so it can never be imported twice.
- **Credit notes stay one brand:** `SalesService::createReturns()` makes one credit note per brand when returned lines span brands (the sale page uses it); `createReturn()` refuses mixed lines.
- **Access:** `BrandContext::authorizeSale()` - a user limited to some brands may open/print a mixed invoice if it has one of their brands, and edit it only with all of them. The sale form also lists products already on the invoice, so editing a mixed invoice with one brand picked keeps the other brand's lines.
- **Payments stay per brand** (unchanged): the sale page shows "Brands on this invoice" with each brand's part, and the customer's payment is recorded against each brand for its part. Not built: one payment split across brands automatically.
- Tests: `tests/Feature/MixedBrandSaleTest.php` (Smart Sales Entry end to end, parts adding up to the rounded total, figures, receivables, GST, Tally, returns, access). Locally the suite needs an app key (no `.env` here): `APP_KEY=base64:... php vendor/bin/phpunit`; full-page tests also need `public/build` (or a temporary `public/hot`).

**2026-11-13 — Stock transfers can be deleted** (user request): a Delete button on the transfers list and on the transfer page (`inventory.transfer` permission, both warehouses accessible). `StockTransferService::deleteBatch()` reverses every line - out of the destination, back into the source at the line's cost - then removes the lines and the batch, all in one transaction. The stock history keeps the transfer and its reversal (notes "Transfer STB-... deleted"), unlike a sale delete, because earlier edits leave movements pointing at removed lines, so a transfer's own history cannot be found and dropped reliably. If the destination has since sold or moved the goods, nothing changes and the reason is shown. An issued challan number is not reused. Editing keeps its own order (destination debited only after the edited lines are credited, 2026-11-01 fix); delete uses `reverseLines()`. Tests: `tests/Feature/StockTransferDeleteTest.php`.

**2026-11-13 — Smart Sales Entry "Build from Prompt" adds only sellable products** (user request): products with no stock at the warehouse, or no rate for the customer's tier (`PricingService::priceLine()` unit price 0, the same test as the review window's "no selling price" warning), are left out whatever the instruction's scope ("every product" used to include zero-stock ones), and a quantity above the stock is cut to the stock. The review window says what was left out (`promptNote`, names of the unpriced ones). Test in `SmartSalesEntryPromptTest`.

**2026-11-14 — Build from Prompt: target amount across several categories, with a quantity of its own for some groups** (user request: "create a order target amount 30000 Add items Creams,Shampoo,Scrubs,Lip Balm,Perfume, 1 each perfume set"). The parsers (AI `SalesOrderPromptParser` and the regex `HeuristicSalesOrderPromptParser`, one result shape via `SalesOrderPromptParser::normalize()`) now also return `target_amount`, `category_names` (all named) and `fixed_quantities` ([name, quantity]). `SmartSalesEntry::buildFromPrompt()`: fixed groups get their quantity (a category of exactly that name, else products with those words in their name - "perfume gift" - else a category the phrase mentions - "perfume set" = Perfume); the rest of the target is shared over the named categories (all products if none) by `SalesOrderPlanner` (same quantity of each, closest to the amount, valued at the customer's listed price with GST - the Target amount mode's rule); without a target a flat "N each" applies. Category names are matched ignoring plurals and by containment ("Lip Balm" = Balm). Stock/rate exclusions and stock caps as before; the review note gives the planned value and what was left out. The regex parser keeps the old reading of "Order 3 pieces each of Creams in stock" (3 each, category Creams) unless the sentence has an amount or a list. Template "Target amount from categories" replaced "Every product, ignore stock level" (zero-stock products are always left out now).

**2026-11-14 — Smart Sales Entry lists only products in stock** (user request). `SmartSalesEntry::stockOnHand()` = pieces per product at the chosen warehouse (before one is chosen: across the user's warehouses). The review window's product dropdown (plus products already on a line), its "Check match" alternatives, the By amount picker and the Grid (its "In stock only" tickbox removed; a product with a quantity already typed stays, so it can be cleared; category tabs with nothing in stock are hidden) show only those. Each review line shows its stock ("Stock 12", red "Only 4 in stock" / "No stock" when the lines of that product ask for more); Confirm is disabled and `confirmSale()` refuses while any line is short (`overStockProductIds()`), instead of the save failing on negative stock. The paste matcher (SmartSalesParser, shared with quotations) still matches every product, so a line can land on an out-of-stock product - the red badge says so and the in-stock alternatives are offered. Tests in `SmartSalesEntryTest`.

**2026-11-14 — Switching brand in the header no longer reloads the page** (user report: a half-entered sale was emptied). `BrandSwitcher::updatedBrandId()` sets the brand and dispatches `brand-switched`; the switcher's view then calls `$refresh` on every Livewire component inside `<main>` (the page body - the bell and the command palette in the layout are left alone), so they re-render under the new brand with their state kept (what was typed or picked, filters). A page whose body is not Livewire is reloaded as before. This relies on components reading the brand while rendering or acting, never only in `mount()` (checked 2026-11-14: the only mount-time uses are access checks on show pages) - keep it that way. Tests in `BrandAccessTest`.

**2026-11-14 — Smart Sales Entry "Grid" mode** (user request: an Excel-like grid, category selection changes the grid, keep updating quantities). Fourth tab next to Paste / By amount / Plain English. Category chips (with a count of lines typed in each) and a name/SKU search narrow the rows; "In stock only" (on by default) never hides a product that already has a quantity. Columns: #, product, category, stock at the chosen warehouse, the customer's rate (listed, with GST), qty, amount. Qty cells use a plain deferred `wire:model` on `gridQuantities.{id}`: typing stays in the browser and reaches the server with the next request, so a reply never overwrites a cell being typed in (`updatedGridQuantities()` saves as typed and must not rewrite the array). Totals, row amounts and chip counts are worked out in the browser (Alpine, from rates/categories handed over by the `grid()` computed; the block is keyed by customer so rates refresh). Enter/↓/↑ move between qty cells; a qty above stock turns red. Quantities are kept in the session per workspace and person (`gridSessionKey()`), so switching category or reloading loses nothing; Review order (`reviewGrid()`) fills the usual review window (lines marked `(grid)`, by category then name) and a confirmed sale built from the grid clears it. Tests in `SmartSalesEntryTest` (grid_*).

**2026-11-14 — "Copy list" in the Smart Sales Entry review window** (user request): copies the lines as order text, one `<catalog name> <qty>pc` per line (`SmartSalesEntry::draftAsText()`, edits included; a line still without a product keeps what was typed), so the next order with the same items and pieces is one paste. Catalog names make every pasted line an exact match. Test in `SmartSalesEntryTest`.

**Deploy note (2026-11-13):** `deploy/deploy.sh` backs up only the database named in `.env` (the central `aradhyaenterpris_erp`); each company's data is in its own `aradhyaenterpris_<tenant id>` database, which it does not back up. Back those up by hand before a deploy until the script covers them.

---

## 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**
