> **REMOVED 2026-11-14:** the owner decided there are no counter sales. The POS, shifts and customer returns were built and then removed; the accounting core stays (see `docs/database.md`).

# POS and Accounting Plan (Phase 9) — PLAN ONLY, NOT BUILT

Status: **draft for the owner to approve** (written 2026-11-13). No code yet.
Table details are in `docs/database.md` section 15. This file explains *why* and *how things post*.

Goal: a full point-of-sale for selling parts and accessories **inside the same app**, sharing the
existing `parts`, `stock_movements`, `customers`, `suppliers` and `payments`, plus full
double-entry accounting with the standard reports.

Everything must still run on LankaHost shared hosting: Laravel + MySQL, Blade + Alpine, no Node on
the server, no Redis, no long-running processes. All posting happens **synchronously inside the
same database transaction** as the document (sale, GRN, payment…), so there is nothing to queue.

---

## 0. The owner's answers — NOT RECEIVED

The request had placeholders instead of answers. Until they are confirmed, the plan supports both
options, and these defaults are assumed:

| Question | Received | Assumed until confirmed |
|---|---|---|
| VAT registered? | `<yes/no>` | Build VAT fully, **switched off** (`vat_enabled = false`). Turning it on needs no new tables |
| Credit sales? | `<yes/no>` | **Yes**: build credit customers (needed anyway for customer accounts / statements) |
| Price levels | `<list>` | **Retail** (default), **Wholesale**, **Technician / dealer** |
| Receipt printer | 80mm thermal | 80mm receipt, printed from the browser (see §9) |
| Label size | `<e.g. 38x25mm>` | **38 × 25 mm**, one label per unit |

See §14 for the full list of things to decide.

---

## 1. Big picture

```
           ┌────────────── Purchasing ──────────────┐
 Supplier ─► Purchase order ─► GRN (goods received) ─► Supplier bill ─► Supplier payment
                                   │ stock in (+units)        │ AP            │ AP ↓ cash/bank ↓
                                   ▼                          ▼               ▼
                           stock_movements             journal_entries / journal_lines
                                   ▲                          ▲               ▲
                                   │ stock out (units sold)   │ AR / sales    │ cash/bank ↑
 Customer ◄─ POS sale (invoice kind = pos) ─► receipt ─► payment(s) ─► cash drawer shift
           ◄─ Repair invoice (existing)
           ◄─ Sales return (credit note) ─► refund / store credit
```

Three rules that do not change:

1. **Stock**: `stock_movements` stays the only source of truth for quantities. Nothing stores a
   stock number. Serial-numbered units add *identity*, not quantity (§3).
2. **Money documents** (invoices, payments, GRNs, returns) are never edited after they are
   finalized; mistakes are reversed with a new document.
3. **Accounts**: every money or stock event writes one balanced journal entry
   (total debits = total credits) **in the same transaction**. Journal rows are never updated
   or deleted; corrections are reversing entries. If the entry does not balance, the whole
   document is rolled back.

---

## 2. Product master

**Reuse `parts` as the product master** (do not create a second products table). Repair parts
and things we sell over the counter (cases, chargers, cables, used phones…) are the same rows, so
stock, purchasing and reports stay in one place. The menu label becomes "Products".

Changes to `parts` (details in database.md §15):

- `product_type`: `part` (repair part), `accessory`, `device` (phone / laptop for sale), `service`
  (no stock, e.g. "screen guard fitting").
- `is_sellable` (shown on POS), `is_stocked` (false for services).
- `is_serialized` — every unit gets its own label barcode and (optional) manufacturer serial / IMEI.
  On for devices and expensive parts; off for screws, glue, cables (§3).
- `barcode` (already exists) = the **manufacturer barcode** (EAN/UPC) of the product.
- `tax_category`: `standard`, `zero`, `exempt` (only matters when VAT is on).
- Prices: `selling_price` stays as the **retail** price; other levels in `part_prices`.
- `reorder_qty` (suggested order quantity for purchase orders).

