# 12 — Stack & Platform

Technology decisions. Documents 00–11 describe *what* the system does and are deliberately
independent of this file; this one describes *what it is built with and where it runs*.

---

## 1. Confirmed constraints

These were given by the business and are not negotiable by the design.

| # | Constraint | Consequence |
|---|---|---|
| C1 | **One Windows PC per branch, and it is the only terminal** | The branch PC is the entire branch system. No LAN, no device concurrency, no intra-branch sync. |
| C2 | **Offline-first is the most important requirement** | A branch must trade through a multi-day internet outage. |
| C3 | **Central server is shared web hosting with MySQL** | No root, no snapshots, resource caps. |
| C4 | **Cron is available; SSH is not** | Scheduled work is possible. Shell tooling, Composer-on-server and process supervision are not. |
| C5 | **Framework-free PHP, or Python Flask** | No Laravel, no Django. |
| C6 | **SQLite is available and acceptable** | The branch database. |
| C7 | **No native app** — platform freedom for the client | The UI is web technology, delivered as a SPA. |
| C8 | **31 developers** | Capacity is not the constraint; consistency and parallel work are. |

---

## 2. The language decision

### PHP everywhere. Framework-free.

**C4 decides it.** Shared hosting without SSH cannot reliably run Python: no `pip install`,
no virtualenv to repair, no WSGI process to restart when it wedges. cPanel's Python app
support exists but typically needs terminal access to recover from failure — and you do not
have one. PHP is the only language the central server can dependably run.

Given that, using PHP at the branch as well means **one language, one domain codebase, one
set of business rules**. For 31 developers that is worth more than any advantage Flask has
at the branch.

> **The Flask alternative.** Flask at the branch + PHP at the centre is technically viable —
> Waitress runs cleanly as a Windows service. The cost is two codebases with no shared
> domain logic: money handling, cap calculation, credit checks and billing rules would each
> exist twice and drift apart. Only take this path if the team is decisively stronger in
> Python **and** accepts owning that duplication deliberately.

---

## 3. Architecture

> **⛔ Revision (2026-08-05, decision [D17](14-conventions.md)):** the owner ruled that
> **we have no hardware scope — the client installs the POS wherever and whenever they
> want.** The branch side below (Windows PC service, NSSM/WinSW, branch SQLite, bundled
> installer, ESC/POS printing, UPS) is **superseded**: the POS ships as an installable
> **PWA** served from the centre — service worker + IndexedDB for offline, sale-path
> logic in JS held in parity with the PHP kernel by generated test vectors (D20),
> receipts through the OS print dialog (D23). The centre stack (§5), sync contract (§9)
> and the branch rules' *intent* (no network call on the sale path; local durable store;
> sales flow up, master data flows down) are unchanged. The original text is kept for
> the record and for the trade-off analysis that the pivot consciously accepts.

```
BRANCH  (Windows PC — the only terminal)          CENTRE  (shared hosting)
┌────────────────────────────────────────┐        ┌──────────────────────────────┐
│  Browser (Edge, kiosk mode)            │        │  PHP + MySQL                 │
│    └── SPA over http://localhost       │        │                              │
│              │ JSON API                │        │  /api/sync/push  ──▶ staging │
│              ▼                         │        │  /api/sync/pull  ◀── master  │
│  PHP server (Windows Service)          │        │                              │
│    ├── domain logic                    │──HTTPS─▶  cron:                       │
│    ├── SQLite (WAL)                    │◀───────│    process staging → domain  │
│    ├── outbox / sync client            │        │    billing runs              │
│    └── ESC/POS printer + cash drawer   │        │    daily digest              │
└────────────────────────────────────────┘        │    backup dump               │
                                                   │  back-office web UI          │
   × 5 branches                                    └──────────────────────────────┘
```

### Why the install is on the branch PC, not the client device

C7 asks for no native app and platform freedom. C2 asks for offline-first. A browser-only
PWA satisfies the first and struggles badly with the second:

| Browser-only PWA | Local server on the branch PC |
|---|---|
| Offline data in **IndexedDB** — evictable by the browser under disk pressure | Offline data in **SQLite on disk** — durable, backup-able, inspectable |
| Billing, credit, cap and money logic **duplicated in JavaScript** | Logic written **once**, in PHP, shared with the centre |
| Thermal printing and cash-drawer kick require Web Serial / WebUSB — fragile | Local process talks to the printer directly via ESC/POS |
| Sync engine written from scratch in the browser | Sync is an ordinary server-side job |
| Financial records live in a browser profile | Financial records live in a file you control |

The install is **one PHP service on five Windows PCs you already own** — not an application
on any client device. The UI remains a SPA in a browser, so platform freedom is preserved
where it actually matters: add a tablet to a branch later and it points at the branch PC's
LAN address with no code change.

**This is the single most important decision in this document.** Offline-first was stated as
the highest priority; this is what delivers it without rewriting the domain twice.

---

## 4. Branch stack

| Concern | Choice | Notes |
|---|---|---|
| Runtime | **PHP 8.2+** (same minor version as the centre) | Bundled with the installer — never rely on a pre-installed PHP |
| HTTP server | PHP built-in server behind **NSSM** or **WinSW** as a Windows Service | One terminal means one concurrent request; single-threaded is acceptable. Auto-restart on crash and on boot is mandatory. |
| Database | **SQLite**, `WAL` mode, `foreign_keys = ON` | Single file. Back it up by copying the file. |
| API | JSON over `http://localhost` | Same contract shape as the central API |
| Frontend | **SPA** — see §6 | Served as static files by the local server |
| Printing | **mike42/escpos-php** | Thermal ESC/POS + cash-drawer kick. Verify Arabic rendering before buying five printers. |
| Sync client | PHP CLI script, **Windows Task Scheduler** every 60s | Runs independently of the web process, so a sync failure never blocks a sale |
| Startup | Service auto-start + browser kiosk shortcut in Startup | The cashier turns the PC on and the till is there |

### Non-negotiable branch rules

1. **No network call is ever on the path of a sale.** Selling touches SQLite only. Sync is
   a separate process on a timer. If this rule is broken anywhere, offline-first is broken.
2. **SQLite is backed up nightly** by file copy to a second location on the PC and, when
   online, to the centre. Combined with the recovery procedure in
   [10 §9.1](10-cross-cutting-rules.md), this bounds the loss from a dead PC.
3. **The UPS is part of the deployment**, not an accessory. SQLite in WAL mode survives
   power loss well, but the PC and printer do not.

---

## 5. Central stack

| Concern | Choice | Notes |
|---|---|---|
| Runtime | **PHP 8.2+** on shared hosting | Confirm the host's version before committing |
| Database | **MySQL / MariaDB** via **PDO**, prepared statements only | `utf8mb4` throughout for Arabic |
| Scheduling | **cron** (C4) calling `php cron/run.php <job>` | The only mechanism available — there are no queue workers |
| Back office | Server-rendered PHP pages | Owner, Accountant and Manager screens. No offline requirement, so no SPA needed here. |
| Transport | **HTTPS only**, per-device bearer token | Branches never connect to MySQL directly |

### The staging-table pattern — required by shared hosting

There are no background workers, and HTTP requests have execution-time limits. So the sync
endpoint must be fast and dumb, and the real work happens on cron:

```
POST /api/sync/push
    → validate device token
    → INSERT raw events into `sync_staging` (unique index on event_id)
    → return accepted ids immediately            ~ fast, well inside any time limit

cron every 5 minutes:
    → read unprocessed rows from `sync_staging` in chunks
    → apply into domain tables inside a transaction
    → mark processed; quarantine anything invalid
    → resumable: a killed run simply continues next tick
```

This keeps every HTTP request short, makes processing restartable, and means a hosting
timeout can never corrupt a partially-applied batch.

### Cron jobs

| Job | Frequency | Purpose |
|---|---|---|
| `sync:process` | 5 min | Drain the staging table into domain tables |
| `billing:run` | Hourly | Close due billing periods, issue invoices ([05 §3](05-receivables-and-billing.md)) — chunked and idempotent |
| `digest:send` | Daily, after cutoff | Owner daily digest ([09 §1](09-reporting.md)) |
| `rollups:build` | Hourly | Rebuild affected daily aggregates ([09 §8](09-reporting.md)) |
| `alerts:scan` | 15 min | Stale devices, overdue invoices, credit and cap breaches |
| `backup:dump` | Daily | See §8 |

