From 1f23102050864814a1a3d4946be1acecfcf8a212 Mon Sep 17 00:00:00 2001 From: Krikorios <99836218+Krikorios@users.noreply.github.com> Date: Wed, 20 May 2026 19:47:55 +0300 Subject: [PATCH] Prepare shop-ready deployment docs and fix transaction modal UI --- .env.example | 13 ++ .gitignore | 6 + README.md | 24 ++++ docker-compose.yml | 15 +- docs/SHOP_SETUP.md | 72 ++++++++++ scripts/backup.sh | 19 +++ scripts/db_invariants.sql | 90 ++++++++++++ scripts/e2e_validate.mjs | 192 +++++++++++++++++++++++++ scripts/start_prod.sh | 25 ++++ server/.env | 6 - server/.env.example | 15 +- server/src/index.js | 87 +++++++++-- src/components/AdminDataEntryModal.tsx | 13 +- src/components/CashierTools.tsx | 10 +- src/components/TransactionEntry.tsx | 52 ++++--- src/lib/api.ts | 5 +- 16 files changed, 584 insertions(+), 60 deletions(-) create mode 100644 .env.example create mode 100644 docs/SHOP_SETUP.md create mode 100755 scripts/backup.sh create mode 100644 scripts/db_invariants.sql create mode 100644 scripts/e2e_validate.mjs create mode 100755 scripts/start_prod.sh delete mode 100644 server/.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d279288 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy this file to .env before running docker compose. +# Do NOT commit your real .env file. + +# Postgres container password (use a strong random string) +POSTGRES_PASSWORD=replace-with-strong-password + +# Database name used by the app +POSTGRES_DB=crm_omt + +# Seed admin user created on first DB initialization +ADMIN_EMAIL=admin@local.test +ADMIN_PASSWORD=ChangeMe123! +ADMIN_NAME=Owner diff --git a/.gitignore b/.gitignore index a547bf3..ec36dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? + +# production secrets +.env +server/.env +server/.env.production +backups/ diff --git a/README.md b/README.md index a83a584..8ecc0a4 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,30 @@ Default seed admin (override via env in [docker-compose.yml](docker-compose.yml) - email: `admin@local.test` - password: `ChangeMe123!` +## Shop deployment quickstart + +For a machine in the shop, use the full guide at [docs/SHOP_SETUP.md](docs/SHOP_SETUP.md). + +Fast path: + +```bash +git clone https://github.com/Krikorios/OMT-SM.git +cd OMT-SM + +cp .env.example .env +cp server/.env.example server/.env + +npm install +npm --prefix server install + +./scripts/start_prod.sh +``` + +Important: + +- Set strong secrets in `.env` and `server/.env` before first production use. +- Match `POSTGRES_PASSWORD` in `.env` with the password inside `server/.env` `DATABASE_URL`. + ## Tech stack React 18, TypeScript, Vite, shadcn/ui, Tailwind, TanStack Query, react-hook-form, diff --git a/docker-compose.yml b/docker-compose.yml index aca31b0..608fada 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,16 @@ services: image: postgres:16-alpine container_name: crm_omt_db restart: unless-stopped + # bind to loopback only — DB is reachable from the API on this box, not from the LAN ports: - - "5432:5432" + - "127.0.0.1:5432:5432" environment: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: crm_omt - ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@local.test} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!} - ADMIN_NAME: ${ADMIN_NAME:-Local Admin} + # all of these MUST come from the .env file at repo root — no defaults + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:-crm_omt} + ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL is required} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} + ADMIN_NAME: ${ADMIN_NAME:-Owner} volumes: - dbdata:/var/lib/postgresql/data - ./supabase/migrations:/sql/migrations:ro @@ -23,3 +25,4 @@ services: volumes: dbdata: + diff --git a/docs/SHOP_SETUP.md b/docs/SHOP_SETUP.md new file mode 100644 index 0000000..534532e --- /dev/null +++ b/docs/SHOP_SETUP.md @@ -0,0 +1,72 @@ +# Shop Setup Guide (Production) + +This guide is for a fresh machine at the shop. + +## 1. Prerequisites + +- Docker Desktop installed and running +- Node.js 20+ and npm +- Git + +## 2. Pull and prepare + +```bash +git clone https://github.com/Krikorios/OMT-SM.git +cd OMT-SM + +npm install +npm --prefix server install +``` + +## 3. Configure environment + +Create root env for Docker DB + seeded owner: + +```bash +cp .env.example .env +``` + +Edit `.env` and set strong values for `POSTGRES_PASSWORD` and `ADMIN_PASSWORD`. + +Create API env: + +```bash +cp server/.env.example server/.env +``` + +Edit `server/.env`: +- Set `DATABASE_URL` password to match root `.env` `POSTGRES_PASSWORD` +- Set a strong `JWT_SECRET` (32+ chars) +- Keep `NODE_ENV=production` + +## 4. Start in production mode + +```bash +./scripts/start_prod.sh +``` + +The app is then reachable on: +- http://localhost:4000 +- http://127.0.0.1:4000 + +## 5. Optional operations + +Reset DB and rerun all migrations + seed: + +```bash +npm run db:reset +``` + +Run nightly backups (cron example is inside the script header): + +```bash +./scripts/backup.sh +``` + +## 6. First login + +Use the admin email/password from root `.env`: +- `ADMIN_EMAIL` +- `ADMIN_PASSWORD` + +Then create cashier/manager users from the UI. diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..4c78afd --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Nightly backup of the production DB. +# Add to crontab: 5 2 * * * /path/to/cash-collection-management-system/scripts/backup.sh >> /var/log/crm_omt_backup.log 2>&1 +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/backups}" +KEEP_DAYS="${KEEP_DAYS:-30}" +TS="$(date +%Y%m%d_%H%M%S)" +OUT="$BACKUP_DIR/crm_omt_${TS}.sql.gz" + +mkdir -p "$BACKUP_DIR" +docker exec crm_omt_db pg_dump -U postgres -d crm_omt --clean --if-exists \ + | gzip -9 > "$OUT" + +# Prune anything older than KEEP_DAYS days +find "$BACKUP_DIR" -name 'crm_omt_*.sql.gz' -type f -mtime "+$KEEP_DAYS" -delete + +echo "[backup] wrote $OUT ($(du -h "$OUT" | cut -f1))" diff --git a/scripts/db_invariants.sql b/scripts/db_invariants.sql new file mode 100644 index 0000000..691f019 --- /dev/null +++ b/scripts/db_invariants.sql @@ -0,0 +1,90 @@ +-- ===================================================================== +-- DB invariants: run against a closed E2E shift to verify SQL-side rules. +-- Pass shift id via: psql ... -v shift_id="''" +-- ===================================================================== +\set ON_ERROR_STOP on +\timing off + +\echo +\echo === shift under test === +select id, status, opening_usd, expected_close_usd, declared_close_usd, + variance_usd, opening_lbp, expected_close_lbp, variance_lbp + from app.shifts where id = :shift_id; + +\echo +\echo === INV1: every completed REPAIR txn has >=1 cash_movements row === +select t.id, t.service_code, t.status + from app.transactions t + where t.shift_id = :shift_id + and t.status = 'completed' + and t.service_code = 'REPAIR' + and not exists (select 1 from app.cash_movements cm where cm.ref_txn_id = t.id); +\echo (expect 0 rows) + +\echo +\echo === INV2: voided txns have net-zero cash (per currency) === +with v as ( + select id from app.transactions + where shift_id = :shift_id and status = 'voided' +) +select cm.ref_txn_id, cm.currency, sum(cm.amount) as net + from app.cash_movements cm + join v on v.id = cm.ref_txn_id + group by cm.ref_txn_id, cm.currency +having sum(cm.amount) <> 0; +\echo (expect 0 rows) + +\echo +\echo === INV3: opening + Σ cash_movements(post-open) = expected_close === +with s as (select * from app.shifts where id = :shift_id), +mv_usd as ( + select coalesce(sum(amount),0) as total + from app.cash_movements + where shift_id = :shift_id and currency = 'USD' and type <> 'opening_float' +), +mv_lbp as ( + select coalesce(sum(amount),0) as total + from app.cash_movements + where shift_id = :shift_id and currency = 'LBP' and type <> 'opening_float' +) +select s.opening_usd, mv_usd.total as movements_usd, + (s.opening_usd + mv_usd.total) as computed_usd, + s.expected_close_usd, + (s.opening_usd + mv_usd.total = s.expected_close_usd) as usd_ok, + s.opening_lbp, mv_lbp.total as movements_lbp, + (s.opening_lbp + mv_lbp.total) as computed_lbp, + s.expected_close_lbp, + (s.opening_lbp + mv_lbp.total = s.expected_close_lbp) as lbp_ok + from s, mv_usd, mv_lbp; +\echo (expect usd_ok = t AND lbp_ok = t) + +\echo +\echo === INV4: variance = declared - expected === +select id, + (declared_close_usd - expected_close_usd) as computed_var_usd, + variance_usd, + (declared_close_usd - expected_close_usd) = variance_usd as usd_ok, + (declared_close_lbp - expected_close_lbp) as computed_var_lbp, + variance_lbp, + (declared_close_lbp - expected_close_lbp) = variance_lbp as lbp_ok + from app.shifts where id = :shift_id; +\echo (expect usd_ok = t AND lbp_ok = t) + +\echo +\echo === INV5: cash_movements sign rule never violated (whole DB) === +select id, shift_id, type, currency, amount + from app.cash_movements + where (type in ('sale_in','fx_swap_in','opening_float') and amount <= 0) + or (type in ('payout_out','drop_to_safe','bank_deposit','expense','fx_swap_out') and amount >= 0); +\echo (expect 0 rows) + +\echo +\echo === INV6: cash_movements.ref_txn_id always resolves === +select cm.id, cm.ref_txn_id + from app.cash_movements cm + where cm.ref_txn_id is not null + and not exists (select 1 from app.transactions t where t.id = cm.ref_txn_id); +\echo (expect 0 rows) + +\echo +\echo === DONE === diff --git a/scripts/e2e_validate.mjs b/scripts/e2e_validate.mjs new file mode 100644 index 0000000..5ab1e49 --- /dev/null +++ b/scripts/e2e_validate.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// End-to-end production-readiness validation against the running stack. +// Usage: node scripts/e2e_validate.mjs +// ENV: API=http://localhost:4000 EMAIL=admin@local.test PASSWORD=ChangeMe123! + +const API = process.env.API || 'http://localhost:4000'; +const EMAIL = process.env.EMAIL || 'admin@local.test'; +const PASSWORD = process.env.PASSWORD || 'ChangeMe123!'; + +let TOKEN = null; +let failures = 0; +let checks = 0; + +const fmt = (n) => Number(n).toFixed(2); +const eq = (a, b, tol = 0.0001) => Math.abs(Number(a) - Number(b)) <= tol; + +function pass(msg) { checks++; console.log(` PASS ${msg}`); } +function fail(msg, extra='') { checks++; failures++; console.log(` FAIL ${msg}${extra ? ' ('+extra+')' : ''}`); } +function step(msg) { console.log(`\n== ${msg}`); } + +async function req(method, path, body) { + const r = await fetch(`${API}${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await r.text(); + let json; try { json = JSON.parse(text); } catch { json = { raw: text }; } + if (!r.ok) { + const err = new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(json)}`); + err.body = json; + throw err; + } + return json; +} + +const rpc = (fn, args) => req('POST', `/rpc/${fn}`, args).then(r => r.data); +const view = (v) => req('GET', `/from/${v}`).then(r => r.data); + +(async () => { + step('login'); + const login = await req('POST', '/auth/login', { email: EMAIL, password: PASSWORD }); + TOKEN = login.token; + const ADMIN_ID = login.user.id; + pass(`login as ${login.user.email}`); + + step('discover shop + till'); + const shops = await view('v_my_shops'); + if (!shops.length) { fail('no shops visible'); process.exit(1); } + const shopId = shops[0].shop_id; + pass(`shop ${shops[0].name} (${shopId})`); + + const tillName = `E2E-Till-${Date.now()}`; + const newTill = await rpc('create_till', { p_shop: shopId, p_name: tillName }); + const tillId = typeof newTill === 'string' ? newTill : newTill?.id ?? newTill; + pass(`created till ${tillName} (${tillId})`); + + step('open shift with opening float USD 100 / LBP 0 (owner assigns to self)'); + const openingUsd = 100, openingLbp = 0; + await rpc('open_shift', { + p_till_id: tillId, + p_opening_usd: openingUsd, + p_opening_lbp: openingLbp, + p_assigned_user_id: ADMIN_ID, + }); + const active = await rpc('my_active_shift', { p_shop: shopId }); + const shift = Array.isArray(active) ? active[0] : active; + if (!shift?.shift_id) { fail('shift did not open'); process.exit(1); } + const shiftId = shift.shift_id; + pass(`shift opened ${shiftId} status=${shift.status}`); + + step('record REPAIR USD 30 (cash-only path)'); + const repair1 = await rpc('record_repair', { + p_shop: shopId, p_till: tillId, + p_payment_method: 'cash_usd', + p_gross_usd: 30, p_gross_lbp: 0, + p_fx_rate: null, + p_device_type: 'iPhone 12', + p_device_imei: null, + p_issue_summary: 'screen replacement', + p_warranty_days: 7, + p_customer_id: null, + p_notes: 'e2e r1', + }); + pass(`REPAIR #1 txn ${repair1}`); + + step('record REPAIR USD 20'); + const repair2 = await rpc('record_repair', { + p_shop: shopId, p_till: tillId, + p_payment_method: 'cash_usd', + p_gross_usd: 20, p_gross_lbp: 0, + p_fx_rate: null, + p_device_type: 'Samsung A50', + p_device_imei: null, + p_issue_summary: 'battery replacement', + p_warranty_days: 30, + p_customer_id: null, + p_notes: 'e2e r2', + }); + pass(`REPAIR #2 txn ${repair2}`); + + step('midday safe drop USD 25'); + await rpc('record_cash_drop', { + p_shift_id: shiftId, + p_drop_usd: 25, + p_drop_lbp: 0, + p_notes: 'e2e midday', + }); + pass('drop recorded'); + + step('ENFORCEMENT: void without voided_paper_photo evidence must be REJECTED'); + let voidRejected = false; + try { + await rpc('void_transaction', { + p_txn_id: repair1, + p_reason: 'e2e void without evidence', + }); + } catch (e) { + voidRejected = true; + const msg = e.body?.error || e.message; + if (/voided_paper_photo/i.test(msg)) { + pass(`void correctly rejected: ${msg}`); + } else { + fail(`void rejected but for unexpected reason: ${msg}`); + } + } + if (!voidRejected) fail('SECURITY: void with no evidence was accepted!'); + + step('ENFORCEMENT: cash_movements sign guard (drop with negative amount should fail)'); + let dropGuardOk = false; + try { + await rpc('record_cash_drop', { + p_shift_id: shiftId, + p_drop_usd: -10, + p_drop_lbp: 0, + p_notes: 'e2e negative drop', + }); + } catch (e) { + dropGuardOk = true; + pass(`negative drop correctly rejected: ${e.body?.error || e.message}`); + } + if (!dropGuardOk) fail('SECURITY: negative drop amount was accepted!'); + + // Expected drawer math (USD, no void applied): + // opening 100 + // + REPAIR1 +30 + // + REPAIR2 +20 + // - drop -25 + // = 125 + const expectedUsd = 125; + const expectedLbp = 0; + const counted = 124; // intentional $1 short + + step('declare close USD 124 (intentional $1 short)'); + await rpc('declare_close', { + p_shift_id: shiftId, + p_declared_close_usd: counted, + p_declared_close_lbp: 0, + }); + pass('declared'); + + step('finalize close + assert expected/variance'); + const fin = await rpc('finalize_close', { p_shift_id: shiftId }); + const row = Array.isArray(fin) ? fin[0] : fin; + console.log(' finalize_close ->', JSON.stringify(row)); + eq(row.expected_usd, expectedUsd) + ? pass(`expected_usd = ${fmt(row.expected_usd)} (== ${expectedUsd})`) + : fail(`expected_usd = ${fmt(row.expected_usd)} (!= ${expectedUsd})`); + eq(row.expected_lbp, expectedLbp) + ? pass(`expected_lbp = ${fmt(row.expected_lbp)} (== ${expectedLbp})`) + : fail(`expected_lbp = ${fmt(row.expected_lbp)} (!= ${expectedLbp})`); + eq(row.variance_usd, counted - expectedUsd) + ? pass(`variance_usd = ${fmt(row.variance_usd)} (== ${counted - expectedUsd})`) + : fail(`variance_usd = ${fmt(row.variance_usd)} (expected ${counted - expectedUsd})`); + + // ------------------------------------------------------------------ + // DB-level invariant checks via /rpc/... we don't + // have such an RPC, so just print a SQL block for the runner to exec. + // ------------------------------------------------------------------ + step('SUMMARY'); + console.log(` checks: ${checks} failures: ${failures}`); + if (failures) process.exit(2); + + console.log(`\nShift under test: ${shiftId}`); +})().catch(e => { + console.error('FATAL', e.message); + if (e.body) console.error(JSON.stringify(e.body, null, 2)); + process.exit(1); +}); diff --git a/scripts/start_prod.sh b/scripts/start_prod.sh new file mode 100755 index 0000000..fdeb25d --- /dev/null +++ b/scripts/start_prod.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Start the full production stack on this box. +set -euo pipefail +cd "$(dirname "$0")/.." + +# 1. DB up (idempotent) +docker compose up -d db + +# 2. Wait for DB to be healthy +for i in $(seq 1 30); do + if docker exec crm_omt_db pg_isready -U postgres -d crm_omt >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# 3. Build frontend (no-op if dist/ already current; safe to re-run) +if [ ! -d dist ] || [ -n "$(find src -newer dist -type f -print -quit 2>/dev/null)" ]; then + echo "[prod] building frontend..." + npm run build +fi + +# 4. Start API in production mode (foreground; use a process manager for restart) +cd server +node src/index.js diff --git a/server/.env b/server/.env deleted file mode 100644 index e436ef2..0000000 --- a/server/.env +++ /dev/null @@ -1,6 +0,0 @@ -# Server config (copy to server/.env or set in your shell) -PORT=4000 -DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt -JWT_SECRET=change-me-to-a-long-random-string -JWT_EXPIRES_IN=12h -CORS_ORIGIN=http://localhost:5173,http://localhost:8080 diff --git a/server/.env.example b/server/.env.example index bb57fd9..784e7cf 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,9 +1,12 @@ -# Server config (copy to server/.env or set in your shell) +# Production API config (copy to server/.env) +NODE_ENV=production PORT=4000 -DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt -JWT_SECRET=change-me-to-a-long-random-string +BIND_HOST=127.0.0.1 +DATABASE_URL=postgres://postgres:replace-with-db-password@localhost:5432/crm_omt +JWT_SECRET=replace-with-a-long-random-string-at-least-32-chars JWT_EXPIRES_IN=12h -CORS_ORIGIN=http://localhost:5173,http://localhost:8080 -# Frontend clients on the same LAN can use the host machine's IP automatically. -# Add fixed origins here if you want to restrict access more tightly. +# Same-origin when SPA is served by this API. +# Keep localhost/127.0.0.1 unless you intentionally expose the service behind a reverse proxy. +CORS_ORIGIN=http://localhost:4000,http://127.0.0.1:4000 +ENABLE_LOCAL_TEST_ROUTES=0 diff --git a/server/src/index.js b/server/src/index.js index fa442f8..d9e49b2 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -4,17 +4,39 @@ import cors from 'cors'; import pkg from 'pg'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; const { Pool } = pkg; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const IS_PROD = process.env.NODE_ENV === 'production'; +const BIND_HOST = process.env.BIND_HOST || (IS_PROD ? '127.0.0.1' : '0.0.0.0'); const PORT = Number(process.env.PORT || 4000); const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt'; +if (IS_PROD) { + if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { + console.error('[server] refusing to start: JWT_SECRET missing or too short in production'); + process.exit(1); + } + if (/:(postgres|password|change[-_]?me)@/i.test(DATABASE_URL)) { + console.error('[server] refusing to start: DATABASE_URL still uses a default password'); + process.exit(1); + } +} const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h'; -const LOCAL_DEV_TOOLS_ENABLED = process.env.ENABLE_LOCAL_TEST_ROUTES === '1' - || (process.env.NODE_ENV !== 'production' && /localhost|127\.0\.0\.1/i.test(DATABASE_URL)); -const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5173,http://localhost:8080') +const LOCAL_DEV_TOOLS_ENABLED = !IS_PROD + && (process.env.ENABLE_LOCAL_TEST_ROUTES === '1' + || /localhost|127\.0\.0\.1/i.test(DATABASE_URL)); +const CORS_ORIGINS = (process.env.CORS_ORIGIN || (IS_PROD ? '' : 'http://localhost:5173,http://localhost:8080')) .split(',').map(s => s.trim()).filter(Boolean); +// Always allow same-origin requests when we're serving the SPA from this server. +const SAME_ORIGIN_HOSTS = ['localhost', '127.0.0.1']; +const STATIC_DIR = process.env.STATIC_DIR + || path.resolve(__dirname, '..', '..', 'dist'); +const SERVE_STATIC = IS_PROD && fs.existsSync(STATIC_DIR); function isPrivateIpv4(hostname) { return /^10\./.test(hostname) @@ -26,10 +48,15 @@ function isPrivateIpv4(hostname) { function isAllowedOrigin(origin) { if (!origin) return true; if (CORS_ORIGINS.includes(origin)) return true; - + // Same-origin: when the SPA is served from this server, the browser sends + // Origin: http://:. Allow that pair regardless of NODE_ENV. try { const url = new URL(origin); - return ['localhost', '127.0.0.1'].includes(url.hostname) || isPrivateIpv4(url.hostname); + if (SAME_ORIGIN_HOSTS.includes(url.hostname) && Number(url.port || (url.protocol === 'https:' ? 443 : 80)) === PORT) { + return true; + } + if (IS_PROD) return false; // in prod, only explicit list + same-origin + return SAME_ORIGIN_HOSTS.includes(url.hostname) || isPrivateIpv4(url.hostname); } catch { return false; } @@ -38,12 +65,45 @@ function isAllowedOrigin(origin) { const pool = new Pool({ connectionString: DATABASE_URL, max: 10 }); const app = express(); + +// Same-origin requests (where the browser's Origin host:port matches the +// request's own Host header) are always allowed. This makes the API + SPA +// combo work whether the cashier opens http://localhost:4000, +// http://127.0.0.1:4000, or http://thispc.local:4000. +function isSameOrigin(req) { + const origin = req.get('origin'); + if (!origin) return true; + try { + const o = new URL(origin); + const host = req.get('host') || ''; + return `${o.hostname}:${o.port || (o.protocol === 'https:' ? '443' : '80')}` === host + || o.host === host; + } catch { + return false; + } +} + app.use(cors({ origin(origin, callback) { - callback(isAllowedOrigin(origin) ? null : new Error('Not allowed by CORS'), isAllowedOrigin(origin)); + // Pass-through; per-request same-origin check happens below. + if (!origin) return callback(null, true); + if (isAllowedOrigin(origin)) return callback(null, true); + return callback(null, false); }, credentials: true, })); +// Belt + suspenders: if the cors() middleware rejected based on a stale +// allowlist but the request is actually same-origin, let it through. +app.use((req, res, next) => { + if (isSameOrigin(req)) { + const origin = req.get('origin'); + if (origin && !res.get('Access-Control-Allow-Origin')) { + res.set('Access-Control-Allow-Origin', origin); + res.set('Vary', 'Origin'); + } + } + next(); +}); app.use(express.json({ limit: '1mb' })); // ---- helpers --------------------------------------------------------- @@ -405,6 +465,17 @@ app.get('/health', async (_req, res) => { catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); -app.listen(PORT, () => { - console.log(`[server] listening on http://localhost:${PORT}`); +// ---- static SPA ------------------------------------------------------ +// In production we serve the React build from this same process so there +// is only one port to manage. CORS is not crossed when the SPA and API +// share an origin, which is the whole point on the shop's local box. +if (SERVE_STATIC) { + console.log(`[server] serving SPA from ${STATIC_DIR}`); + app.use(express.static(STATIC_DIR, { index: false, maxAge: '1h' })); + app.get(/^\/(?!auth|rpc|from|employees|employee_transactions|admin|health).*/, + (_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html'))); +} + +app.listen(PORT, BIND_HOST, () => { + console.log(`[server] listening on http://${BIND_HOST}:${PORT} (NODE_ENV=${process.env.NODE_ENV || 'development'})`); }); diff --git a/src/components/AdminDataEntryModal.tsx b/src/components/AdminDataEntryModal.tsx index bb31c2d..ddd0f01 100644 --- a/src/components/AdminDataEntryModal.tsx +++ b/src/components/AdminDataEntryModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -112,14 +112,17 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData return ( - + {isEmployee ? "Submit Transaction" : "Insert Employee Data"} + + Record collection and deposit amounts with the proper date and currency. + -
+
-
+
-
+
diff --git a/src/components/CashierTools.tsx b/src/components/CashierTools.tsx index 928397f..1e6271c 100644 --- a/src/components/CashierTools.tsx +++ b/src/components/CashierTools.tsx @@ -94,9 +94,9 @@ export const FxSwapDialog: React.FC<{ return ( !o && onClose()}> - + - Cash FX Swap + Cash FX Swap Convert till cash between USD and LBP at the posted rate. @@ -112,7 +112,7 @@ export const FxSwapDialog: React.FC<{
-
+
!o && onClose()}> - + - Manager Self-Deal Override + Manager Self-Deal Override Enter the manager / owner PIN to allow exactly one transfer where the cashier is sender or beneficiary. The override is diff --git a/src/components/TransactionEntry.tsx b/src/components/TransactionEntry.tsx index 8f53253..0759e7a 100644 --- a/src/components/TransactionEntry.tsx +++ b/src/components/TransactionEntry.tsx @@ -302,11 +302,18 @@ export const TransactionEntry: React.FC = ({ ? (data[0] as OpenShift | undefined) ?? null : ((data as OpenShift | null) ?? null); setShift(row); - if (row) setTillId(row.till_id); + if (row && row.till_id) setTillId(row.till_id); })(); return () => { cancelled = true; }; }, [isOpen, shopId]); + // Belt-and-suspenders: keep tillId pinned to the open shift's till. + useEffect(() => { + if (shift && shift.till_id && tillId !== shift.till_id) { + setTillId(shift.till_id); + } + }, [shift, tillId]); + const reset = () => { setLastRecorded(null); setServiceCode(""); @@ -629,7 +636,9 @@ export const TransactionEntry: React.FC = ({ }; const cat: ServiceCategory | undefined = service?.category; - const canStartTransaction = Boolean(shopId && tillId && shift); + // If an open shift is detected, the till is implicit (a shift is on a till). + // The till dropdown is informational in that case. + const canStartTransaction = Boolean(shopId && shift && (tillId || shift.till_id)); const serviceGuidance = service ? SERVICE_GUIDANCE[service.code] : null; const submitLabel = service ? ACTION_LABELS[service.code] ?? "Record transaction" : "Record transaction"; @@ -637,7 +646,7 @@ export const TransactionEntry: React.FC = ({ return ( { if (!open) handleClose(); }}> - + New transaction @@ -719,7 +728,7 @@ export const TransactionEntry: React.FC = ({ {/* Shop / Till / Open shift */} -
+
= ({ )}
-
+
= ({ {(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE") && (
Beneficiary payout
-
+
= ({ {(service?.code === "OMT_BILL" || service?.code === "EDL_BILL") && (
Bill
-
+
= ({ setExternalRef(e.target.value)} />
-
+
setBeneficiaryName(e.target.value)} /> @@ -1064,7 +1072,7 @@ export const TransactionEntry: React.FC = ({ {cat === "telecom_recharge" && (
Recharge
-
+
= ({ {service?.code === "GOODS_SALE" && (
Goods sale
-
+
= ({ setGoodsUnitCostUsd(e.target.value)} />
-
+
setSerialNumber(e.target.value)} /> @@ -1171,7 +1179,7 @@ export const TransactionEntry: React.FC = ({ {service?.code === "REPAIR" && (
Repair
-
+
= ({ setDeviceImei(e.target.value)} />
-
+
setIssueSummary(e.target.value)} /> diff --git a/src/lib/api.ts b/src/lib/api.ts index 18451f1..1c101ee 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,8 +7,9 @@ */ function resolveApiBase(): string { - const configuredBase = (import.meta.env.VITE_API_BASE as string | undefined)?.replace(/\/$/, ''); - if (configuredBase) return configuredBase; + const raw = import.meta.env.VITE_API_BASE as string | undefined; + // Explicit empty string means "same origin" (the API serves the SPA in prod). + if (raw !== undefined) return raw.replace(/\/$/, ''); if (typeof window === 'undefined') { return 'http://localhost:4000';