# Product Catalog (Mobile)

How a paired mobile/POS device reads the merchant's active product catalog, including the current
device branch's server-calculated Available quantity. Products, categories, favorites and units
are managed in the web portal; the device catalog is read-only.

Mobile/POS callers use `GET /api/v1/device/products`. The existing `GET /api/v1/products` contract
remains for portal and Invoice callers only; do not use it from a paired device.

---

## What the catalog is

Reusable product and service items the merchant maintains in the portal, so a line item does not
have to be typed out each time. Selecting one fills the line's name, description, price, tax and
discount.

For inventory-tracked physical products, the response adds only the Available quantity at the
device's current active physical branch. It never exposes unit cost, aggregate On Hand or Reserved.

---

## Headers

The device catalog requires the ordinary user JWT, the paired device token and the tenant header:

```http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id
```

Both tokens must be valid and the device must be active. The tenant in the user JWT must match
`X-Tenant-ID`.

Do not send `merchantId`, `branchId` or `tenantId` as query parameters. The server takes tenant,
merchant and device identity from verified authentication context, reloads the current device row
on every request, and derives its branch from that row. A branch reassignment therefore takes
effect on the next request without re-pairing. Request values cannot select another merchant or
stock location.

---

## Fetching the catalog

```http
GET /api/v1/device/products?limit=200
```

This endpoint always returns active products. A caller-supplied `status` does not broaden the
result and should be omitted.

| Query | Default | Description |
|---|---|---|
| `type` | `all` | `all` \| `product` \| `service` |
| `search` | — | Case-insensitive partial match on name and description |
| `categoryId` | — | Only products in this category |
| `subCategoryId` | — | Only products in this sub-category |
| `favorite` | — | `true` keeps favorites only; `false` keeps non-favorites |
| `view` | `all` | `all` \| `most_sold` — the POS tile view, see "Tiles" below |
| `inventoryTracking` | `all` | `all` \| `tracked` \| `not_tracked` |
| `sort` | `newest` | `newest` \| `name` \| `price_asc` \| `price_desc` \| `most_sold` |
| `page` | `1` | Page number |
| `limit` | `10` | Page size; values above 200 are reduced to 200 |

Results are ordered by `sort` — newest first (`createdAt` descending) unless you ask otherwise.
`most_sold` orders by units sold, then favorites first, then name.

```json
{
  "success": true,
  "data": [
    {
      "id": "2f1c...",
      "type": "product",
      "name": "Hardware Kit",
      "description": "Standard equipment bundle",
      "price": 480.50,
      "unit": "box",
      "unitLabel": "Box",
      "discountValue": 5,
      "discountType": "percent",
      "taxRate": 8.25,
      "productCode": "KIT-STD",
      "status": "active",
      "isFavorite": true,
      "imageUrl": "https://cdn.example.com/products/tenant-1/6f1c.../2f1c.../8d0b....png",
      "maxQuantity": 10,
      "soldQuantity": 37.5,
      "categoryId": "9b4e7a10-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
      "categoryName": "Hardware",
      "subCategoryId": "c7d8e9f0-1a2b-4c3d-9e8f-7a6b5c4d3e2f",
      "subCategoryName": "Parts",
      "sku": "HW-KIT",
      "trackInventory": true,
      "allowZeroStockSale": false,
      "available": 7.5,
      "eligible": true,
      "ineligibilityReason": null,
      "createdAt": "2026-07-14T09:12:00.000Z",
      "updatedAt": "2026-09-02T16:40:00.000Z"
    }
  ],
  "meta": { "page": 1, "limit": 200, "total": 1 }
}
```

`price`, `discountValue`, `taxRate`, `maxQuantity`, `soldQuantity` and `available` are JSON
**numbers** when present. `taxRate` is a percentage and may be `null`. Category, code, SKU, unit,
image and availability fields may also be `null` as described by their type.

The safe device DTO contains the fields shown above. In particular, it does **not** contain
`unitCost`, `onHand` or `reserved`.