Every job must be **idempotent and chunked**. Shared-host cron fires unreliably; jobs must
tolerate being run twice, and must never depend on completing in one execution.

---

## 6. Frontend

A SPA is correct for the POS specifically: an open order is in-memory state, and a page
reload per item tap would be unusably slow. Back-office screens have no such need and stay
server-rendered.

| Concern | Choice |
|---|---|
| POS UI | SPA, built on a dev machine, deployed as static files to the branch |
| Framework | **Preact + htm**, or **Alpine.js** — small, no mandatory build step, no React toolchain |
| State | Plain module state. The open order is the only complex state; there is nothing here that needs Redux. |
| Styling | Hand-written CSS, **RTL-first** ([10 §6](10-cross-cutting-rules.md)) |
| Offline | The SPA is served from `localhost`, so it is always available. A service worker is **optional** and adds little — the data is already local. |

> "Framework-free" applies to the backend. Using a 4 KB view library on the POS screen is
> not the thing that decision was protecting against.

---

## 7. Framework-free: what it actually means

No Laravel means no Eloquent, no migrations, no router, no validation, no auth — those do
not disappear, they become **yours to write**. With 31 developers and no framework
conventions, the real risk is not capability, it is **31 different styles in one codebase**.

Mitigate it by building a thin shared kernel first, before any feature work:

| Kernel piece | Approx. size | Purpose |
|---|---|---|
| Router | ~100 lines | Method + path → handler |
| Request / Response | ~150 lines | Input parsing, JSON responses, status codes |
| DB layer over PDO | ~250 lines | Connection, transactions, query helpers, **works against both SQLite and MySQL** |
| Migration runner | ~150 lines | Numbered SQL files, applied in order, recorded in a `migrations` table |
| Validator | ~200 lines | Declarative rules; used by both API and forms |
| Auth / session / RBAC | ~300 lines | PIN and password login, capability checks ([01 §3](01-organization-and-access.md)) |
| Audit writer | ~100 lines | One call, used everywhere ([01 §4](01-organization-and-access.md)) |
| Money | ~150 lines | Integer minor units, IQD/USD, rounding, FX ([10 §2](10-cross-cutting-rules.md)) |

**Framework-free is not library-free.** Use focused Composer packages where they earn it:
`mike42/escpos-php` for printing, `vlucas/phpdotenv` for config, `guzzlehttp/guzzle` for
sync HTTP, `phpoffice/phpspreadsheet` for accountant exports.

### Mandatory with a team this size

1. **A written conventions document** — naming, layering, error handling, test expectations.
   The framework would have imposed these; now you must.
2. **Every schema change is a numbered migration file.** No manual SQL on any database, ever.
3. **The domain layer knows nothing about HTTP.** It is what branch and centre share.
4. **PHPUnit from day one**, with the billing engine and money handling under the heaviest
   coverage — they are where a bug costs real money and real customer trust.
5. **All SQL through the DB layer**, and tested against **both** SQLite and MySQL in CI.
   Dialect differences in dates and JSON functions are where this architecture will bite.

---

## 8. Deployment and backup without SSH

C4 shapes the whole workflow.

| Task | Without SSH |
|---|---|
| **Install dependencies** | `composer install` runs on a dev machine or in CI. **`vendor/` is committed or bundled into the release artifact.** There is no Composer on the server. |
| **Deploy to centre** | CI builds an artifact and uploads over **FTPS**. No developer FTPs files by hand — with 31 developers that is how production drifts from `main`. |
| **Run migrations** | A cron job or a token-protected admin endpoint that applies pending migrations. Never manual. |
| **Deploy to a branch** | A signed installer/updater package. The branch service checks for updates, downloads, verifies, and applies during a closed period — never mid-shift. |
| **Backups** | Daily cron running `mysqldump` if the host provides the binary; otherwise a **PHP dump script** writing SQL through PDO. The dump is then **pushed off the host** (object storage or a second provider). |

**The backup is the single largest risk in this architecture.** You cannot snapshot shared
hosting, and it holds every invoice, balance and payroll record. Requirements:

- Runs daily, unattended.
- Lands **off the hosting account**.
- Emits a **dead-man's-switch alert** if it does not run — a silent backup failure is worse
  than no backup, because it is believed.
- Is **restore-tested on a schedule** ([10 §7](10-cross-cutting-rules.md)). An untested
  backup is not a backup.

---

## 9. Sync contract

```
POST /api/sync/push          Authorization: Bearer <device_token>
{ "device_id", "branch_id", "events": [
    { "event_id": uuid, "type": "order.confirmed", "occurred_at", "business_day",
      "payload": { ... } } ] }
→ 200 { "accepted": [uuid...], "quarantined": [{ "event_id", "reason" }] }

GET  /api/sync/pull?cursor=<opaque>
→ 200 { "changes": [ { "entity": "item", "op": "upsert", "data": {...} } ],
        "next_cursor": "..." , "has_more": bool }
```

| Rule | Reason |
|---|---|
| **Sales flow up; master data flows down.** Never two-way merge on one entity. | Removes nearly all conflicts by construction ([10 §4](10-cross-cutting-rules.md)) |
| **`event_id` is a UUID with a unique index at the centre.** | Retry after a dropped connection is normal; it must never double-post |
| **The centre accepts and flags — it never rejects a synced sale.** | The coffee is already drunk |
| **Events are append-only, ordered per device.** | Deterministic replay |
| **Master data is applied at a quiet moment**, never mid-order. | A price changing between "add item" and "pay" is a real bug |
| **Device tokens are revocable centrally.** | A stolen branch PC must be cut off ([10 §7](10-cross-cutting-rules.md)) |

---

## 10. Working as a team of 31

The architecture must let people work in parallel without colliding.

| Stream | Owns |
|---|---|
| **Kernel & platform** | §7 kernel, migrations, CI, installer, deployment. **Must finish first** — everything else depends on it. |
| **POS / branch** | SPA, order flow, settlement, shifts, printing |
| **Accounts & billing** | Accounts, funding policies and caps, billing runs, invoices, collections |
| **Inventory & purchasing** | Stock, recipes, suppliers, POs, counts |
| **Expenses & payroll** | Rent, payroll, advances, payables |
| **Reporting & sync** | Rollups, dashboards, digest, sync pipeline |
| **QA** | Test strategy, dialect-parity tests, restore drills, hardware verification |

Rules that keep parallel work honest:

1. **The kernel is built and frozen first.** Feature streams starting before it will each
   invent their own version of it.
2. **Modules communicate through the domain layer**, never by reaching into each other's
   tables.
3. **The API contract (§9) is agreed and versioned before branch and centre work in
   parallel.**
4. **CI runs the full suite against both SQLite and MySQL** on every pull request.

---

## 11. Risks

| Risk | Severity | Mitigation |
|---|---|---|
| Shared-host backup loss | **Critical** | §8 — off-host, alerted, restore-tested |
| Branch PC failure (single terminal) | **High** | [10 §9.1](10-cross-cutting-rules.md) rehearsed recovery; nightly local backup |
| SQLite/MySQL dialect divergence | **High** | All SQL through the DB layer; CI against both |
| Inconsistent code across 31 developers | **High** | §7 kernel + written conventions + review |
| Cron fires unreliably | Medium | Idempotent, chunked, resumable jobs; dead-man's-switch monitoring |
| Host resource caps / suspension | Medium | Pre-aggregated rollups; never scan raw tables for reports |
| Arabic thermal printing fails | Medium | **Verify on real hardware before buying five printers** |
| Shared hosting outgrown | Low (later) | Migrate to a VPS — same PHP, same MySQL, no rewrite |

---

## 12. Still open

1. **Confirm the host's PHP version and that `mysqldump` is reachable from cron.** If PHP is
   below 8.2 or cron cannot dump, the hosting plan must change before Phase 1 starts.
2. **Buy and test one full hardware set** — PC, thermal printer, cash drawer, UPS — and
   verify Arabic printing, before committing to five.
3. **Decide the branch update mechanism**: self-updating service, or manual install per
   release. With five branches in three cities, self-updating pays for itself quickly.
