# Customers Scan — Requirements

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

## Overview

`customers-scan` is the JSON customer-lookup endpoint (`GET /customers/scan`, `CustomerController::scan`) that the POS terminal's `select2` control calls as the cashier types, to find an existing customer by phone number or name and attach them to the cart. It is a read-only, autocomplete-style search returning at most ten matches; it does not create, modify, price, or otherwise mutate any data. 🟢 (`routes/web.php:53`, `CustomerController.php:283-294`)

## Responsibilities

- Accept a free-text query token `q` and return up to 10 customers whose `phone` OR `fullname` contains that token (substring match). 🟢 (`:285-287`)
- Serialize each match as a full `Customer` JSON record (including the appended `type`/`type_label` attributes) so the POS client can bind name, phone, and pricing tier without a second round-trip. 🟢 (`:289-291`, `Customer.php:15,25,49-55`)
- Exclude soft-deleted customers from results via the model's default SoftDeletes scope. 🟢 (`Customer.php:9-12`) — see the ungrouped-`orWhere` caveat under Business Rules.
- Always answer with a `200` JSON array (possibly empty), never a partial page or HTML. 🟢 (`:289-291`)

## Business Rules

- **Substring search over two columns.** The match is `phone LIKE %q%` OR `fullname LIKE %q%` — both leading-and-trailing wildcard, so any customer containing `q` anywhere in either field qualifies. There is no exact-match fast path (unlike `pos-scan`, which tries an exact product code first). 🟢 (`:287`)
- **Hard cap of 10 results.** `take(10)` limits the collection regardless of how many rows match; there is no pagination and no total count. 🟢 (`:287`)
- **The response is always a JSON array.** `get()` returns an Eloquent `Collection`, which is truthy even when empty, so the `if($result)` guard is always true and the endpoint always returns `response()->json($result)` (`[]` when nothing matches). The `response()->json(null, 204)` no-content branch is therefore **dead code** and never executes. 🟢 Confirmed harmless by the team — left as-is, not fixed. (`:289-293`, `_reversa_sdd/flowcharts/customers.md:71`)
- **Soft-delete exclusion applies to both branches — verified, not a leak.** The two search conditions are chained without an explicit grouping closure (`where(...)->orWhere(...)`), but Laravel 5.6's Eloquent `Builder::callScope()` (`vendor/laravel/framework/.../Eloquent/Builder.php:943-962`) automatically isolates the wheres added by a **global scope** (SoftDeletes) into their own nested group, separate from hand-written wheres. The compiled SQL is `WHERE (phone LIKE %q% OR fullname LIKE %q%) AND (deleted_at IS NULL)` — a soft-deleted customer cannot leak via either branch. This differs from the `customers-crud`/`products-catalog`/`debts` ungrouped-OR bugs, which combined **hand-written** filters in the same method (not protected by this scope-nesting mechanism). 🟢 (`:287`, verified against `vendor/laravel/framework`)
- **`q` is used as an SQL `LIKE` pattern.** The value is bound as a parameter (no SQL injection), but user-typed `%` and `_` act as `LIKE` wildcards rather than literals. 🟡 (`:287`)
- **`phone` is the customer's business identity.** The POS client keys the selected customer by `phone`, not `id` (it remaps the `select2` value to `phone`), consistent with `Customer::scopeCode` matching on `phone`. 🟢 (`Customer.php:58-61`; cross-ref `pos-terminal`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | Return up to 10 customers matching `q` on `phone` OR `fullname` (substring) | Must | A query matching ≥11 customers returns exactly 10 records. 🟢 |
| RF-02 | Serialize each result as a full `Customer` JSON object including `type` and `type_label` | Must | Response objects expose `id, fullname, phone, points, debt_total, type, type_label`. 🟢 |
| RF-03 | Always respond `200` with a JSON array; empty search → `[]` | Must | A query matching nothing returns HTTP `200` with body `[]`, never `204`. 🟢 |
| RF-04 | Exclude soft-deleted customers from results | Must | A soft-deleted customer does not appear via either the `phone` or `fullname` path. 🟢 (verified: Eloquent scope-nesting protects both branches) |
| RF-05 | Require an authenticated admin session | Must | Anonymous request → `302` to `auth/login`. 🟢 (`routes/web.php:23-28`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | Admin authentication required (admin route group middleware `['web','admin']`) | `routes/web.php:23-28,53` | 🟢 |
| Performance | Result set bounded to 10 rows per call | `CustomerController.php:287` | 🟢 |
| Performance | Leading-wildcard `LIKE %q%` on `phone`/`fullname` cannot use a B-tree index → full scan that degrades as the customer table grows | `CustomerController.php:287` | 🟡 |
| Observability | None — the action emits no log, metric, or trace despite being called on every keystroke | `CustomerController.php:283-294` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Given an authenticated cashier at the POS
When he types a fragment of a customer's phone or name in GET /customers/scan?q=<fragment>
Then he receives HTTP 200 with a JSON array of up to 10 customers whose phone OR fullname contains the fragment

Given a search that matches no customer
When GET /customers/scan?q=<nonexistent> is called
Then it receives HTTP 200 with body [] (never 204)

Given a request with no authenticated admin session
When GET /customers/scan is called
Then it receives HTTP 302 redirecting to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| Substring search on phone/fullname returning ≤10 (RF-01) | Must | Core lookup called by the POS `select2` on every keystroke |
| Full-`Customer` JSON payload incl. `type` (RF-02) | Must | POS binds pricing tier and identity from this payload with no extra call |
| Always `200` array contract (RF-03) | Must | The client branches on an array; a `204`/`null` would break it |
| Exclude soft-deleted customers (RF-04) | Must | Already correctly scoped on both branches via Laravel's scope-nesting; no change needed |

> Priority inferred from call frequency (per-keystroke) and position in the POS add-customer flow.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/CustomerController.php:283-294` | `CustomerController::scan` | 🟢 |
| `routes/web.php:53` | `GET /customers/scan` route (declared before `resource('/customers')`) | 🟢 |
| `app/Models/Customer.php:9-12,15,25,49-55,58-61` | `Customer` (SoftDeletes, `$fillable`, `$appends`, `type`/`type_label` accessors, `scopeCode`) | 🟢 |