`discountValue` is only a percentage when `discountType` is `percent`; when it is `fixed` the number is a money amount. Carry the type across with the value — copying the number alone turns a \$20 discount into 20%.

### Availability and eligibility

`available` is computed by the server as `max(0, On Hand - Reserved)` for one active physical
branch. The mobile app must render this value as advisory state; it must not calculate Available
from other fields or accept a cashier-supplied stock location.

| Item/device state | `available` | `eligible` | `ineligibilityReason` |
|---|---:|---:|---|
| Tracked physical product, active physical device branch | Branch Available | `true` | `null` |
| Tracked product, no device branch | `null` | `false` | `DEVICE_LOCATION_REQUIRED` |
| Tracked product, assigned branch is inactive, non-physical or outside the merchant | `null` | `false` | `DEVICE_LOCATION_INACTIVE` |
| Untracked product or service | `null` | `true` | `null` |

`eligible` describes whether the device has a usable location for that item. It is not a promise
that a requested quantity can be sold: stock may change after the read, and a tracked item can be
location-eligible with `available: 0`.

### Product image

`imageUrl` is a public URL (no auth headers needed) or `null` when the product has none — show a
placeholder then. The file is a JPEG, PNG, WebP **or SVG**; make sure the image loader you use
renders SVG (on iOS and Android that usually needs an SVG-capable loader or decoder), and draw it
as an image, never in a web view that would run it as a document. A changed image always gets a new
URL, so cache images by URL for as long as you like; a URL that stops loading means the image was
replaced or removed — re-fetch the product.

### Caching and search

`limit` is capped at **200** (larger values are silently reduced, not rejected). Fetch the first page with `limit=200` when the POS catalog opens and filter on the device — a catalog usually fits in one page.

If `meta.total` is greater than what you received, the rest exists and on-device filtering would hide it. Fall back to asking the server as the user types:

```http
GET /api/v1/device/products?search=hardware&limit=200
```

`search` matches name and description, case-insensitively, on partial words.

Re-fetch when the POS catalog opens rather than caching across sessions; the merchant may have changed prices, favorites or branch assignment in the portal in between.

---

## Tiles: favorites and most sold

The POS shows two tile views over the catalog. Search still covers the whole catalog — the tiles
are a shortcut, not a filter on what can be sold.

**Favorites** — products the merchant marked as favorite in the portal (`isFavorite: true`):

```http
GET /api/v1/device/products?favorite=true&sort=name&limit=200
```

**Most sold** — the top products by units sold:

```http
GET /api/v1/device/products?view=most_sold
```

- Returns at most **10** active products (`limit` may lower it, never raise it); `page` is ignored and `meta.total` is the number returned.
- Ordered by `soldQuantity` descending, then favorites, then name. Before the merchant's first sale every `soldQuantity` is 0, so the view shows the favorites alphabetically, then the rest A–Z.
- Re-fetch after each completed sale to re-order the tiles.

### What `soldQuantity` counts

Units sold of that product, summed across:

- **POS sales** — product lines of a sale sent with `lineItems` (see [Device Integration](./device-integration.md), section "Itemised sales (POS cart)"), counted once the payment is approved (card), settled (cash) or approved by the bank (ACH);
- **Invoices** — lines picked from the catalog (carrying `productId`), counted when the invoice becomes fully paid.

The unit is not converted: 2.5 gallons and 3 gallons sum to 5.5. A void, a full refund or an ACH
return takes the sale back out; a **partial** refund does not change the count (a refund is an
amount, and nothing says which items it covered). Misc lines are never counted. Invoices created
before this feature have no `productId` on their lines and are not counted.

---

## Using a product in the POS cart

When the cashier taps a tile or search result, keep `productId` and the cashier-entered quantity as
the inventory identity. The existing payment payload still requires receipt/amount fields such as
`name` and `unitPrice`, and validates its subtotal separately; those fields do not choose the
inventory product, policy, availability, or location. The server-owned catalog and live device row
remain authoritative for those inventory decisions. Never send cashier-selected `branchId` or
`available` values.

For display, the app can build a local cart line from the returned item:

| Cart line field | From |
|---|---|
| `type` | `product` |
| `productId` | `product.id` |
| `name` | `product.name` |
| `unit` | `product.unit` (may be null) |
| `unitPrice` | `product.price` |
| `quantity` | What the cashier enters; up to 3 decimal places (`2.5`) |

Show the unit beside the quantity with `product.unitLabel` ("2.5 Gallon").

**Per-order limit.** When `product.maxQuantity` is not null, the cart may hold at most that many
units of the product **in total across its lines** — adding the same product twice counts both.
Block the add or quantity change that would go over, and show the limit ("Max 10 per order").
`null` means unlimited. Send the cart as `lineItems` with the payment — the current public payment
payload is documented in [Device Integration](./device-integration.md), section "Itemised sales
(POS cart)". A
"Miscellaneous" line is not a product: send it as `type: "misc"` with a name and amount and no
`productId`.

The server revalidates every product line against the current catalog and current device branch.
For card/cash sale, token sale, AUTH, and ACH submission, a rejected or unavailable product-cart
validation fails closed before the processor is called. `allowZeroStockSale: false` rejects a
quantity above current Available; `allowZeroStockSale: true` permits the payment and bounds the
later deduction at current Available.

External SDK payment recording is different because the provider may already have collected the
money. A failed/unavailable validation does not reverse that payment record. When POS inventory
settlement is enabled and an external SALE later reaches finality, the server queues inventory work
without guessing a deduction. Stock or location conflicts accepted by Invoice become Pending
Review; a terminal Transaction-to-Invoice delivery failure remains operational recovery work and
never reverses the payment.

Only show the cart when the device parameter `posLite.enabled` (POS Lite) is `true`
(`GET /api/v1/device/parameters`). The response also carries the same value as
`productCart.enabled` for older app builds; `productCart` is a deprecated alias, not a separate
inventory or settlement switch.

---

## Portal/Invoice catalog (unchanged)

The following Invoice behavior continues to use `GET /api/v1/products`; it is not the paired-device
catalog contract above.

## Filling an invoice line item

When the user picks a product, copy these values into the line **at that moment**:

| Line item field | From |
|---|---|
| name | `product.name` |
| description | `product.description` (null → empty) |
| unitPrice | `product.price` |
| unit | `product.unit`, when it is not null (otherwise keep the line's own unit) |
| taxRate | `product.taxRate` (null → 0) |
| discount | `product.discountValue` |
| discountType | `product.discountType` (`percent` or `fixed`) |

Overwrite every one of them, an absent description included — picking product B after product A must not leave A's description on a line that now says B.

Keep `product.maxQuantity` with the line (it is not sent): the same per-order limit as the POS cart
applies to an invoice, summed across the invoice's lines with that `productId`. Block quantities
over it; the server refuses them with `400 QUANTITY_LIMIT_EXCEEDED`. When editing an invoice saved
before the limit was set or lowered, the server only refuses an edit that adds more of the product,
so do not block the existing quantity — only an increase.

Then post the invoice as usual (`POST /api/v1/invoices`) — see [Invoices](../invoicing/invoices.md). Line items go in `lineItems`, and the customer is given with flat `customerName` / `customerEmail` / `customerPhone` fields; `merchantId` is required.

```json
{
  "merchantId": "6f1c2a9e-3b7d-4e21-9a55-0c8f1d2b7e40",
  "customerName": "John Doe",
  "customerEmail": "john@example.com",
  "lineItems": [
    {
      "productId": "2f1c...",
      "name": "Hardware Kit",
      "description": "Standard equipment bundle",
      "quantity": 1,
      "unitPrice": 480.50,
      "taxRate": 8.25,
      "discount": 5,
      "discountType": "percent",
      "amount": 480.50
    }
  ],
  "discountAmount": 24.03
}
```

`productId` is optional. The invoice does not look the product up: it stores its own copy of every line. Send it whenever the line was picked from the catalog — it is how a paid invoice counts toward the product's `soldQuantity`. Drop it if the user then replaces the name with free text. The server recomputes each line's `taxAmount` and `amount` from `quantity`, `unitPrice`, discount and `taxRate`, so the `amount` you send is not trusted.

A line discount lowers that line's taxable base, but the invoice total only subtracts the invoice-level `discountAmount`. Send `discountAmount` as the sum of the line discounts (here 5% of 480.50 = 24.03) plus any invoice-level discount, or the copied product discount will not reduce what the customer pays.

### Two rules that follow from that

**The line is a snapshot.** A price changed in the portal afterwards does not change an invoice that already exists. A service added at \$100 stays at \$100 on that invoice even if the catalog later says \$150.

**Editing the line does not edit the catalog.** Every copied field stays editable on the line wherever the invoice flow allows editing, and changing it there has no effect on the product. Do not write edits back.

### Custom (free-text) invoice lines

An invoice line does not have to come from the catalog. When the user types an item that is not
in it — and does not create a product — send the line **without `productId`**:

```json
{
  "name": "Site visit",
  "description": "Call-out on Saturday",
  "quantity": 1,
  "unit": "hour",
  "unitPrice": 85.00,
  "taxRate": 0
}
```

- This is a normal, supported line, not a fallback: the invoice is created exactly as with a catalog line.
- `name` and `unitPrice` are entered by the user; `quantity` is greater than 0 with up to 3 decimal places; `unit` is optional and must be one of the unit codes listed in [Products](../invoicing/products.md) (common spellings such as `hours` are accepted).
- It never creates a catalog product, and it does **not** count toward any product's `soldQuantity`, even if its name happens to match a product. To count, the user has to pick the product from the catalog so the line carries its `productId`.
- If the user picks a product and then retypes the name, treat the line as custom again and drop the `productId`.

---

## Errors

| Status | Code | Meaning |
|---|---|---|
| 400 | `TENANT_REQUIRED` | `X-Tenant-ID` is missing |
| 401 | `UNAUTHORIZED` | User JWT or device token is missing, invalid, expired or revoked |
| 403 | `DEVICE_SUSPENDED` | The paired device is suspended |
| 403 | `DEVICE_CONTEXT_MISMATCH` | The user and device tokens do not identify the same tenant/merchant context |
| 403 | `DEVICE_NOT_ACTIVE` | The device is not active |
| 404 | `DEVICE_NOT_FOUND` | The persisted device no longer exists |
| 400/422 | `CATALOG_REQUEST_REJECTED` | A forwarded filter is malformed or unsupported; the response does not expose Invoice-service internals |
| 502 | `CATALOG_SERVICE_ERROR` | The catalog service returned another failure; retry without exposing internal details |
| 503 | `CATALOG_SERVICE_UNAVAILABLE` | The catalog service timed out or could not be reached; retry later |

Product-cart preflight failures use stable violation codes in `error.details.violations`:

| Status | Code | Meaning |
|---:|---|---|
| 404 | `PRODUCT_NOT_FOUND` | Product is absent from this merchant's live catalog |
| 422 | `PRODUCT_NOT_ACTIVE` | Product is inactive |
| 400 | `QUANTITY_LIMIT_EXCEEDED` | Aggregated product quantity exceeds `maxQuantity` |
| 422 | `DEVICE_LOCATION_REQUIRED` | A tracked product has no current device branch |
| 422 | `DEVICE_LOCATION_INACTIVE` | The assigned branch is not a usable active physical branch |
| 409 | `INSUFFICIENT_STOCK` | `allowZeroStockSale` is false and requested quantity exceeds Available |
| 503 | `PRODUCT_CART_VALIDATION_UNAVAILABLE` | Validation could not be completed; pre-payment paths fail closed |

After a completed sale, refresh `GET /api/v1/device/products`; the catalog response is display
state and can be stale as soon as another sale or reservation changes the branch balance.

A portal Invoice catalog that fails to load does not block invoicing: let the user type the item
and send it as a custom Invoice line (see above). Do not apply that fallback to a POS product line:
pre-payment product-cart validation is server-authoritative and fails closed.
