Prepare shop-ready deployment docs and fix transaction modal UI

This commit is contained in:
Krikorios
2026-05-20 19:47:55 +03:00
parent 1896cbdd11
commit 1f23102050
16 changed files with 584 additions and 60 deletions
+19
View File
@@ -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))"
+90
View File
@@ -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="'<uuid>'"
-- =====================================================================
\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 ===
+192
View File
@@ -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/<admin diagnostic>... 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);
});
+25
View File
@@ -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