# Deploying the POS API to posapi.ealliance.net and pointing the tills at it

Everything below matches the files in this folder as of 29 Sep 2026. Do the steps in order; each has a check you must pass before moving on.

## 0. Before you start

You need:

- cPanel (or SSH) on the server that hosts `upgrade.ealliance.net`, with PHP 8.1 or newer and the extensions `pdo_mysql`, `curl`, `openssl`, `mbstring`.
- The live database name, user and password (the same database the web app uses).
- A quiet window of about 15 minutes for the migration (it adds indexes to a 2.7 million row table).
- One Sunmi till you can use for a bench test before the fleet.

Nothing here changes the web app or the legacy `POS/` scripts. Both systems write the same tables, so old and new tills can trade side by side during the cut-over.

## 1. Create the subdomain

cPanel → Domains → Create a New Domain (or Subdomains on older cPanel).

| Field | Value |
|---|---|
| Domain | `posapi.ealliance.net` |
| Document root | `/home/<account>/ealliance-pos-api/public` |

The document root must be the `public` folder, never the project root. That keeps `.env`, `src/` and `storage/` outside the web.

If DNS for `ealliance.net` is not on this server, add an `A` record for `posapi` pointing at the server IP at your DNS provider. Wait until `ping posapi.ealliance.net` resolves.

Then cPanel → SSL/TLS Status → tick the subdomain → Run AutoSSL. Check: `https://posapi.ealliance.net/` must load without a certificate warning (it will show a 500 or "service" JSON until step 3, that is fine).

If cPanel asks for a PHP version for the domain (MultiPHP Manager), pick 8.1, 8.2 or 8.3.

## 2. Upload the code

Upload the whole `ealliance-pos-api` folder to `/home/<account>/ealliance-pos-api` **except**:

- `.env` (the local one holds throwaway secrets; you create the real one in step 3)
- `storage/logs/*` (keep the empty folder)
- `tests/` (optional; they need a database and are not for live)

Easiest: zip the folder locally, upload the zip with cPanel File Manager to `/home/<account>/`, Extract, then delete the zip and the uploaded `.env`.

Make `storage/logs` writable by PHP (755 is enough on cPanel, because PHP runs as your account).

Confirm `public/.htaccess` arrived (File Manager hides dot-files unless "Show Hidden Files" is on). It rewrites every path to `index.php` and passes the `Authorization` header through, which the JWT needs.

## 3. Create the real `.env`

In `/home/<account>/ealliance-pos-api/`, copy `.env.example` to `.env` and fill in:

```
APP_ENV=production
APP_DEBUG=0
APP_URL=https://posapi.ealliance.net

DB_HOST=localhost
DB_PORT=3306
DB_NAME=<live database name>
DB_USER=<database user>
DB_PASS=<database password>

JWT_SECRET=<48+ random characters>
JWT_ISSUER=posapi.ealliance.net
ACCESS_TOKEN_TTL=43200
REFRESH_TOKEN_TTL=2592000

PIN_PEPPER=<another 48+ random characters>

OFFICIAL_WA_BASE_URL=https://whatsapp.ealliance.net/api/v1
OFFICIAL_WA_BEARER_TOKEN=<the gateway token the web app uses>
OUTBOX_IN_WINDOW_ONLY=1
OUTBOX_DRY_RUN=0
POS_COMPLIMENT_BOT_NUMBER=263780865065

CORS_ALLOWED_ORIGINS=https://upgrade.ealliance.net
```

Generate the two secrets on your PC with:

```
C:\xampp\php\php.exe -r "echo bin2hex(random_bytes(32)), PHP_EOL, bin2hex(random_bytes(32)), PHP_EOL;"
```

Rules:

- Never reuse the local `.env` secrets.
- `JWT_SECRET` changed later = every till is logged out.
- `PIN_PEPPER` changed later = every PIN stops working until you re-run `migrate.php --pins`.
- `OUTBOX_IN_WINDOW_ONLY=1` means the POS never sends a billed WhatsApp template (only free replies inside an open 24 h session). Set it to 0 only if you accept template charges from the till.
- `APP_DEBUG` stays 0 on live. With 1, error details go into 500 responses.

Set the file permission to 600 (owner read/write only).

## 4. Run the migration (off-peak)

Open cPanel → Terminal (or SSH) and run:

```
cd ~/ealliance-pos-api
php bin/migrate.php
```

Expected output: `Applied:` followed by `001_pos_core.sql`. Running it again prints `Nothing to apply.` It is safe to re-run: every ALTER is guarded.

What it does: creates `pos_pins`, `pos_refresh_tokens`, `pos_login_attempts`, `pos_idempotency_keys`, `pos_outbox`, `pos_audit`; adds `state`, `client_shift_id`, `closing_counted_at`, `approved_at` to `a_pos_shifts`; adds device columns; builds indexes on `a_pos_transactions`, `a_pos_products`, `a_pos_receipts` and the price tables. The index builds take short write locks, which is why this runs off-peak. The legacy scripts keep working before, during and after.

If `php` on the terminal is an old version, use the full path, for example `/opt/cpanel/ea-php82/root/usr/bin/php`.

Then hash every employee PIN:

```
php bin/migrate.php --pins
```

Expected: `pos_pins rebuilt for <N> employees`. Re-run this any time a PIN is changed on the web app, because the legacy pages still write `employee.password`. The simplest way is a cron (step 5).