Price levels: new `price_levels` table (Retail, Wholesale, Technician…) and `part_prices`
(part × level → price). A customer can have a default level; the cashier can switch level on a
sale if allowed (permission, §12). Missing level price → falls back to retail.

---

## 3. One barcode per unit (serial tracking)

New table **`stock_units`**: one row per physical unit of a `is_serialized` product.

- `unit_code`: our label barcode, unique across the system, printed on a 38×25mm label
  (format proposal: `U` + 8 digits, e.g. `U00012345`; Code 128).
- `serial_no`: the manufacturer serial / IMEI (optional, unique per product when given).
- `status`: `in_stock`, `reserved`, `sold`, `used_in_job`, `supplier_return`, `damaged`,
  `returned_to_stock` is just `in_stock` again.
- `purchase_item_id`: the GRN line it came in on → **exact supplier and cost** for every unit
  (this also makes the warranty report's supplier exact instead of "likely").
- `unit_cost`: what we paid for this unit.

How it fits with `stock_movements`:

- A movement still says "part X, quantity −1, type `sale`". The link table
  **`stock_movement_units`** says *which* units that movement moved.
- For a `is_serialized` product, the code refuses a movement unless the number of units attached
  equals the quantity, and each unit is in the right status. So quantity (movements) and
  identity (units) always agree; a nightly-free check can compare `count(units in_stock)` with
  the movement sum and show a warning on the stock page if they ever differ.
- At the GRN, staff scan or type the serials (or press "Make labels" to create unit codes
  without serials) and print the labels.
- At the POS and on a job's Parts tab, a `is_serialized` product **must be scanned** (unit code or
  serial). Non-tracked products are scanned by product barcode or picked by name.

Existing stock of tracked products at go-live: a one-time "Label existing stock" screen creates
units for the current quantity (cost = current average cost) and prints labels.

---

## 4. Purchase orders and GRN

Today `purchases` is "stock in" (goods received, saved straight as `received`). Plan:

- New **`purchase_orders`** + **`purchase_order_items`**: what we asked the supplier for.
  Status: `draft` → `sent` (WhatsApp/email/print PDF) → `partly_received` → `received` / `closed`
  (closed = we will not receive the rest) / `cancelled`. **No stock and no accounting** — a PO is
  only a promise.
- The existing **`purchases` table becomes the GRN** (keep the table name; screens say
  "GRN / Stock in"). New column `purchase_order_id` (null = GRN without a PO, as today).
  New numbers use `GRN-2026-0001`; old rows keep their `PO-…` numbers.
- `purchase_items` gets `purchase_order_item_id`, `tax_rate`, `tax_amount`. Receiving a GRN line
  raises `received_qty` on the PO line; over-receiving needs a reason.
- Supplier's bill: one GRN = one supplier bill (supplier invoice no, date, **due date**). The
  current `paid_amount` / `payment_status` columns become a cache updated from supplier payment
  allocations (§6).
- Part requests keep working: receiving a GRN still reserves arrived parts for waiting jobs.

---

## 5. POS sales screen, receipts, sales

**A POS sale is an invoice** (`invoices.kind = pos`). This was already the plan (Decision 10:
counter sales use `invoices` with no job). Benefits: one sales ledger, one VAT sequence, the Daily
Summary, payments, PDFs and reports already understand invoices.

POS screen (Blade + Alpine, tablet and PC):

- Big search / scan box (USB barcode scanners type like a keyboard + Enter — no driver or package).
  Scan unit code → that unit; scan product barcode → the product; type → search by name/SKU.
- Cart: qty (tracked products: one line per unit), price (by price level), line discount
  (permission + limit), notes.
- Customer: walk-in (no customer) or search by phone; credit sale only for a customer with credit
  allowed and enough credit left.
- Pay: one or more payments (cash, card, bank transfer, other); cash shows change to give;
  "Hold sale" (park) and "Recall".
- Finish = **finalize immediately**: invoice issued (number), stock out, units → `sold`, payments
  saved, journal entries posted, warranty rows made (from `parts.warranty_days`), receipt opens
  for printing. All in **one transaction**.
