# Products

Reusable product and service items, and the categories that group them. Physical products can opt
into branch-scoped inventory tracking; services and untracked products remain catalog-only items.

---

## Overview

- 🧾 **One catalog for products and services** — `type` distinguishes them
- 🗂️ **Categories and sub-categories** — at most one of each per item
- 🔁 **Reused on invoices** — selecting an item fills the line's name, description, price, tax and discount ($ or %)
- 🖼️ **One image per item** — JPEG, PNG, WebP or SVG up to 2 MB, served from a public URL
- 🔢 **Optional per-order limit** — `maxQuantity` caps how much of an item one invoice or POS cart may hold
- 📦 **Optional inventory tracking** — tracked physical products use server-owned branch balances
- 📥 **Excel import / export** — `.xlsx`, validated all-or-nothing
- 🔒 **Per merchant** — a catalog is never shared between merchants

All endpoints require `Authorization: Bearer <token>` and `X-Tenant-ID`, plus a user permission:

| Permission | Endpoints |
|---|---|
| `invoices.view` | List, get, export, import template, list categories |
| `invoices.create` | Create, import (an import that updates existing products also needs `invoices.manage`) |
| `invoices.manage` | Update, status, image, delete, bulk delete, every category / sub-category write |

A missing permission returns `403 FORBIDDEN`. API keys carry no user, so `x-api-key` calls to these endpoints are refused with `403` as well.

### Merchant scope

The merchant is taken from the access token. A merchant-scoped session may not read or write
another merchant's catalog, and passing a different `merchantId` returns `403 FORBIDDEN` rather
than being ignored.

Platform-level tokens carry no merchant, so they must name one explicitly with the `merchantId`
query parameter (or, on a write, in the body).

### Response shape

Every endpoint answers with the platform envelope:

```json
{ "success": true, "data": { }, "meta": { "page": 1, "limit": 10, "total": 42 } }
```

```json
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Price must be 0 or greater" } }
```

---

## The product object

```json
{
  "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": false,
  "imageUrl": "https://cdn.example.com/products/tenant-1/6f1c.../2f1c.../8d0b....png",
  "maxQuantity": 10,
  "soldQuantity": 12,
  "categoryId": "9b0e...",
  "categoryName": "Hardware",
  "subCategoryId": "41aa...",
  "subCategoryName": "Parts",
  "sku": "HW-KIT",
  "trackInventory": true,
  "lowQuantityThreshold": 2,
  "unitCost": 275.00,
  "allowZeroStockSale": false,
  "onHand": 12,
  "reserved": 4,
  "available": 8,
  "createdAt": "2026-09-23T02:55:30.000Z",
  "updatedAt": "2026-09-23T02:55:30.000Z"
}
```

| Field | Type | Notes |
|---|---|---|
| `type` | `product` \| `service` | Defaults to `service` on create |
| `name` | string | Required, trimmed, max 150 characters |
| `description` | string \| null | Max 1000 characters |
| `price` | number | Required, 0 or greater, at most 2 decimal places |
| `unit` | string \| null | What the price is per. One of `piece`, `each`, `box`, `hour`, `day`, `kg`, `lb`, `ton`, `liter`, `gallon`, `metre`, `foot`, `mile`, `mil`, `other`. Optional; aliases such as `gallons` or `meter` are stored as the code. Omitted on `PUT` = unchanged, `null` clears it. Copied onto an invoice line when the product is picked |
| `unitLabel` | string \| null | Read-only display label for `unit` (`Gallon`) |
| `discountValue` | number | Defaults to 0. Read with `discountType` |
| `discountType` | `percent` \| `fixed` | Defaults to `percent`. A percentage is 0–100; a fixed amount is money and has no upper bound |
| `taxRate` | number \| null | 0–100. A **percentage**, not a formatted string |
| `status` | `active` \| `inactive` | Defaults to `active` |
| `isFavorite` | boolean | Defaults to `false`. Pins the product as a POS tile. Not synced to Aux365 |
| `imageUrl` | string \| null | Read-only. Public URL of the product image, or `null`. Set it with a multipart create/update or the image endpoints below. Each upload gets a new URL, so it can be cached indefinitely |
| `maxQuantity` | number \| null | Most units of the product one invoice or POS cart may hold, summed across its lines. `null` (default) = unlimited. Greater than 0, at most 1,000,000, up to 3 decimal places (`2.5` gallons). Omitted on `PUT` = unchanged, `null` clears it. Not stock: it never counts across orders. See "Per-order quantity limit" below |
| `soldQuantity` | number | Read-only. Units sold, see "Most sold" below |
| `categoryId` / `subCategoryId` | uuid \| null | A sub-category cannot be set without its category |
| `sku` | string \| null | Optional merchant SKU, unique per merchant ignoring case and surrounding whitespace |
| `trackInventory` | boolean | Physical products only. Defaults to `false`; a service cannot enable tracking |
| `lowQuantityThreshold` | number \| null | Shared per-product low-stock threshold, 0 or greater, up to 3 decimal places |
| `unitCost` | number \| null | Optional merchant cost, 0 or greater, up to 2 decimal places; never exposed by the device catalog |
| `allowZeroStockSale` | boolean | Defaults to `false`. When true, a final sale may deduct only current Available and record the rest as not deducted |
| `onHand` / `reserved` / `available` | number \| null | Read-only aggregate across persisted location balances for tracked physical products; `null` for services and untracked products. `available = max(0, onHand - reserved)`. Use the inventory balances endpoint for active-branch operational views |

