# CWPOS Online Store — Install & Go-Live Runbook

This is the deployment guide for the `online-store` addon: what to install, the one
change you must make inside CWPOS itself, how to configure it, and how to test it on
staging before taking real payments.

Work top to bottom. **Do Part B on staging first** — it changes how the live POS reads
stock, so it needs a regression pass before it goes near a real shop.

---

## 0. What you're deploying

A single CWPOS addon (`addons/online-store/`) that adds a public storefront, customer
accounts, cart/checkout, CWPay payment, and staff order fulfilment — all sharing the POS's
own catalogue, stock, customers, and sales. It ships with 154 automated tests.

| Area | Where |
|---|---|
| Public storefront | `/shop`, `/shop/catalogue`, `/shop/category/{id}`, `/shop/product/{slug}` |
| Customer accounts | `/shop/account`, `/shop/account/login`, `/shop/account/register` |
| Cart / checkout | `/shop/cart`, `/shop/checkout`, `/shop/checkout/return` |
| Staff — web catalogue | `/web-catalogue` |
| Staff — online orders | `/online-orders` |
| Staff — store settings | `/shop-settings` |
| Payment settle cron | `/shop/cron/settle?key=…` |
| SEO | `/shop/sitemap.xml`, `/shop/robots.txt`, `/shop/manifest.webmanifest` |

---

## 1. Server requirements

- **PHP 7.4+** (8.x fine). Extensions: **pdo_sqlite**, **mbstring**, **curl**; **GD**
  recommended (product-image resizing falls back to a plain copy without it).
- The web server must route unknown paths to CWPOS's front controller (same rewrite CWPOS
  already uses). No extra vhost is required — the storefront lives under `/shop`.
- Write access to CWPOS's `DATA_PATH` (the addon creates `DATA_PATH/cwpos.sqlite` and uses
  `DATA_PATH/uploads/products` for images).

---

## Part A — Install the addon