- Requires an **open cash drawer shift** for the cashier (§8).

Receipts: the existing `payments.receipt_no` stays the receipt for a payment. The 80mm printout of a
POS sale shows the sale lines, payments, change, warranty per line and a QR/number to find the sale
again. Repair payments get the same 80mm receipt layout (option on the invoice page).

Void of a POS sale: admin only, only while the shift is still open; otherwise use a return (§7).

---

## 6. Credit customers, customer payments, supplier payments

Customers (`customers`) get: `credit_allowed`, `credit_limit`, `credit_days`, `price_level_id`,
`tax_number` (customer VAT no. for B2B tax invoices).

Walk-in sales need **no customer**: `invoices.customer_id` and `payments.customer_id` become
nullable (credit sales and repairs still require one).

**Payment allocations** (customers): new `payment_allocations` (payment → invoice, amount).
A credit customer pays one lump sum against several invoices; an advance sits unallocated (a
customer deposit) until an invoice is issued. The **paid amount of an invoice = sum of its
allocations**. Existing `payments.invoice_id` links are copied into allocations by the migration,
and the column is then kept only as "first invoice" for old screens until they are moved over
(decide: drop later, see §14).

**Supplier payments**: new `supplier_payments` + `supplier_payment_allocations` (payment → GRN).
This replaces Decision 8 ("no supplier ledger"). The migration turns every existing
`purchases.paid_amount` into one supplier payment (method `other`, date = purchase date, note
"Before Phase 9").

Statements and ageing come from the journal lines tagged with `customer_id` / `supplier_id` on the
AR / AP control accounts, so they always agree with the balance sheet.

---

## 7. Returns

**Customer returns** (`sales_returns` + `sales_return_items`, number `CN-2026-0001` = credit note):

- Pick the original invoice (scan the receipt / search); choose lines and units; reason;
  condition: **restock** (unit back `in_stock`) or **faulty** (to `damaged`, or kept aside for a
  supplier return).
- Settle as: cash/card **refund** (a `payments` row type `refund`, needs an open shift for cash),
  **reduce what the customer owes** (credit customers), or **store credit** (customer deposit).
- Rules: not more than was sold; within `return_days` (setting); admin approval above a limit.
- Repair invoices keep using **void** (whole invoice) as built in Phase 6; part refunds on a repair
  invoice use the same `sales_returns` document.

**Supplier returns** (`supplier_returns` + items — already designed in database.md §7, never built):
`draft` → `sent` (stock out, units → `supplier_return`) → `credited` (supplier reduces our bill) /
`replaced` (new units come in, `supplier_replacement` movement) / `rejected` (units come back).
Units link to their GRN line, so we always return to the right supplier.

---

## 8. Cash drawer shifts

- `cash_drawers`: the physical drawers (one now: "Counter 1"), each linked to a cash account.
- `cash_shifts`: a cashier opens a shift on a drawer with an **opening float**, closes it with the
  **counted cash**; the app works out expected cash = float + cash sales + cash payments − cash
  refunds ± pay-ins/outs. Over/short is posted (§10). One open shift per drawer.
- `cash_movements`: pay-in (owner adds change), pay-out / petty cash (with expense account and
  reason), drop to safe / bank deposit.
- Every **cash** payment or refund (POS and repair counter) must be in an open shift
  (`payments.cash_shift_id`). Card / bank payments do not need a shift but are still listed.
- The **Daily Summary** (built) stays as the day report. Its "Close day" becomes a day sign-off
  that is only allowed when all shifts of that day are closed; the cash count moves to the shift.
  Old `day_closings` rows stay as history.

---

## 9. Printing

- **80mm receipts**: an HTML page sized for 80mm paper (`@page { size: 80mm auto; margin: 0 }`,
  72mm printable width), printed with the browser's Print (thermal printer driver installed on the
  counter PC, "Paper: 80mm roll"). No print server, no package. Auto-print can be set in Chrome
  with kiosk printing on the counter PC.
- **Cash drawer opening**: thermal printers open the drawer through the printer driver setting
  "Open drawer when printing". The browser cannot send raw printer codes.