`price`, `discountValue`, `taxRate`, `maxQuantity`, `soldQuantity`, `lowQuantityThreshold`,
`unitCost`, `onHand`, `reserved`, and `available` are JSON numbers when present, not strings.

`imageUrl` and `maxQuantity` are local to AuxCORE and are not sent to Aux365.

A fixed discount is deliberately not capped at the price: an invoice line carries a quantity, and
the line is where the charge is finally worked out.

---

## List products

```http
GET /api/v1/products
```

| Query | Notes |
|---|---|
| `search` | Case-insensitive partial match on name **and** description |
| `type` | `all` (default), `product`, `service` |
| `status` | `all` (default), `active`, `inactive` |
| `categoryId` | Restrict to one category; must be a valid id |
| `subCategoryId` | Restrict to one sub-category; must be a valid id |
| `favorite` | `true` keeps favorites only, `false` the rest |
| `view` | `all` (default) or `most_sold` — the top 10 active products, see "Most sold" below |
| `inventoryTracking` | `all` (default), `tracked`, `not_tracked` |
| `sort` | `newest` (default), `name`, `price_asc`, `price_desc`, `most_sold` |
| `page` | Defaults to 1 |
| `limit` | Defaults to 10, capped at **200** |

```bash
curl "https://uat.auxcore.net/api/v1/products?status=active&limit=200" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

```json
{
  "success": true,
  "data": [ { "id": "2f1c...", "name": "Hardware Kit", "price": 480.50 } ],
  "meta": { "page": 1, "limit": 200, "total": 1 }
}
```

An unknown `type`, `status`, `view`, `inventoryTracking`, or `sort` value is rejected with
`400 VALIDATION_ERROR`; use `all` to mean "no filter".

### Most sold

`soldQuantity` is a reporting ledger, separate from physical inventory balances. Its existing
reversal behavior does not restock On Hand:

- **POS sales** — product lines of a transaction sent with `lineItems`, counted by the reporting lifecycle;
- **Invoices** — lines carrying a `productId`, counted when the invoice becomes fully paid. The portal's invoice builder sets `productId` when a line is picked from the catalog.

Units are summed as-is (2.5 + 3 gallons = 5.5). A partial refund leaves the count unchanged; misc
lines and lines without a `productId` are never counted.

For the paired-device inventory contract, use `GET /api/v1/device/products`; it resolves the live
device branch server-side and returns branch Available without exposing aggregate On Hand,
Reserved, or unit cost. See [Mobile Product Catalog](../mobile/product-catalog.md) and
"Inventory".

`view=most_sold` returns at most 10 active products ordered by `soldQuantity`, then favorites, then
name — so before the first sale it is the favorites alphabetically. `page` is ignored and
`meta.total` is the number returned.

---

## Get one product

```http
GET /api/v1/products/:id
```

Returns `404 NOT_FOUND` for an id outside the caller's merchant, so a missing product and another
merchant's product are indistinguishable.

---

## Create a product

```http
POST /api/v1/products
```

```json
{
  "type": "product",
  "name": "Hardware Kit",
  "price": 480.50,
  "discountValue": 5,
  "discountType": "percent",
  "taxRate": 8.25,
  "description": "Standard equipment bundle",
  "status": "active",
  "isFavorite": true,
  "categoryId": "9b0e...",
  "subCategoryId": "41aa..."
}
```

Responds `201` with the created product.

### With an image

To save the product and its image in one request, send `multipart/form-data` instead of JSON:

| Part | Content |
|---|---|
| `data` | The same JSON body as above, as a string |
| `image` | The image file — JPEG, PNG, WebP or SVG, max 2 MB |

```bash
curl -X POST https://uat.auxcore.net/api/v1/products \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -F 'data={"type":"product","name":"Hardware Kit","price":480.50,"maxQuantity":10}' \
  -F "image=@kit.png"
