# Alva Requor Store POS & Inventory Management System — Documentation

Laravel 10 / PHP 8.1+ / MySQL / Tailwind / Alpine.js. Multi-branch-ready, single-tenant-per-deployment (see §4). This document is the functional reference; see [DEPLOYMENT.md](DEPLOYMENT.md) for server setup and [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for the security-specific audit trail.

---

## 1. Installation

```bash
composer install
npm install
cp .env.example .env
php artisan key:generate
php artisan migrate:fresh --seed
npm run build           # or `npm run dev` while developing
php artisan storage:link
php artisan serve
```

This seeds the four RBAC roles/permissions plus a demo business, branch, and one user per role (`database/seeders/BusinessSeeder.php`) — password `password` for all of them. **These are fixed, publicly-known credentials — never run the full seeder against a real production database** (see [DEPLOYMENT.md §5](DEPLOYMENT.md#5-first-deploy-migrations-seeding-permissions)).

For production hosting, see [DEPLOYMENT.md](DEPLOYMENT.md) in full.

## 2. Configuration

Two separate layers of configuration exist — don't confuse them:

- **`.env` / `config/*.php`** — server-level: database credentials, mail, session, logging, timezone. Requires a deploy/restart to take effect (or `php artisan config:cache` if config is cached).
- **Business settings** (`Settings → /settings`, gated by `settings.manage`, Super Admin only by default) — per-business behavior toggles, stored in the `settings` table (key/value, some backed by dedicated `businesses` columns), managed by `App\Services\SettingsService`. Take effect immediately, no deploy needed. Six tabs:

| Tab | Controls |
|---|---|
| **General** | Business name, currency symbol, date format |
| **POS** | `allow_discount`, `allow_credit_sales`, `allow_negative_stock`, `require_sale_confirmation`, `enable_barcode_scanning`, `default_payment_method` |
| **Inventory** | `minimum_stock` default, low-stock alert visibility, `expiry_warning_days`, `prevent_expired_sales` |
| **Tax** | `enable_tax`, default tax rate (products can still override per-product via `products.tax_rate`) |
| **Payments** | Active payment methods (toggle on/off; the default method cannot be disabled) |
| **Receipt** | Receipt header/footer text |

All of these are read through `SettingsService::forBusiness($business)->get($key, $default)` — every consuming service (`SalesService`, `InventoryService`, `PurchasingService`) reads the live value at the moment of the transaction, never a cached/stale copy.

## 3. Database

Schema: 40 application tables (plus Laravel/Spatie framework tables), all under `database/migrations/`. Key conventions, enforced consistently across the whole schema:

- Every money column is `decimal(15,2)`; every stock/quantity column is `decimal(15,3)` — never `float`/`double`, to avoid floating-point rounding error on financial and inventory math. All PHP-side arithmetic on these values uses `bcmath` (`bcadd`/`bcsub`/`bcmul`/`bcdiv`/`bccomp`) for the same reason — never native `+`/`-`/`*`/`/` on money or stock.
- Every table is tenant-scoped by a `business_id` foreign key (`cascadeOnDelete` in nearly every case — deleting a business cascades its entire dataset; `users.business_id` is the one exception, `restrictOnDelete`, so a business with users can't be deleted out from under them).
- Human-readable reference numbers (`sale_number`, `purchase_number`, `expense_number`, `return_number`, `reference_number`) are generated as `count() + 1` per business and enforced unique at the DB level (`unique(['business_id', '<column>'])`) as a collision backstop under concurrent submissions.
- `products.current_stock` is a denormalized cross-branch cache, kept in sync by `App\Services\InventoryService` — see §8.

Migrating: `php artisan migrate` (adds new tables/columns) or `php artisan migrate:fresh --seed` (drops and rebuilds everything — local/dev only, **never** on a database with real data). See [DEPLOYMENT.md §5](DEPLOYMENT.md#5-first-deploy-migrations-seeding-permissions) for the production-safe sequence.

## 4. Authentication

Custom-built (no Breeze/Jetstream/Fortify) — `app/Http/Controllers/Auth/`, session-based, one account per person (`users.email`, globally unique — see the note on multi-tenancy below).

- **Login**: `LoginRequest::authenticate()` rate-limits at 5 failed attempts per email+IP combination (Laravel's `RateLimiter` facade), locking out with a countdown message. Successful login stamps `last_login_at`/`last_login_ip` and writes an `audit_logs` entry.
- **Logout**: invalidates the session and regenerates the CSRF token.
- **Inactive account**: `EnsureUserIsActive` middleware runs on every authenticated request; if `users.is_active` is false it force-logs-out mid-session (not just at the login screen) and redirects to `/login` with an explanatory error. This means deactivating a user takes effect on their very next request, even if they're mid-session.
- **Password reset**: standard Laravel token-based flow (`PasswordResetLinkController`/`NewPasswordController`), gated behind `MAIL_MAILER` actually being configured to deliver mail (defaults to the `log` driver, i.e. reset links land in the log file, not an inbox, until real mail is configured).

**Multi-tenancy note**: `users.email` is unique globally, not per-business. This app is architected to support multiple branches per business, but the login layer assumes one deployment serves either one business or a set of businesses whose admins agree not to collide on email addresses — it is not a true multi-tenant SaaS login model (two unrelated shops signing up independently could not both register `owner@gmail.com`). This is a pre-existing architectural characteristic, not a bug introduced by this audit; changing it would be a real design decision, not a hardening fix.

## 5. RBAC (Roles & Permissions)

`spatie/laravel-permission`, seeded by `database/seeders/RolesAndPermissionsSeeder.php` using a strict `module.action` naming convention (e.g. `products.create`, `sales.cancel`, `cashier.shifts.approve`).

| Role | Summary |
|---|---|
| **Super Admin** | Every permission that exists, unconditionally. |
| **Manager** | Every permission *except* `users.*`, `roles.*`, `settings.manage`, `audit-logs.view` — computed as `allPermissions.diff(adminOnly)`, so new permissions are automatically available to Manager without a seeder update. |
| **Cashier** | Explicit allow-list: `pos.access`, `sales.create`/`view`/`return`, `returns.*`, `customers.view`/`create`/`pay`, `products.view`, `expenses.view`/`create`, `cashier.shifts.view`/`open`/`close`. Notably excludes `sales.cancel` and `sales.discount` (discounting requires Manager-level authorization — see §11). |
| **Storekeeper** | Explicit allow-list: `inventory.*`, `purchases.view`/`receive`, `products`/`categories`/`brands`/`units.view`, `reports.inventory` only (not sales/purchases/profit reports). No `pos.access` at all. |

Enforcement happens at three layers, all verified in this audit: route middleware (`permission:x.y`), Policy classes (`ProductPolicy`, `SalePolicy`, `PurchasePolicy`, `ExpensePolicy`, `CashierShiftPolicy`, `UserPolicy` — each also checks `$user->business_id === $model->business_id`, so a permission match alone can never cross a tenant boundary), and service-layer checks for anything that depends on computed values rather than raw request input (e.g. the discount-amount check in `SalesService::completeSale()`).

## 6. Products

`ProductController` + `Product` model. Each product belongs to one business, one category (required), one unit (required), and optionally one brand.

- **SKU and barcode** are both unique per business (DB-enforced via composite unique indexes, not just form validation) — a duplicate of either is rejected with a field-level validation error, on both create and edit. Barcode is nullable; any number of products may have no barcode.
- **Pricing**: `purchase_price` (cost), `selling_price`, optional `wholesale_price`, `tax_rate` (per-product override of the business default). `profit_per_unit` = `selling_price - purchase_price`.
- **Stock tracking flags**: `track_batch` / `track_expiry` — when either is on, the product's stock is tracked at the batch level (`product_batches`) with FEFO (first-expiry-first-out) depletion on sale, in addition to the aggregate `current_stock` figure every product has.
- **Archiving**: soft-deleted (`deleted_at`), not hard-deleted — an archived product can still be viewed (with a restore option) and still appears on historical sales/purchases; it simply drops out of active product pickers.
- **Price changes are separately audited**: changing `purchase_price` or `selling_price` writes a distinct `price_changed` audit log entry (not just the generic `updated` one), since price history matters more than most other field edits.

## 7. Barcode scanning

Two independent entry points, both hitting the same exact-match, business-scoped lookup (`BarcodeController::lookup()`, indexed via `unique(['business_id','barcode'])`):

- **POS checkout** (`<x-barcode-input>` component, `resources/js/app.js`) — auto-focused on page load, auto-refocuses after every scan (success or failure), Enter-triggered, AJAX (no page reload). Scanning the same barcode twice increments the existing cart line's quantity rather than creating a duplicate line. Lookups are queued (not dropped) if a scan arrives while the previous one is still in flight, so fast sequential scanning under network latency can't silently lose an item.
- **Standalone lookup page** (`/products/barcode-lookup`) and **inline in the Purchase/Stock-In forms** — same component, same behavior, for looking up or adding a product by barcode outside the checkout flow.

If a scanned barcode doesn't match any product, or matches an inactive product, the cashier sees an inline error banner without losing focus or cart state.

## 8. Inventory

`App\Services\InventoryService` is the **only** trusted mechanism for changing stock anywhere in the app — verified during this audit that no controller or other service writes to `product_stocks.quantity` or `products.current_stock` directly. Every one of its public methods funnels through `applyTransaction()`, which:

1. Locks the relevant `product_stocks` row (`lockForUpdate()`) inside a DB transaction.
2. Validates the resulting balance won't go negative (unless the business's `allow_negative_stock` setting explicitly allows it).
3. Writes an immutable `inventory_transactions` ledger row (`type`, signed `quantity`, `stock_before`/`stock_after`, `user_id`, `reference_type`/`reference_id`) — **every** stock change, with no exceptions, produces one of these.
4. Updates the branch-level `product_stocks.quantity` and the cross-branch `products.current_stock` cache atomically in the same transaction.

Operations: opening stock, receiving (purchase/stock-in), sale (FEFO-aware for batch-tracked products), sale return, purchase return, manual adjustment, damage write-off, expiry write-off, stock count variance.

**Stock count workflow**: create a count → add products (individually or "add all active") → enter physical quantities → approve. Approval applies each item's pre-computed variance (`physical − system`, snapshotted when the item was added) through `InventoryService::applyStockCountVariance()`, all inside one transaction — a mid-loop failure can never leave some items applied and the count still marked in-progress.

**Concurrency**: every stock-changing operation is protected by row-level locking, not just a transaction boundary — verified both by the pre-existing test suite (`test_concurrent_sales_cannot_oversell_the_same_product`) and by this audit, which additionally found and fixed a related class of bug: several *status-transition* actions (purchase confirm/cancel, sale cancel, stock-count approve, cashier-shift close/approve) checked their guard condition (`isDraft()`, `isCancelled()`, etc.) against an in-memory model fetched *before* the transaction opened, with no re-validation against a locked row inside it. Two overlapping requests could each pass the check and both apply their effect (e.g. doubling stock received and doubling the supplier balance charged on a double-submitted purchase confirmation). All five instances found were fixed — see the audit's bug-fix log for detail.

## 9. Purchasing

`App\Services\PurchasingService`. A purchase is a **draft** until confirmed — creating or editing a draft has zero effect on stock or supplier balance. Confirming:

1. Receives every line's quantity into stock via `InventoryService::receiveStock()` (creates a `product_batches` row too, if the product tracks batches/expiry).
2. Increases the supplier's `current_balance` by the purchase total (a `supplier_ledger_entries` row records it).
3. Marks the purchase confirmed, stamped with who/when.

Cancelling a confirmed purchase reverses only what's still outstanding — if a supplier return has already taken back part of the purchase, cancellation reverses just the remainder, so stock/balance are never double-reversed. Cancelling a purchase with payments already recorded against it is blocked (resolve the payment first).

Supplier payments and supplier returns both flow through the same service, each wrapped in its own transaction with row-level locking on the supplier's balance.

## 10. POS

`PosController` + `resources/views/pos/index.blade.php` (Alpine.js-driven single-page checkout screen, no reload between adding items and completing payment).

- **Cart**: add via barcode scan, typed search, or clicking a product tile; quantity is editable per line; per-line and invoice-level discounts (gated by the `sales.discount` permission and the business's `allow_discount` setting).
- **Tax**: computed per-line from each product's `tax_rate` (or the business default), only when `enable_tax` is on.
- **Payments**: cash, plus any active payment method configured in Settings → Payments. **Mixed payment** is supported (e.g. part cash, part mobile money) — payments sum to the total, and only cash is allowed to exceed the total (producing change; any other method overpaying is rejected). **Credit sales** (no payment collected at time of sale, recorded as a customer receivable) require the `sales.credit` permission, an attached customer, the business's `allow_credit_sales` setting, and cannot exceed the customer's credit limit.
- **Sale completion**: `SalesService::completeSale()` — validates every line against live stock (race-safe, re-checked under lock inside `InventoryService`, not just the pre-flight check shown in the UI), computes totals, writes the sale + items + payments, and updates inventory, all in one transaction.
- **Held sales**: park an in-progress cart (e.g. customer steps away) and resume it later without losing the line items.

## 11. Payments

Two independent ledgers, structurally identical: **customer payments** (against a customer's outstanding sale balance or general account balance) and **supplier payments** (against a purchase's outstanding balance or a general supplier balance). Both:

- Require the amount be positive and not exceed the outstanding balance being paid down.
- Lock the customer/supplier row (`lockForUpdate()`) before adjusting `current_balance`, so two simultaneous payments against the same account can't race.
- Write a ledger entry (`customer_ledger_entries` / `supplier_ledger_entries`) recording `balance_before`/`balance_after`, not just the delta — every historical balance is independently reconstructable from the ledger, not dependent on the current `current_balance` figure being correct.

## 12. Returns

**Customer (sale) returns** — `SaleReturnController` / `SalesService::recordSaleReturn()`: select which sold items and quantities are coming back (cannot exceed what remains returnable after any prior partial returns on the same sale), mark each line restockable or damaged. Restockable lines return to sellable stock (to the exact batch they were sold from, if batch-tracked); damaged lines create a `damage` inventory transaction instead and do not increase sellable stock. Refund method is one of: cash, original payment method, store credit, or customer-account credit (the latter two require the sale to have a customer attached, and adjust the customer's balance under the same locked, ledgered pattern as §11).

**Supplier returns** — `PurchasingService::recordSupplierReturn()`: the mirror operation against a confirmed purchase — returns stock to the supplier (an inventory decrease) and reduces the supplier balance by the returned value. Cannot return more than was originally purchased, net of any prior returns on the same purchase.

Both types write an audit log entry and are fully reflected in the inventory ledger (a return is never a "silent" stock change).

## 13. Expenses

`ExpenseController` — categorized (`expense_categories`, per-business), tied to a branch, optional receipt upload (image or PDF, MIME-type and size-limited, stored under a randomized filename — not the original filename, so uploads can't overwrite each other or be path-traversed). Expenses can be cancelled (not hard-deleted) with a required reason; cancelled expenses are excluded from cashier-shift cash-expense totals and profit/loss calculations.

## 14. Reports

`app/Services/Reports/*` + `/reports/*` routes, organized under four hubs: **Sales** (summary, by cashier/product/category/customer/payment-method, year-over-year), **Purchases** (summary, by supplier/product, supplier balances), **Inventory** (current stock, valuation, movement ledger, low/out-of-stock, damaged/expired, adjustments, stock-count variance), **Profit** (P&L, product profitability, top products, slow-moving stock). Every report supports CSV/Excel/PDF export via a shared `ExportsReports` trait.

**Every financial figure is computed with `bcmath`, not SQL `SUM()` or native float arithmetic** — rows are fetched from the database with plain filtering, then folded in PHP through helpers in `AggregatesDecimals`, specifically to avoid the rounding error that floating-point aggregation can introduce over many rows. Verified this audit: revenue is tax-exclusive (tax collected is a liability, not revenue), COGS uses each sale item's `cost_price` as snapshotted *at the time of that sale* (not the product's current purchase price, so historical reports stay accurate even after a price change), and returns are netted out of both revenue and COGS in the same period (matching-principle correct).

**Known limitation, not fixed in this pass**: report queries that filter by date have no default date range — visiting a report URL with no query-string filters returns the entire lifetime history for the business, folded in PHP, in one request. At this app's realistic target scale (a single shop or small chain) this is not a near-term problem, but it is a real, documented scaling ceiling — see the production-readiness checklist.

## 15. Backups

Covered fully in [DEPLOYMENT.md §7](DEPLOYMENT.md#7-backup-strategy). Summary: `php artisan app:backup-database` (scheduled daily at 02:00) dumps and gzip-compresses the database to `storage/app/backups/` (private, gitignored) and prunes anything older than 14 days. Requires `mysqldump` on the server and the scheduler's one cron entry (`schedule:run` every minute) to be installed.

## 16. Troubleshooting

| Symptom | Likely cause / fix |
|---|---|
| Changed `.env` but nothing happened | Config is cached (`php artisan config:cache` was run at some point). Run `php artisan config:clear` (or re-run `config:cache` after the edit). |
| 500 error with no detail, blank/generic page | Correct production behavior when `APP_DEBUG=false` — check `storage/logs/laravel-*.log` for the real exception (daily-rotated, see §"Logging" note below). Do **not** flip `APP_DEBUG=true` on a live production site to debug, even temporarily — it exposes stack traces/SQL to every visitor. |
| Product images / receipts 404 | `php artisan storage:link` wasn't run, or the symlink was lost during a deploy that recreates the release directory (common with some zero-downtime deploy tools) — re-run it. |
| "SQLSTATE... Cannot drop index... needed in a foreign key constraint" during a migration rollback | Some indexes on FK columns are load-bearing for that foreign key's constraint (MySQL/InnoDB requirement) and can't be dropped independently — see the comment in `database/migrations/2026_08_13_173517_add_missing_performance_indexes.php` for a concrete example. Not a bug; drop/recreate the FK itself first if you truly need to remove such an index. |
| A purchase/sale/stock-count reference number collided (rare) | Every reference-number column is now uniquely constrained per business at the DB level (fixed in this audit) — a genuine collision under concurrent submission now fails loudly with a duplicate-key error rather than silently duplicating. Retry the submission. |
| Backup command fails with "mysqldump: command not found" | Set `MYSQLDUMP_PATH` in `.env` to the full path of the binary (e.g. on XAMPP/Windows: `C:\xampp\mysql\bin\mysqldump.exe`). |
| Login rate-limited ("Too many login attempts") | Expected after 5 failed attempts for the same email+IP within the window; wait out the countdown shown, or `RateLimiter::clear()` the key manually via `tinker` in a dev environment. |
| A user was deactivated but can still act | Should not happen — `EnsureUserIsActive` force-logs-out on their very next request. If observed, confirm the middleware is still registered on the `auth` route group in `routes/web.php` and that `is_active` was actually persisted (not just toggled in an unsaved form). |
| Report/export takes a long time or times out | No date filter applied — see §14's known limitation. Add a `date_from`/`date_to` filter to bound the query. |

**Logging**: default channel is `daily` (rotating, 14-day retention — fixed in this audit from a single unbounded file). Application errors are never displayed to end users in production (`APP_DEBUG=false`) — they're only ever written to the log file, so `storage/logs/` is the correct (and only) place to look.

## 17. Deployment

See [DEPLOYMENT.md](DEPLOYMENT.md) for the full guide: server requirements, install steps, `.env` configuration, web server document-root setup, first-deploy migration/seeding sequence, production optimization commands, backup strategy, queue, scheduler, and a post-deploy smoke test.

---

## Production-readiness checklist

See below for the honest, as-of-this-audit verdict — including what's genuinely solid and what remains open.

### Solid / verified clean

- [x] SQL injection — no raw-SQL call site interpolates request-controlled input.
- [x] XSS — zero unescaped Blade output (`{!! !!}`) anywhere in the app.
- [x] CSRF — enforced on every state-changing route with zero exceptions.
- [x] Mass assignment — every model uses explicit `$fillable`; every create/update path uses validated FormRequest data.
- [x] IDOR — every route-bound model access is scoped to the authenticated user's business, checked both at the FormRequest/policy layer and (mostly) with explicit `abort_unless` guards.
- [x] File uploads — MIME/size-limited, randomized storage filenames, no path traversal.
- [x] Password handling — bcrypt via Laravel's hashing config, rate-limited login, no plaintext anywhere.
- [x] Inventory integrity — single trusted write path, row-level locking, no negative stock without an explicit opt-in setting, immutable per-change ledger.
- [x] Financial calculations — `bcmath` throughout, historical cost snapshots, tax/discount/refund logic all verified against their own documented spec and a large existing test suite.
- [x] Database schema — consistent decimal precision, foreign keys with explicit and considered `onDelete` behavior, no missing FK indexes.
- [x] Concurrency (stock/money) — verified and, where gaps were found, fixed: five status-transition race conditions (purchase confirm/cancel, sale cancel, stock-count approve, cashier-shift close/approve) that could double-apply stock or balance changes under overlapping requests.
- [x] N+1 queries — the checkout hot path, stock-count bulk-add, stock-count approval, and two dashboard/list views had confirmed N+1s; all fixed and verified against the full test suite.
- [x] Backups — implemented (was entirely absent before this audit): scheduled, compressed, pruned, tested against the real database.
- [x] Logging — errors are never shown to users in production, are never silently swallowed, and now rotate daily instead of growing one unbounded file.
- [x] Barcode scanning reliability — auto-focus/refocus confirmed working; a latent "dropped scan under latency" gap (input disabled during lookup) was found and fixed.

### Open items — do not claim these are resolved

- [ ] **Report queries have no default date range** and can pull a business's entire lifetime history into PHP memory in one request (compounded by CSV/Excel export, which materializes the same unbounded collection). Not fixed in this pass — the fix requires a product decision (what should the default range be, and should "all time" still be reachable) that's outside a hardening audit's mandate. Low risk at this app's realistic target scale today; becomes real after a couple of years of accumulated history for an active shop.
- [ ] **No off-site/offline copy of backups by default** — the backup command writes to local disk only. A server-loss event (not just a bad deploy) would lose both the live database and its backups together unless someone separately syncs `storage/app/backups/` off-server. Documented in DEPLOYMENT.md as a recommendation; not implemented (requires choosing a destination specific to your hosting).
- [ ] **No automated backup-restore test.** The backup command was verified to produce a valid, restorable gzip'd SQL dump once, manually, during this audit — there's no scheduled job that periodically proves a backup can actually be restored. Untested backups are a known risk category.
- [ ] **User/role/settings management has no UI** (carried over from the prior security audit, not addressed here — out of this audit's scope, listed under "verify," not "build"). `UserController` is read-only; there's no `RoleController` at all. Anyone needing to create a user, change a role's permissions, or manage settings today must do so via `tinker` or a database client. This is incomplete functionality, not a vulnerability (nothing exploitable exists because nothing reachable exists), but it is a real gap for actually operating this system day-to-day.
- [ ] **Two production `.env` values require a human decision at deploy time and are easy to forget**: `APP_DEBUG` must be flipped to `false` and `SESSION_SECURE_COOKIE` to `true`. Both are called out prominently in `.env.example` and DEPLOYMENT.md, but neither is enforced by the application itself — a deploy that forgets either one will run without complaint.
- [ ] **No automated CI pipeline was found or added** in this repository (no `.github/workflows`, no equivalent). The test suite (480 tests, all passing as of this audit) is comprehensive but only runs when a developer remembers to run it locally.

### Verdict

**Not unconditionally production-ready.** The application's core transactional logic — inventory, sales, purchasing, payments, returns, RBAC — is genuinely solid and was hardened further during this audit (5 concurrency bugs fixed, N+1s fixed, backups implemented, logging fixed, indexes added). It is ready to run a real shop's day-to-day operations at the scale this app is clearly designed for (single business, one or a few branches, a normal retail transaction volume).

It is **not** ready to hand to a non-technical operator with zero further setup: someone with server/Laravel experience still needs to (1) follow DEPLOYMENT.md's `.env` checklist by hand, (2) decide on and implement off-site backup replication, and (3) manage users/roles via direct database access until that UI is built. None of these are defects in what exists — they're gaps in what doesn't exist yet, and this audit's job was to report that honestly rather than paper over it.