1. **Copy the folder** into the CWPOS install: `addons/online-store/`.
   (On a multi-tenant/shared-master setup, it goes in the shared master's `addons/`.)
2. **Confirm it's enabled.** `module.json` ships `"enabled": true`; CWPOS auto-discovers it.
   It should appear in the sidebar under **ONLINE STORE** (Web Catalogue / Online Orders /
   Store Settings). If not, check the module toggle in the CWPOS add-ons screen.
3. **First load creates the database.** On the first request after install, the addon
   bootstrap runs `Inventory::ensureSchema()` and a one-time `products.json → SQLite`
   migration. Verify `DATA_PATH/cwpos.sqlite` now exists.
4. **Smoke test:** visit `/web-catalogue` (as an owner/admin). You should see your existing
   products with a web-status column.

> At this point the storefront reads a **migrated snapshot** of products. It is not yet
> sharing live stock with the POS — Part B is what makes stock one number across both.

---

## Part B — Land the data layer (the one required change to CWPOS)

The addon uses SQLite for products/stock so online and in-store sales can't oversell each
other. Until CWPOS itself reads/writes through the same layer, the POS keeps using
`products.json` and the two drift. This part converts CWPOS over. **Do it on staging and
regression-test the whole POS before production.**

### B1. Promote the shared classes to core (so they're always loaded)

Once CWPOS core references `Products`/`Inventory`, those classes must load even if the addon
is ever disabled. Move (or symlink) these two files into CWPOS `core/` and boot them in
CWPOS's own `bootstrap.php`:

- `core/Inventory.php`, `core/Products.php`  → CWPOS `core/`
- In CWPOS `bootstrap.php`, after the data-layer helpers:
  ```php
  Inventory::boot(DATA_PATH . '/cwpos.sqlite');
  Inventory::ensureSchema(BASE_PATH . '/addons/online-store/data/schema.sql');
  Inventory::migrateProductsFromJson(DATA_PATH . '/products.json'); // one-time, self-skipping
  ```
  (The addon bootstrap keeps doing this too; both are idempotent. If you promote to core,
  you can remove the duplicate from the addon bootstrap.)

### B2. Convert the ~90 product call sites

Three mechanical buckets. There are ~90 sites across ~24 files (a `grep -rn "products.json"
controllers core addons` gives the current list).

1. **Reads → accessor (same array shape, no surrounding changes):**
   `data_read('products.json')` → `Products::all()` (or `Products::active()` /
   `Products::find($id)` where the intent is obvious).
2. **Catalogue writes (non-stock):** the read-mutate-`data_write('products.json', $arr)`
   pattern → `Products::saveMany($arr)`; the delete-by-filter in `ProductController::destroy`
   → `Products::delete($id)`.
3. **Stock writes → `Inventory` (do NOT blind-swap these):** route every stock change through
   the atomic path. The sites that change stock:
   - `controllers/TerminalController.php` — `processSale()` deduction → `Inventory::commitSale()`
   - `controllers/StockController.php` — `receive()` / `adjust()` → `Inventory::restock()` / signed adjust
   - `controllers/TransactionController.php` — refund / void restock → `Inventory::restock()`
   - `controllers/StockCountController.php` — count reconcile
   - `addons/branches/…/TransferController.php` — stock transfers
   - `addons/wholesale/…/WholesaleConsignController.php` — consignment moves

### B3. Migrate, verify, retire

1. Load any page once so the migration runs; confirm product counts match
   (`SELECT COUNT(*) FROM products` == rows in the old `products.json`).
2. Spot-check a product with variants and case pricing — the `doc` column preserves every
   field the columns don't (case_size, bundle_components, etc.).
3. **Regression pass (this is the real cost):** exercise every POS surface that touches
   products — terminal sale, stock receive/adjust, stock count, reports (products, profit,
   EOD), branches, wholesale, purchase-orders receiving. Confirm stock numbers move correctly.
4. Once satisfied, retire `products.json` (rename to `products.json.bak`) so nothing reads a
   stale copy.

> Fallback: if you must ship sooner without the full cut, the alternative is hardening the
> JSON layer (one lock across read-modify-write + atomic temp-then-rename). It works for a
> single low-traffic store but is a ceiling — SQLite is the durable choice.

---

## Part C — Configure

### C1. CWPay (payments)

In CWPOS settings, set (these are the existing CWPay keys the POS already uses):

- `cwpay_enabled` = 1
- `cwpay_base_url` = your CWPay install URL
- `cwpay_api_key` = the shared API key
- `cwpay_webhook_secret` = the HMAC secret CWPay signs webhooks with

Use the **Test** button (CWPayClient::testConnection) to confirm connectivity before going further.

### C2. Delivery zones

At **`/shop-settings`**, add your delivery zones (name + flat rate + optional per-kg), and a
free-delivery-over threshold if wanted. If you skip this, delivery falls back to the single
`shop_delivery_fee` value (0 = free). For accurate per-kg pricing, make sure products have a
**weight** set on their web listing.

### C3. Store details & catalogue

- Set `company_name`, `company_address`, `company_phone`, `company_logo`, and `currency` in
  CWPOS settings (the storefront header/footer and emails use these).
- At **`/web-catalogue`**, opt products online: tick *publish*, add a web description, at
  least one image, a weight (for delivery), and a slug (auto-generated if blank). The screen
  flags anything missing and refuses to publish until it's complete.

### C4. Settle cron (payment safety net)

If a customer pays but closes the tab before returning, the return-page finalize never runs.
The settle cron catches those. Set a secret and schedule it:

- Set `cron_key` in CWPOS settings.
- Cron (every 2–5 min):
  ```
  curl -s "https://YOURDOMAIN/shop/cron/settle?key=YOUR_CRON_KEY"
  ```
  It finds paid-but-pending orders, verifies with CWPay, and finalizes them idempotently.

---

## Part D — Staging end-to-end test (before real money)

Run this on staging with **real CWPay sandbox/live-test credentials**. The payment path is
the one thing that can only be truly verified against CWPay, not in unit tests.

1. Publish one in-stock product with a known stock count (say 3).
2. As a customer: register, add 2 to cart, checkout choosing **delivery** with an address.
3. Pay through CWPay. On return, confirm the order shows **Order confirmed**.
4. Verify in CWPOS:
   - Product stock dropped by 2 (`/stock` or `/web-catalogue`).
   - A transaction exists in reports/EOD marked `channel: online`, `payment_method: cwpay`,
     total including delivery.
   - The customer's spend/visit count bumped.
5. **Idempotency:** refresh the return URL — no second transaction, stock unchanged.
6. **Cron:** pay another order, close the tab *before* returning; wait for the cron; confirm
   it finalizes (order → paid, stock deducted, one transaction).
7. **Fulfilment:** in `/online-orders`, walk the order paid → picking → packed → shipped →
   delivered (or the pickup path). Confirm the customer gets an email at the notify steps.
8. **Cancel path:** cancel a paid order; confirm stock is returned and the transaction is
   voided.
9. **Oversell guard:** with a product at stock 1, start a checkout, then sell that unit at the
   till before finalizing; confirm the online order lands in `stock_failed` and books no sale.
10. **SEO:** load `/shop/sitemap.xml` and `/shop/robots.txt`.

If all ten pass on staging, you're clear for production.

---

## Part E — Go-live checklist

- [ ] Part B applied and the full POS regression pass is green on staging
- [ ] `products.json` retired after a verified migration
- [ ] CWPay live credentials set; **webhook secret** matches CWPay's config; Test passes
- [ ] Delivery zones + free-over configured; products have weights
- [ ] Products opted online with description, image, weight, slug
- [ ] `cron_key` set and the settle cron scheduled
- [ ] HTTPS enforced (payments + logins)
- [ ] A real end-to-end payment completed on production with a live card, then refunded in CWPay
- [ ] Backups running (see Part G)

---

## Part F — Running the automated tests

From `addons/online-store/`:

```bash
# one-time (Debian/Ubuntu): PHP CLI + the extensions the tests use
apt-get install -y php-cli php-sqlite3 php-mbstring

php test/inventory_test.php      # atomic stock, migration, oversell guard
php test/products_test.php       # accessor round-trip fidelity
php test/web_catalogue_test.php  # slugs, web fields, gallery, readiness
php test/storefront_test.php     # departments, filter/sort, view-model
php test/checkout_test.php       # auth, cart, bundle expand, idempotent finalize
php test/fulfillment_test.php    # state machine, cancel restock/void
php test/delivery_seo_test.php   # delivery quotes, sitemap, reorder
```

Each prints `Result: N passed, 0 failed` and cleans up its own scratch DB. Run them after any
change to the core classes.

---

## Part G — Backups & rollback

- **Back up before Part B:** copy the whole `DATA_PATH` (it holds `products.json`, all other
  `*.json`, and — after install — `cwpos.sqlite`).
- **Rollback (data layer):** if the cut goes wrong on staging, restore `products.json` from
  `.bak`, revert the converted call sites, and remove the core `Inventory::boot` lines. The
  addon can stay installed but disabled.
- **Ongoing backups:** include `DATA_PATH/cwpos.sqlite` and `DATA_PATH/uploads` in the regular
  backup. SQLite is in WAL mode — back up with the DB idle, or use `sqlite3 cwpos.sqlite ".backup"`.

---

## Appendix — settings keys the addon reads

| Key | Purpose | Default |
|---|---|---|
| `cwpay_enabled`, `cwpay_base_url`, `cwpay_api_key` | CWPay client | — |
| `cwpay_webhook_secret` | webhook HMAC verification | — |
| `currency`, `txn_prefix`, `txn_pattern` | shared with POS | JMD / TXN / `{PREFIX}-{YEAR}-{NNNN}` |
| `company_name`, `company_address`, `company_phone`, `company_logo` | storefront + emails | — |
| `shop_delivery_zones` (JSON), `shop_delivery_free_over`, `shop_delivery_fee` | delivery pricing | flat 0 |
| `low_stock_threshold` | reorder alerts + stock chips | 5 |
| `cron_key` | protects `/shop/cron/settle` | — |

---

*Two reminders that outlast this document: online and in-store share one stock count only
after Part B is applied; and no store should take a first real payment that hasn't completed
a live end-to-end CWPay test (Part D/E).*