- **Labels 38×25mm** (if confirmed): HTML label sheet, one label per page, like the existing
  job stickers. The barcode (Code 128) needs an image: either a **small barcode package**
  (`picqer/php-barcode-generator`, MIT, pure PHP, SVG/PNG, no extensions) or ~150 lines of our
  own Code 128 SVG code. CLAUDE.md says to ask before adding packages → decision in §14.
- A4 PDFs (invoice, PO, GRN, statements) use dompdf as today.

---

## 10. How each event posts (accounts and stock)

Accounts are named here by their **system key** (the chart is in database.md §15, `accounts`).
VAT lines appear only when `vat_enabled`. AR/AP lines carry `customer_id` / `supplier_id`.
COGS uses the **average cost** at the moment of the stock movement (`stock_movements.unit_cost`),
as today. For tracked units the unit's own cost is stored for information (§14 decision).

### 10.1 Purchasing

| Event | Stock | Journal |
|---|---|---|
| Purchase order sent | none | none |
| **GRN received** (credit supplier) | `purchase_in` +qty (+units `in_stock`); average cost updated | Dr `inventory` (cost excl. VAT) · Dr `vat_input` · Cr `accounts_payable` (supplier) |
| GRN for a cash purchase (no supplier credit) | same | Dr `inventory` · Dr `vat_input` · Cr `cash_drawer` / `bank` |
| Supplier payment | none | Dr `accounts_payable` (supplier) · Cr `cash_drawer` / `bank` |
| Supplier discount on payment | none | Dr `accounts_payable` · Cr `purchase_discounts` |
| Supplier return **sent** | `supplier_return` −qty (units → `supplier_return`) | Dr `supplier_returns_pending` · Cr `inventory` (at cost) |
| …supplier **credits** us | none | Dr `accounts_payable` · Dr/Cr `vat_input` (reverse) · Cr `supplier_returns_pending` |
| …supplier **replaces** | `supplier_replacement` +qty (new units) | Dr `inventory` · Cr `supplier_returns_pending` |
| …supplier **rejects** (comes back) | `adjustment_in` +qty (units back) | Dr `inventory` · Cr `supplier_returns_pending` |

### 10.2 POS sales

| Event | Stock | Journal |
|---|---|---|
| **Cash sale** | `sale` −qty (units → `sold`) | Dr `cash_drawer` (total) · Cr `sales_products` (net) · Cr `vat_output` — and — Dr `cogs_products` · Cr `inventory` (at cost) |
| Card sale | same | Dr `card_clearing` instead of cash |
| Bank transfer | same | Dr `bank` |
| **Credit sale** | same | Dr `accounts_receivable` (customer) instead of cash |
| Invoice-level discount | — | Dr `discounts_allowed` (contra income) instead of reducing sales |
| Card settlement (bank pays us, minus fee) | — | Dr `bank` · Dr `card_fees` · Cr `card_clearing` |
| Credit customer pays | — | Dr `cash_drawer`/`bank` · Cr `accounts_receivable` (customer) |
| Void POS sale (same shift) | movements reversed (`sale_reversed`), units back | exact reversing entry |

Split payment (part cash, part card): one sale entry with several debit lines.

### 10.3 Returns

| Event | Stock | Journal |
|---|---|---|
| **Customer return, restocked** | `sale_return` +qty (units → `in_stock`) | Dr `sales_returns` (contra income, net) · Dr `vat_output` · Cr `cash_drawer` / `accounts_receivable` / `customer_deposits` — and — Dr `inventory` · Cr `cogs_products` (original cost) |
| Customer return, faulty | `sale_return` +qty then `damaged` −qty (or kept for supplier return) | same sales part · Dr `inventory` · Cr `cogs_products`, then Dr `stock_writeoff` · Cr `inventory` |

### 10.4 Repairs (existing flow — will start posting too)