```

The product is validated before the image is stored, so a rejected request leaves nothing behind.
The image type is read from the file's bytes, not from its name or the `Content-Type` the client
sends. An SVG is refused if it contains scripts, event handlers, `javascript:` links,
`<foreignObject>` or references to external files. Render the image with an `<img>` tag (or
the platform's image view), never inline it into a page.

| Error | When |
|---|---|
| `400 VALIDATION_ERROR` | A field breaks one of the rules in the table above |
| `400 VALIDATION_ERROR` | The image is empty, over 2 MB, not a JPEG/PNG/WebP/SVG, or an unsafe SVG |
| `400 VALIDATION_ERROR` | The multipart `data` part is not a JSON object |
| `400 UPLOAD_ERROR` | The upload itself was refused (for example `File too large (max 2MB)`) |
| `503 IMAGE_STORAGE_UNAVAILABLE` | Image storage is not configured or did not accept the file; nothing was saved, retry |
| `400 VALIDATION_ERROR` | `subCategoryId` given without `categoryId` |
| `404 NOT_FOUND` | The category or sub-category does not exist for this merchant |
| `404 NOT_FOUND` | The sub-category exists but belongs to a different category |

---

## Update a product

```http
PUT /api/v1/products/:id
```

Takes the same body as create and replaces every field, so send the whole object. Omitting
`categoryId` or `taxRate` **clears** it rather than leaving it alone. `unit`, `isFavorite` and
`maxQuantity` are the exceptions: omitted, they are left as they are.

The image is never touched by a JSON update. To change it, either:

- send the update as multipart (`data` + `image`, as for create) — the new image replaces the old one; or
- add `"removeImage": true` to the body to clear it.

The replaced image file is deleted from storage once the product points at the new one.

Updating a product never changes an invoice that already includes it: invoices keep their own copy
of each line.

---

## Product image

```http
PUT    /api/v1/products/:id/image
DELETE /api/v1/products/:id/image
```

Change or remove only the image, without resending the product. `PUT` takes `multipart/form-data`
with the file in the `image` field (same rules and errors as a create with an image); `DELETE`
takes no body and succeeds on a product that has no image. Both return the updated product and
require `invoices.manage`.

```bash
curl -X PUT https://uat.auxcore.net/api/v1/products/2f1c.../image \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -F "image=@kit.webp"
```

---

## Per-order quantity limit

A product with a `maxQuantity` may appear in one order — an invoice, or a POS cart sent with a
payment — at most that many units, **summed across all of the order's lines with its
`productId`**. Two lines of 3 for a product limited to 5 are refused. Lines without a `productId`
(custom invoice lines, POS `misc` lines) never count.

Checked on:

| Request | Behaviour |
|---|---|
| `POST /api/v1/invoices` | Refused when any product is over its limit |
| `PUT /api/v1/invoices/:id`, `POST /api/v1/invoices/:id/edit` | Refused only when a product is over its limit **and** the edit raises its total. An invoice written before the limit was set or lowered stays editable as long as it does not add more of that product |
| POS `sale` / `auth` / `token-sale` / `ach-sale` with `lineItems` | Refused before the card or account is charged — see [Transactions](../core/transactions.md) |

`external-payment` also validates the product cart, but money may already have been collected by
the device SDK. A limit or other inventory rejection is therefore persisted with the financial
record instead of rejecting or reversing the payment. When POS inventory settlement is enabled and
the payment reaches finality, the server queues inventory work. Stock or location conflicts accepted
by Invoice become Pending Review; a terminal Transaction delivery failure remains operational
recovery work. Limits are not checked on invoices synchronised from Aux365 or generated by a
recurring schedule — those record an order that exists, they do not create one. A product that is
deleted after being added to an order has no limit.

```json
{
  "success": false,
  "error": {
    "code": "QUANTITY_LIMIT_EXCEEDED",
    "message": "\"Diesel\" is limited to 50 per order; this order has 60.",
    "details": {
      "violations": [
        { "productId": "2f1c...", "name": "Diesel", "maxQuantity": 50, "requested": 60 }
      ]
    }
  }
}
```

`400`. `details.violations` lists **every** product over its limit, so a client can mark each
offending line. Enforce the limit in the client too (block the add or the quantity change), and
treat this error as the fallback.

---

## Activate / deactivate

```http
PATCH /api/v1/products/:id/status
```

```json
{ "status": "inactive" }
```

Inactive items stay in the catalog and in historical invoices, but are not offered when building a
new invoice line.

---

## Favorite / unfavorite

```http
PATCH /api/v1/products/:id/favorite
```

```json
{ "isFavorite": true }
```

Returns the updated product. Requires `invoices.manage`. `isFavorite` must be a boolean (`"true"`
and `"false"` are also accepted); anything else is `400 VALIDATION_ERROR`. Favorites are local to
AuxCORE and are not sent to Aux365.

---

## Delete a product

```http
DELETE /api/v1/products/:id
```

A soft delete, answering `{ "success": true, "data": { "id": "2f1c..." } }`. Invoices that already include the product are untouched.

### Bulk delete

```http
DELETE /api/v1/products/bulk
```

```json
{ "ids": ["2f1c...", "77ab..."] }
```

```json
{
  "success": true,
  "data": { "deleted": 1, "notFound": ["77ab..."] }
}
```

Ids outside the caller's merchant are reported in `notFound` rather than deleted. At most 200 ids
per call. Declare this route before `/:id` in any client-side router too — `bulk` is not an id.

---

## Bulk edit

Set one or more fields on many products at once — the ticked rows, or every product matching the
list filters. The edit runs as a background job so a large selection can report progress. Starting and cancelling need `invoices.manage`; reading a job needs `invoices.view`.

### Start a bulk edit

```http
POST /api/v1/products/bulk-jobs
```

```json
{
  "selection": { "mode": "ids", "ids": ["2f1c...", "77ab..."] },
  "changes": {
    "status": "inactive",
    "price": { "mode": "adjust_percent", "value": -10 },
    "taxRate": null
  }
}
```

`selection` is either `{ "mode": "ids", "ids": [...] }` or `{ "mode": "filter", "filters": { ... } }`, where `filters` takes the same keys as the list: `search`, `type`, `status`, `categoryId`, `subCategoryId`, `favorite`, `view`, `sort`. The filter is resolved to a list of products when the job is created; products that match it later are not touched. At most 10,000 products per job.

A key in `changes` is a field to set on every selected product; a key that is absent leaves each product's own value alone.

| Key | Value |
|---|---|
| `type` | `product` or `service` |
| `status` | `active` or `inactive` |
| `price` | `{ "mode": "set", "value": 12.5 }`, or `{ "mode": "adjust_percent", "value": -10 }` to move each product's own price by a percentage (-100 to 1000) |
| `discount` | `{ "value": 10, "type": "percent" }` — value and type together |
| `taxRate` | 0-100, or `null` to clear |
| `unit` | a unit code, or `null` to clear |
| `category` | `{ "categoryId": "...", "subCategoryId": "..." }` (sub-category optional), or `null` to remove the category and sub-category |
| `maxQuantity` | a limit, or `null` for unlimited |
| `isFavorite` | `true` or `false` |

A blank value is refused: only `null` clears a field. `name`, `description`, `productCode` and the image cannot be changed in bulk. A percentage price change is computed in whole cents and rounded half-up.

The answer is `202` with the job. Only one bulk edit per merchant can be queued or running; a second one answers `409 CONFLICT`.

To check an edit before starting it, send the same body to `POST /api/v1/products/bulk-jobs/preview` (`invoices.manage`). It validates exactly like the start, queues nothing, and answers `{ "total": 40, "found": 40, "type": { "to": "service", "changing": 12, "alreadySet": 28 } }`: `found` counts the live items in the selection, and `type` (null unless `changes.type` is set) says how many of them would switch type.

### Get a bulk edit

```http
GET /api/v1/products/bulk-jobs/:jobId
```

```json
{
  "success": true,
  "data": {
    "id": "9b2d...",
    "status": "running",
    "total": 1532,
    "processed": 600,
    "updated": 588,
    "unchanged": 12,
    "skipped": 0,
    "changes": { "status": "inactive" },
    "error": null,
    "cancelRequested": false,
    "createdAt": "2026-10-22T07:01:12.000Z",
    "startedAt": "2026-10-22T07:01:12.000Z",
    "finishedAt": null
  }
}
```

`status` is `queued`, `running`, `completed`, `cancelled` or `failed`. `unchanged` counts products that already had the new values (they are not written), and `skipped` counts products deleted before their turn.

The job works through the products in batches of 200. Each batch is saved completely or not at all, but the job as a whole is not one transaction: **cancelling, or a failure, keeps the batches already applied**, and the counts say how far it got.

### Active bulk edit

```http
GET /api/v1/products/bulk-jobs/active
```

The merchant's queued or running job in the same shape, or `data: null` — what a page opened mid-job reads to show the progress again.

### Cancel a bulk edit

```http
POST /api/v1/products/bulk-jobs/:jobId/cancel
```

A queued job is cancelled at once; a running one stops before its next batch and ends as `cancelled`. Answers with the job. Cancelling a finished job changes nothing.

Every changed product is announced to Aux365 as `product.updated`, except a change to `isFavorite` alone, which stays local.

---

## Categories

```http
GET    /api/v1/product-categories
POST   /api/v1/product-categories
PUT    /api/v1/product-categories/:id
DELETE /api/v1/product-categories/:id

