# apexl/io-product

Product / rate-card catalogue module for Cygnus (`apexl/io`). A saleable and/or purchasable
product with price, cost, markup, currency, optional variants (`parent_id`) and
active/sold/purchased flags — generic and host-agnostic, so a consumer extends it (a finance
module maps a product to nominal accounts and tax codes; a billing module adds plans) without
forking the entity.

## What it provides

- **`Product` entity** — table `products`. Prices and costs are integer minor units (pence).
  `code` is unique; `kind` is a free host-meaningful classification
  (`service` / `material` / `expense` / `bundle`); `parent_id` links a variant to its parent.
- **`ProductService`** — CRUD, `list()` / `listActive()` (ordered by `sort_order` then `code`),
  `find()`, `findByCode()`, and `serialise()` for the API shape.
- **`ProductEntityFactory`** — test/seed factory.
- **`ProductPermission`** — `CAN_VIEW_PRODUCTS` (reads) and `CAN_MANAGE_PRODUCTS` (writes),
  defaulting to director/admin/accounts (+ sales for view).
- **`ProductController`** — the REST surface, five routes under `/api/v1/products`.
- A phinx migration installing `products` (`src/db/migrations`).

## Installation

```
composer require apexl/io-product
```

Served from the VPN-only `packages.apexl` Satis registry. Register the module in the host's
`config/modules.php`:

```php
Apexl\Io\Product\ProductModule::class,
```

`apexl/io-permissions` is not in `require` — it has no io 6 release yet — but
`ProductPermission` implements its `PermissionFlagInterface`, so a host that registers this
module must install it. See [UPGRADING.md](UPGRADING.md).

## Two supported lines — read before you commit

| line | branch | requires | for |
|---|---|---|---|
| **5.x** | `v3` | `apexl/io` ^6.0, `apexl/io-user` ^6.0 | the io 6 estate |
| **4.x** | `4.x` | `apexl/io` ^5.0, `apexl/io-user` ^5.0 | hosts still on io 5 |
| 1.x | `master` | `apexl/io` ^1.0 | retired — the pre-2026 GlobalSim-era module |

`5.0.0` is the io 6 port of `4.0.0` — same behaviour, same five routes, same schema.

`v3` has been the trunk of the modern line since `3.0.0` (2026-06-06); the branch name predates
the versions that shipped from it. `master` still holds the retired 1.x line and is untouched.

**Any new feature lands on both live lines**, until every host has moved to io 6 and the `4.x`
branch is retired. Write it on `v3`, then port it to `4.x` (the namespaces differ, so this is a
rewrite rather than a cherry-pick), and release both. A fix that ships on one line only silently
un-fixes itself for whichever hosts are on the other.

## Routes

All five sit under `/api/v{api.version}/products` and are gated per verb:

| verb | path | name | permission |
|---|---|---|---|
| GET | `/api/v1/products` | `product.index` | `CAN_VIEW_PRODUCTS` |
| POST | `/api/v1/products` | `product.create` | `CAN_MANAGE_PRODUCTS` |
| GET | `/api/v1/products/{id}` | `product.get` | `CAN_VIEW_PRODUCTS` |
| PUT | `/api/v1/products/{id}` | `product.update` | `CAN_MANAGE_PRODUCTS` |
| DELETE | `/api/v1/products/{id}` | `product.delete` | `CAN_MANAGE_PRODUCTS` |

`index` accepts `?active=1` to return only active products, and always returns the `kinds` list
alongside.

## Tests

`tests/ProductServiceTest.php` extends io's `FeatureTest`, which boots a full container and a
migrated database — so it runs in a **host**, not in this repo on its own (`composer test` here
has never had a `.env`, a `config/` or a database to talk to). The io 6 port was verified by
installing the package into a disposable io 6 host and running it there: 4/4, 13 assertions,
against io v6.0.3 / io-user 6.0.2 / vault 6.0.1.