| Event | Stock | Journal |
|---|---|---|
| Advance at intake | — | Dr `cash_drawer`/`bank`/`card_clearing` · Cr `customer_deposits` (customer) |
| Part used on a job | `job_used` −qty | Dr `cogs_repair_parts` · Cr `inventory` |
| Part returned from a job | `job_returned` +qty | reverse of the above |
| **Repair invoice finalized** | — | Dr `accounts_receivable` (customer) · Cr `repair_labour_income` (labour lines) · Cr `repair_parts_income` (part lines) · Cr `other_income` (fees) · Cr `vat_output` · Dr `discounts_allowed` |
| …advance linked to it | — | Dr `customer_deposits` · Cr `accounts_receivable` |
| Payment on repair invoice | — | Dr `cash_drawer`/… · Cr `accounts_receivable` |
| Repair invoice **void** | — | exact reversing entry; allocations removed so the advance goes back to `customer_deposits` |
| Warranty job (no charge) | parts used still post COGS | Dr `warranty_cost` instead of `cogs_repair_parts` (so warranty cost is visible) |
| Delivered with a balance | — | nothing new: the balance stays in `accounts_receivable` |

### 10.5 Stock and cash adjustments

| Event | Stock | Journal |
|---|---|---|
| Stock count / adjustment in | `adjustment_in` | Dr `inventory` · Cr `stock_adjustments` |
| Adjustment out / damaged | `adjustment_out` / `damaged` | Dr `stock_adjustments` / `stock_writeoff` · Cr `inventory` |
| Shift closed **short** | — | Dr `cash_over_short` · Cr `cash_drawer` |
| Shift closed **over** | — | Dr `cash_drawer` · Cr `cash_over_short` |
| Pay-out / petty cash | — | Dr expense account chosen · Cr `cash_drawer` |
| Pay-in (owner adds change) | — | Dr `cash_drawer` · Cr `owner_capital` (or `cash_in_safe`) |
| Drop to safe / bank deposit | — | Dr `cash_in_safe` / `bank` · Cr `cash_drawer` |
| Expense paid (rent, power…) | — | Dr expense · Dr `vat_input` · Cr `cash`/`bank`/`accounts_payable` |
| Manual journal (admin / accountant) | — | any balanced lines, with a reason |

### 10.6 How posting works in code (later)

- `App\Accounting\Poster::post(source, lines)` checks the period is open, each line has one side,
  total debits = total credits (in cents), then inserts `journal_entries` + `journal_lines`.
  Called **inside the business document's transaction**.
- One posting rule class per event (e.g. `PostPosSale`, `PostGrn`) so rules are easy to test.
- Every posting rule gets a Pest test that the entry balances and hits the right accounts.
- A "Accounts check" page compares: inventory account vs stock value from `stock_movements`;
  AR control vs sum of customer balances; AP control vs supplier balances; cash drawer vs open
  shifts. Differences are shown in red.

---

## 11. Accounting reports

All from `journal_lines` (fast with the indexes in §15), per branch or all branches, any date range:

- **Trial balance**, **General ledger** (account transactions with running balance), **Journal / day book**
- **Profit & loss** (with this period vs last), **Balance sheet**, **Cash flow** (direct method, from
  cash and bank accounts)
- **Customer statement**, **AR ageing** (0–30, 31–60, 61–90, 90+), **Supplier statement**, **AP ageing**
- **VAT return report** (output VAT, input VAT, per rate) — when VAT is on
- **Sales**: by product, category, cashier, price level, payment method; gross profit per sale
- **Stock**: valuation (matches the inventory account), slow-moving, units by status / serial lookup
- **Shift report** (Z report) per shift; the Daily Summary per day

Periods: `accounting_periods` (monthly). Admin can **lock** a month after checking it; nothing can
post into a locked month (corrections go into the next open month).

---

## 12. Roles and permissions

Current roles: admin, front_desk, technician. Proposed additions (decision §14):

- `cashier`: POS only — open/close own shift, sell, take payments, print receipts. Returns and
  discounts above a limit need an admin override (PIN or admin login).
- `accountant`: accounts, journals, expenses, all reports; no settings or users; cannot sell.
- Technicians stay as they are: **never** see prices, costs, POS or accounts.

