Posbridge/server.js

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}`);
});