POST   /api/v1/product-categories/:id/sub-categories
PUT    /api/v1/product-categories/:id/sub-categories/:subId
DELETE /api/v1/product-categories/:id/sub-categories/:subId
```

The list returns each category with its sub-categories and usage counts:

```json
{
  "success": true,
  "data": [
    {
      "id": "9b0e...",
      "name": "Hardware",
      "productCount": 4,
      "createdAt": "2026-09-23T02:50:00.000Z",
      "subCategories": [
        { "id": "41aa...", "categoryId": "9b0e...", "name": "Parts", "productCount": 2,
          "createdAt": "2026-09-23T02:51:00.000Z" }
      ]
    }
  ],
  "meta": { "page": 1, "limit": 10, "total": 1 }
}
```

The list takes `search`, `page` and `limit` (default 10, capped at **100**).

Create and update both take `{ "name": "Hardware" }`. Creating a category or sub-category responds `201`; a delete responds `{ "success": true, "data": { "id": "..." } }`.

Sub-categories are addressed **through their parent category**, so the parentage is checked by the
route itself.

### Rules

- A category name is unique per merchant, ignoring case.
- A sub-category name is unique within its category, ignoring case — the same name may exist under
  different categories.
- Names are trimmed and limited to 100 characters; whitespace-only is rejected.

### Errors

| Error | When |
|---|---|
| `409 CONFLICT` | The name already exists (the message quotes the existing spelling) |
| `409 CONFLICT` | Deleting while products still reference it — the message says how many |
| `404 NOT_FOUND` | Unknown id, or a sub-category id under the wrong category |

Deleting a category is refused until the products using it are reassigned or cleared. Once allowed,
it also removes that category's sub-categories.

---

## Export

```http
GET /api/v1/products/export
```

Returns the products as a file. `GET` takes the options as query parameters; `POST /api/v1/products/export` takes the same options as a JSON body
— use `POST` for `ids`, since a few hundred ids do not fit in a URL. Both take the list's `search`,
`type`, `status`, `categoryId`, `subCategoryId`, `favorite`, `view` and `sort`, so the file matches
what the caller is filtered to; with none of them it exports the whole catalog. `view=most_sold`
exports the same top 10 the list shows.

| Option | Notes |
|---|---|
| `ids` | Export exactly these products, at most **500** (array, or comma-separated in a query string). Combined with any filters given |
| `preset` | `update` (default) — the import columns, so the file imports straight back as an update. `report` — adds the read-only columns |
| `columns` | Column keys to export, in place of `preset`. `productId`, `name` and `price` are always added; the file keeps the order below |
| `format` | `xlsx` (default) or `csv` |

| Key | Column | |
|---|---|---|
| `productId` | Product ID | always |
| `productCode` | Product Code | |
| `name` | Name | always |
| `type` | Type | |
| `price` | Price | always |
| `discount` | Discount | |
| `discountType` | Discount Type | |
| `unit` | Unit | exported as its label |
| `description` | Description | |
| `category` / `subCategory` | Category / Sub-category | by name |
| `tax` | Tax | |
| `status` | Status | |
| `maxQuantity` | Max Quantity | blank when there is no limit |
| `soldQuantity` | Sold Quantity | read-only |
| `favorite` | Favorite | read-only, `Yes` / `No` |
| `imageUrl` | Image URL | read-only |
| `createdAt` / `updatedAt` | Created At / Updated At | read-only |

Read-only columns are for reference: the import ignores them. Images themselves are not exported.

```bash
curl -X POST https://uat.auxcore.net/api/v1/products/export   -H "Authorization: Bearer YOUR_TOKEN"   -H "X-Tenant-ID: your-tenant-id"   -H "Content-Type: application/json"   -d '{"ids": ["3f2504e0-4f89-41d3-9a0c-0305e82c3301"], "columns": ["tax", "status"]}'   -o products.xlsx
```

The `.xlsx` file is laid out for editing and importing back: Product ID and the read-only columns
are greyed, Product ID carries a "do not edit" note, Type / Status / Discount Type / Unit have
dropdowns, and an Instructions sheet explains the columns. `.csv` is UTF-8 with a byte-order mark,
dates in ISO 8601; it cannot be imported back.

`400 VALIDATION_ERROR` is returned for an export of more than 10,000 rows (narrow the filter rather
than getting a silently truncated file), more than 500 `ids`, an id that is not a valid id, an
unknown column key, or an unknown `preset` / `format`.

---

## Import

```http
GET  /api/v1/products/import-template
POST /api/v1/products/import
```

`import-template` returns a blank `.xlsx` with the expected headers, dropdowns on Type, Status,
Discount Type and Unit, and an Instructions sheet.

`POST /products/import` takes `multipart/form-data` with the file in the `file` field, max 5 MB.

```bash
curl -X POST https://uat.auxcore.net/api/v1/products/import \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -F "file=@products.xlsx"
```

### Columns

| Column | Required | Rule |
|---|---|---|
| Product ID | No | Blank adds a new product. The ID of one of this merchant's products updates it — see below |
| Product Code | No | Max 80 characters |
| Name | Yes | Max 150 characters |
| Type | No | `Product` or `Service`; blank means Service |
| Price | Yes | 0 or greater, at most 2 decimals |
| Unit | No | A unit label or code (`Gallon`, `gallon`, `gallons`); blank means no unit |
| Discount | No | Blank means 0. Read with Discount Type |
| Discount Type | No | `Percent` or `Fixed`; blank means Percent. `%` and `$` are accepted too |
| Description | No | Max 1000 characters |
| Category | No | Must already exist |
| Sub-category | No | Must already exist under the Category named in the same row |
| Tax | No | 0–100 |
| Status | No | `Active` or `Inactive`; blank means Active |
| Max Quantity | No | Per-order limit: greater than 0, at most 1,000,000, up to 3 decimals; blank means unlimited |

Headers are matched by name, case-insensitively, so column order does not matter. `Subcategory` is
accepted as well as `Sub-category`, and `ProductID` / `ID` as well as `Product ID`. Blank rows are skipped.

### Updating existing products

A row whose **Product ID** is one of the caller's live products updates that product; every other
row adds a new one. To bulk-edit, export, change the cells, and import the file back.

- A Product ID that is not a valid id, is deleted, or belongs to another merchant is ignored: the
  row is added as a new product and listed in `warnings`.
- A column left out of the file keeps the product's current value, so a sheet with only Product ID,
  Name, Price and Tax edits only those. A blank Type, Status or Discount Type also keeps it; any
  other blank cell clears that field.
- A row identical to its product is counted as `unchanged` and not written.
- The same Product ID on two rows is a row error.
- Images and favorites are never changed by import.

Updating needs `invoices.manage` as well as `invoices.create`. A file that would change an
existing product without it is refused with `403 FORBIDDEN` and nothing is written.

### All or nothing

A readable file always answers `200`. Check `data.imported`, not the status code:

```json
{
  "success": true,
  "data": {
    "imported": 0,
    "created": 0,
    "updated": 0,
    "unchanged": 0,
    "totalRows": 3,
    "validRows": 2,
    "errors": [
      { "row": 3, "column": "Price", "message": "Price must be 0 or greater" }
    ],
    "warnings": []
  }
}
```

If any row fails validation **nothing is written** — no row is added or updated, including the rows
that passed. `row` is the 1-based row number as Excel shows it, so it points at the line to fix.
Correct the file and upload it again.

On success `imported` is `created + updated`. `warnings` lists rows whose Product ID matched no
product and were added as new; re-uploading a file whose rows have no Product ID adds them again.
Images cannot be imported — add them per product afterwards. Import never creates categories — an
unknown Category or Sub-category is a row error.

`400 VALIDATION_ERROR` is returned when the file itself is unusable: not a readable `.xlsx`, missing
the Name or Price column, containing no product rows, or holding more than 2,000 rows.
