334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
/**
|
|
* 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}`);
|
|
});
|