New gates: `use-pos`, `manage-purchasing`, `manage-accounts`, `approve-discount`, `approve-return`.
Cost prices and profit stay admin (+ accountant) only.

---

## 13. Build order (each step ships, is tested, and keeps the app working)

1. **Accounting core**: chart of accounts (seeded), journal tables, `Poster`, periods.
   Post the *existing* flows (advances, repair invoices, payments, voids, parts used, GRNs,
   adjustments). Opening balances at the go-live date (§14). Trial balance + ledger reports.
2. **Products**: `parts` changes, price levels, `stock_units` + labels + "Label existing stock".
3. **Purchasing**: purchase orders, GRN against PO, supplier payments + allocations,
   supplier returns, AP statements/ageing.
4. **Cash drawer shifts**, then the **POS screen** and 80mm receipts.
5. **Customer returns**, credit customers, payment allocations, statements, AR ageing.
6. **Reports**: P&L, balance sheet, cash flow, VAT, sales, stock; accounts check page.

Rough size: this is bigger than phases 1–6 together. Steps 1 and 4 are the riskiest.

---

## 14. Things you need to decide

**From the owner (answers did not come through):**

1. **VAT registered?** If yes: VAT rate (18% now), VAT registration number for the invoice, prices
   on screen **including** or **excluding** VAT, which products are zero-rated / exempt, and
   whether the accountant also needs **SSCL** (2.5%) handled. If no: we still build the columns,
   switched off.
2. **Credit sales?** Who can approve a credit customer and set the limit; what happens when the
   limit is reached (block, or admin override).
3. **Price levels**: the list (assumed Retail / Wholesale / Technician) and who may change the
   level on a sale.
4. **Label size** (assumed 38×25mm) and the label printer model.

**Accounting:**

5. **Go-live date for accounting** and opening balances: cash, bank balances, stock value (we can
   take it from `stock_movements`), money customers owe, advances held, money we owe suppliers,
   owner's capital. We do **not** back-post history before that date. Who gives these numbers
   (the shop's accountant)?
6. **Chart of accounts**: use the proposed chart (database.md §15) or the accountant's own codes?
7. **Cost method**: keep **average cost** for everything (simple, matches today) or use the actual
   unit cost for serial-tracked units (more exact, two methods to maintain)? Recommended: average.
8. **Card payments**: does the bank pay card money daily net of fees (needs `card_clearing` +
   fee entries), or do we treat card as bank directly?
9. **Financial year**: April–March (Sri Lanka tax year) or January–December?
10. Should **repair parts used** post to COGS when used (recommended, simple) or wait in
    "work in progress" until the job is invoiced (more exact, more work)?

**Operations:**

11. **Serial tracking**: only products with `is_serialized` on (recommended), or every product?
    Barcode format for unit labels (`U00012345`)?
12. **Barcode images**: OK to add the small package `picqer/php-barcode-generator` (MIT, pure PHP),
    or write our own Code 128?
13. **Invoice numbers**: POS sales in the same `INV-` series as repairs (recommended — one tax
    invoice series), or a separate `POS-` series?
14. **New roles** `cashier` and `accountant`: yes / no?
15. **Shifts**: one drawer or more? Must every cash payment be inside an open shift (recommended)?
    Keep the Daily Summary "Close day" as a sign-off after all shifts are closed?
16. **Returns policy**: how many days, refund to original method only, store credit allowed?
17. **POS void**: only same shift + admin (recommended)?
18. **Walk-in sales** without a customer: OK? (Today every invoice needs a customer.)
19. **`payments.invoice_id`**: drop it after allocations are live (cleaner) or keep it?
20. **Expenses**: simple expense screen (date, account, amount, paid from, bill photo) — enough, or
    do they want supplier bills for expenses too (bills payable)?

**Changes to earlier decisions** (database.md §14) if this plan is approved: Decision 7 (VAT) may
change; Decision 8 (no supplier ledger) is **replaced**; Decision 10 (counter sales) becomes the
POS; Decision 11 (payment methods) gets account mapping; Daily Summary "Close day" changes (§8).
