/** * Hamekara POS Bridge (PEC_PCPOS file-drop) * ----------------------------------------- * Exposes HTTP endpoints your app can call to trigger POS operations. * It writes a request .txt into C:\Users\Public\PEC_PCPOS\request * and waits for a response .txt in C:\Users\Public\PEC_PCPOS\response, * which are handled by the Parsian Windows service (PEC_PCPOS). * * Endpoints: * POST /pos/sale -> { amount, invoice? } * POST /pos/cancel -> { invoice? , traceNo? } // only if your PEC supports it * GET /pos/health -> service folders + POS config status * * Environment Variables (optional): * PORT=9090 * POS_IP=192.168.100.90 * POS_PORT=2020 * PEC_REQ_DIR=C:\Users\Public\PEC_PCPOS\request * PEC_RES_DIR=C:\Users\Public\PEC_PCPOS\response * PEC_CANCEL_OP=CANCEL // or ABORT or whatever your build requires * SALE_TIMEOUT_MS=90000 * CANCEL_TIMEOUT_MS=30000 *8696814046000 * * Dependencies: express, cors * npm install express cors */ const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); // ----------------------------- // Configuration // ----------------------------- const PORT = parseInt(process.env.PORT || '9090', 10); // POS network const POS_IP = process.env.POS_IP || '192.168.100.90'; const POS_PORT = parseInt(process.env.POS_PORT || '2020', 10); // PEC file-drop directories const REQ_DIR = process.env.PEC_REQ_DIR || 'C:\\Users\\Public\\PEC_PCPOS\\request'; const RES_DIR = process.env.PEC_RES_DIR || 'C:\\Users\\Public\\PEC_PCPOS\\response'; // Ops / timeouts const SALE_TIMEOUT_MS = parseInt(process.env.SALE_TIMEOUT_MS || '90000', 10); const CANCEL_TIMEOUT_MS = parseInt(process.env.CANCEL_TIMEOUT_MS || '30000', 10); const CANCEL_OP = (process.env.PEC_CANCEL_OP || 'CANCEL').trim().toUpperCase(); // basic polling cadence (ms) const POLL_MS = 100; const FS_STABILIZE_MS = 50; // ----------------------------- // Utilities // ----------------------------- // Ensure directories exist (creates if missing) for (const d of [REQ_DIR, RES_DIR]) { try { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); } catch (e) { // If these are truly managed by the PEC installer, creation might fail on locked systems; it's ok. } } // Simple process-wide mutex to serialize device access let busy = false; function lock() { return new Promise(resolve => { const tryEnter = () => (!busy ? ((busy = true), resolve()) : setTimeout(tryEnter, 40)); tryEnter(); }); } function unlock() { busy = false; } // Atomic write helper (write .part then rename) function writeAtomic(filePath, content) { const tmp = filePath + '.part'; fs.writeFileSync(tmp, content, 'utf8'); fs.renameSync(tmp, filePath); } // Parse key=value text file into object function parseKV(text) { const out = {}; String(text || '') .split(/\r?\n/) .forEach(line => { const i = line.indexOf('='); if (i > 0) { const k = line.slice(0, i).trim(); const v = line.slice(i + 1).trim(); if (k) out[k] = v; } }); return out; } // Generate a unique safe tag for paired req/resp files function makeTag(prefix) { const stamp = new Date().toISOString().replace(/[:]/g, '-'); const rnd = Math.floor(1000 + Math.random() * 9000); return `${prefix}_${stamp}_${rnd}`; } // Wait until a specific file appears or timeout async function waitForFile(filePath, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (fs.existsSync(filePath)) { // tiny delay to let PEC finish writing file fully await new Promise(r => setTimeout(r, FS_STABILIZE_MS)); try { return fs.readFileSync(filePath, 'utf8'); } catch (_) { // in case of transient file-lock, brief wait then retry } } await new Promise(r => setTimeout(r, POLL_MS)); } throw new Error('POS timeout'); } // Clean a few old files with same tag (safety; not strictly required) function safeCleanup(filePath) { try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch (_) {} try { if (fs.existsSync(filePath + '.part')) fs.unlinkSync(filePath + '.part'); } catch (_) {} } // ----------------------------- // Core POS Ops (via PEC file-drop) // ----------------------------- async function doSale(amountRial, invoice, timeoutMs = SALE_TIMEOUT_MS) { if (!fs.existsSync(REQ_DIR) || !fs.existsSync(RES_DIR)) { throw new Error('POS folders not found. Ensure PEC_PCPOS Windows component is installed and running.'); } const tag = makeTag('SALE'); const reqFile = path.join(REQ_DIR, `${tag}.txt`); const resFile = path.join(RES_DIR, `${tag}.txt`); // Build request payload (keys expected by your PEC build) const payloadLines = [ 'TYPE=LAN', `IP=${POS_IP}`, `PORT=${POS_PORT}`, 'OP=SALE', `AMOUNT=${amountRial}`, `INVOICE=${invoice}` ]; const payload = payloadLines.join('\r\n') + '\r\n'; // Clean any leftovers and write request safeCleanup(reqFile); safeCleanup(resFile); writeAtomic(reqFile, payload); // Wait for matching response const raw = await waitForFile(resFile, timeoutMs); const kv = parseKV(raw); // Normalize common fields const code = String(kv.ResponseCode || kv.CODE || kv.Code || ''); return { ok: code === '0', code, message: kv.Message || kv.Msg || '', amount: Number(amountRial), invoice, rrn: kv.RRN || kv.RefNo || '', stan: kv.STAN || '', traceNo: kv.TraceNo || kv.TRACE || '', cardMasked: kv.PAN || kv.CardNo || '', authCode: kv.AuthCode || kv.Auth || '', raw }; } // Optional cancel/abort operation (only if your PEC build supports it) // Many builds require TRACE or INVOICE; OP name may differ (CANCEL/ABORT/VOID) async function doCancel({ invoice, traceNo, timeoutMs = CANCEL_TIMEOUT_MS, op = CANCEL_OP }) { if (!fs.existsSync(REQ_DIR) || !fs.existsSync(RES_DIR)) { throw new Error('POS folders not found. Ensure PEC_PCPOS Windows component is installed and running.'); } const tag = makeTag('CANCEL'); const reqFile = path.join(REQ_DIR, `${tag}.txt`); const resFile = path.join(RES_DIR, `${tag}.txt`); const lines = [ 'TYPE=LAN', `IP=${POS_IP}`, `PORT=${POS_PORT}`, `OP=${op}`, ]; if (invoice) lines.push(`INVOICE=${String(invoice).slice(0, 40)}`); if (traceNo) lines.push(`TRACE=${String(traceNo).slice(0, 40)}`); const payload = lines.join('\r\n') + '\r\n'; safeCleanup(reqFile); safeCleanup(resFile); writeAtomic(reqFile, payload); const raw = await waitForFile(resFile, timeoutMs); const kv = parseKV(raw); const code = String(kv.ResponseCode || kv.CODE || kv.Code || ''); return { ok: code === '0', code, message: kv.Message || kv.Msg || '', invoice: invoice || '', traceNo: traceNo || '', raw, kv }; } // ----------------------------- // HTTP Server // ----------------------------- const app = express(); app.use(express.json({ limit: '64kb' })); app.use(cors({ origin: [ 'https://hamekara.com', 'https://admin.hamekara.com', 'http://localhost', 'http://127.0.0.1' ], credentials: false })); // Health check (folders + current POS config) app.get('/pos/health', (req, res) => { const okReq = fs.existsSync(REQ_DIR); const okRes = fs.existsSync(RES_DIR); res.json({ ok: okReq && okRes, reqDir: { path: REQ_DIR, exists: okReq }, resDir: { path: RES_DIR, exists: okRes }, pos: { ip: POS_IP, port: POS_PORT }, timeouts: { saleMs: SALE_TIMEOUT_MS, cancelMs: CANCEL_TIMEOUT_MS }, cancelOp: CANCEL_OP }); }); // SALE endpoint app.post('/pos/sale', async (req, res) => { await lock(); const startedAt = Date.now(); try { const rawAmount = String(req.body.amount ?? '').trim(); const amount = rawAmount.replace(/\D/g, ''); // keep digits only const invoice = String(req.body.invoice || `INV-${Date.now()}`).slice(0, 40); if (!amount || amount === '0') { return res.status(422).json({ ok: false, code: 'INVALID', message: 'Invalid amount' }); } console.log(`[SALE] amount=${amount} invoice=${invoice} -> ${POS_IP}:${POS_PORT}`); const result = await doSale(amount, invoice); const ms = Date.now() - startedAt; console.log(`[SALE] done in ${ms}ms ok=${result.ok} code=${result.code} rrn=${result.rrn || '-'} trace=${result.traceNo || '-'}`); res.json(result); } catch (e) { const msg = e && e.message ? e.message : String(e); console.error(`[SALE] ERROR: ${msg}`); res.status(/timeout/i.test(msg) ? 504 : 500).json({ ok: false, code: 'ERR', message: msg }); } finally { unlock(); } }); // CANCEL endpoint (works only if PEC build supports OP name + fields) app.post('/pos/cancel', async (req, res) => { await lock(); const startedAt = Date.now(); try { const invoice = req.body.invoice ? String(req.body.invoice).slice(0, 40) : ''; const traceNo = req.body.traceNo ? String(req.body.traceNo).slice(0, 40) : ''; if (!invoice && !traceNo) { return res.status(422).json({ ok: false, code: 'INVALID', message: 'invoice or traceNo required' }); } console.log(`[CANCEL] op=${CANCEL_OP} invoice=${invoice || '-'} trace=${traceNo || '-'} -> ${POS_IP}:${POS_PORT}`); const out = await doCancel({ invoice, traceNo }); const ms = Date.now() - startedAt; console.log(`[CANCEL] done in ${ms}ms ok=${out.ok} code=${out.code}`); res.json(out); } catch (e) { const msg = e && e.message ? e.message : String(e); console.error(`[CANCEL] ERROR: ${msg}`); res.status(/timeout/i.test(msg) ? 504 : 500).json({ ok: false, code: 'ERR', message: msg }); } finally { unlock(); } }); // Root (tiny help) app.get('/', (req, res) => { res.type('text/plain').send( `Hamekara POS Bridge is running. POST /pos/sale { "amount": 120000, "invoice": "INV-123" } POST /pos/cancel { "invoice": "INV-123" } or { "traceNo": "000123" } // if supported GET /pos/health Config: PORT=${PORT} POS_IP=${POS_IP} POS_PORT=${POS_PORT} REQ_DIR=${REQ_DIR} RES_DIR=${RES_DIR} SALE_TIMEOUT_MS=${SALE_TIMEOUT_MS} CANCEL_TIMEOUT_MS=${CANCEL_TIMEOUT_MS} PEC_CANCEL_OP=${CANCEL_OP} `); }); // ----------------------------- // Start server // ----------------------------- app.listen(PORT, () => { console.log(`POS bridge on http://localhost:${PORT}`); console.log(`Using POS ${POS_IP}:${POS_PORT}`); console.log(`ReqDir: ${REQ_DIR}`); console.log(`ResDir: ${RES_DIR}`); });