## 5. Cron jobs

cPanel → Cron Jobs. Add:

| Schedule | Command | Purpose |
|---|---|---|
| Every minute | `php /home/<account>/ealliance-pos-api/bin/outbox.php >> /home/<account>/ealliance-pos-api/storage/logs/outbox.log 2>&1` | Delivers compliment and expense WhatsApp notifications queued by the API |
| Every 10 minutes | `php /home/<account>/ealliance-pos-api/bin/migrate.php --pins > /dev/null 2>&1` | Keeps `pos_pins` in step with PINs edited on the web app |

## 6. Smoke check from your PC

```
curl https://posapi.ealliance.net/v1/health
```

Expected: `{"ok":true,"data":{"status":"ok","db":"ok",...}}`. If `db` is not `ok`, the `.env` credentials are wrong.

Now register a throwaway device, then log in with a known cashier PIN. Login refuses any device that is not registered (`403 device_unknown`), which is by design.

```
curl -X POST https://posapi.ealliance.net/v1/devices -H "Content-Type: application/json" -d "{\"device_id\":\"deploy-test\",\"model\":\"curl\",\"manufacturer\":\"test\",\"branch_id\":<a branch id>,\"rp\":<a revenue point id>}"
```

The reply carries `device_code`. Use it in the login header:

```
curl -X POST https://posapi.ealliance.net/v1/auth/login -H "Content-Type: application/json" -H "X-POS-Device: <device_code>" -d "{\"pin\":\"1234\"}"
```

Expected: `{"ok":true,"data":{"access_token":"...","refresh_token":"...","user":{"username":...,"scopes":[...]}}}`. Afterwards set that test device to inactive on the web app's device page so nobody can trade from it. A `409 pin_ambiguous` means that PIN is shared; the response lists the candidates, which is correct behaviour. A `401` with `pin_invalid` means `--pins` did not run or the pepper changed.

Common failures:

- `404` on every path except `/`: `.htaccess` missing or `AllowOverride` off for the subdomain. Ask the host to enable `mod_rewrite` overrides, or move the rules into the vhost.
- `401 missing_token` when you did send a token: the `Authorization` header is being stripped. The `.htaccess` `RewriteRule .* - [E=HTTP_AUTHORIZATION:...]` line handles this on Apache; on LiteSpeed it is on by default.
- `500` with nothing useful: read `storage/logs/pos-api-<today>.log`. Each request is one JSON line: `m` method, `p` path, `s` HTTP status, `ms` duration, plus a `rid` request id you can match to the app's `X-Request-Id`.

Optional, if you uploaded `tests/`: `php tests/smoke.php` against live creates and cleans up its own test rows on a real branch. Do it only outside trading hours.

## 7. Build the mobile POS

The Flutter app already points at `https://posapi.ealliance.net` (see `lib/api/api_config.dart`), so no code change is needed. On your PC:

```
cd C:\bctech\EAlliancePOS
fvm flutter pub get
fvm flutter analyze                     # must report 0 errors
fvm flutter build apk --release
```

The APK is at `build/app/outputs/flutter-apk/app-release.apk`. Bump `version:` in `pubspec.yaml` first so the new build is distinguishable on the devices.

To test against a local copy of the API instead, build with `--dart-define=POS_API_BASE_URL=http://10.0.2.2:8090` (emulator) or your PC's LAN address. Release builds refuse plain http to anything else, because cleartext traffic is off in the manifest.

## 8. Bench test on one till (do not skip)

Install the APK on one device that still has the old app and a shift's worth of local data. The first launch migrates the old SharedPreferences queues into Hive automatically. Then:

1. Sign in with a cashier PIN while online. Expect the normal dashboard. Sign out, turn off data, sign in again with the same PIN: it must work offline. A PIN that never signed in online on this device must be refused offline.
2. Menu → Assign Revenue Point. Pick the branch and point, save. The device row appears on the web app's device page.
3. Start shift. Prices must load (Price List page shows the count and "up to date").
4. Sell three items, one with a supervisor price change. The supervisor PIN must be checked by the server; a wrong PIN must be refused with the server's message.
5. Turn off data. Sell two items. Close the shift with the supervisor PIN. Expect the "will sync when online" toast.
6. Turn data on and wait one minute, or open Unsynced and press sync. Every record must show `uploaded`, none `rejected`. On the web app, the shift is closed and approved, the receipts are there once, and the stocksheet shows the sales.
7. Void one of the receipts from Sales with the supervisor PIN. The web app shows it refunded and the stock returned.
8. Product List page loads the stock on hand for the point.

Anything `rejected` on Unsynced shows the server's reason in `sync_error`; report that text, it is the exact cause.

## 9. Roll out

- Install the APK branch by branch. Old and new tills can trade at the same time; both write the same tables.
- Keep the legacy `POS/` folder on the server until the last old till is replaced. Then delete it and change the database password, because its 28 files carry the password in clear text.
- Once a week look at `storage/logs/pos-api-*.log` for `req` lines whose `s` is 500 or more (server errors) and for `unhandled` lines, and at `pos_outbox` for rows stuck in `failed`.

## 10. Rolling back

The app: reinstall the previous APK. Its local queues are untouched; the new app only read them into Hive, it did not delete them.

The API: nothing to roll back. The migration adds columns and tables the legacy scripts ignore. Leave them in place.
