saman-payment-pos
v1.0.1
Published
TypeScript SDK for the SSP1126 (Saman Electronic Payment) POS terminal — ISO-8583 over TCP/Serial
Maintainers
Readme
SSP1126 (Saman Electronic Payment)
A clean, dependency-free TypeScript SDK for the SSP1126 POS terminal (Saman Electronic Payment). It speaks the terminal's native ISO-8583:1987 over TCP protocol, reverse-engineered from the legacy Delphi component.
Installation
npm install --save saman-payment-posQuick start
import { Ssp1126Client } from 'saman-payment-pos';
// TCP (default channel)
const pos = new Ssp1126Client({ host: '192.168.14.105', port: 1197 });
const conn = await pos.connectionTest();
if (conn.ok) {
// No purchaseId — that would request an *identified purchase*; see below.
const res = await pos.purchase({ mainAmount: 10000, referenceData: 'ORDER-1001' });
console.log(res.ok, res.responseCode, res.responseMessage, res.rrn);
}
pos.close();Choosing a transport
The same client works over TCP or Serial — pick the channel in the constructor options.
// TCP: { host, port? }
const tcp = new Ssp1126Client({ transport: 'tcp', host: '192.168.14.105', port: 1197 });
// `transport` may be omitted; TCP is the default.
// Serial: { path, baudRate? } — 8 data bits, 1 stop bit, no parity (fixed)
const serial = new Ssp1126Client({ transport: 'serial', path: '/dev/ttyUSB0', baudRate: 19200 });
// Windows: path: 'COM3'Serial uses the optional serialport package; install
it only if you use the serial channel (npm install serialport). TCP has zero runtime
dependencies. Both channels frame, buffer partial reads, time out and surface errors
identically (shared FrameTransport base), so they are fully interchangeable.
You can also build a transport directly:
import { createTransport, TcpTransport, SerialTransport } from 'saman-payment-pos';
const t1 = createTransport({ host: '192.168.14.105' }); // TcpTransport
const t2 = createTransport({ transport: 'serial', path: 'COM3' }); // SerialTransportAPI
Construct with new Ssp1126Client(options):
| option | default | meaning |
| ------------------ | ----------- | -------------------------------------------------------- |
| transport | "tcp" | channel: "tcp" or "serial" |
| host | — | terminal IP (TCP) |
| port | 1197 | terminal TCP port (TCP) |
| path | — | serial device, e.g. "COM3" / "/dev/ttyUSB0" (serial) |
| baudRate | 19200 | serial baud rate (serial) |
| connectTimeoutMs | 10000 | connect / open timeout |
| currency | "364" | DE49 currency (IRR) |
| componentVersion | "1.4.3.0" | DE57 for purchases |
| verifyMac | true | verify DE64 MAC on responses |
| logger | — | (line) => void raw frame / step trace |
| minReconnectGapMs| 500 | reconnect gap after a transaction that ended cleanly (final 17) |
| reconnectGapAfterPartialMs | 1500 | reconnect gap after one that ended without a final 17 |
| firstAckTimeoutMs| 3000 | timeout for the opening 15 acknowledgement only |
| retryOnFirstTimeout | true | retry once if the opening request of a transaction times out on a fresh connection |
| retryDelayMs | 500 | delay before the retry above |
Each method runs a full transaction (connect → multi-step dialog → dispose → close) and
resolves to a TransactionResult (or a typed result for reports/ops):
| method | purpose |
| ------------------------------------------------------- | ------------------------------------------------ |
| connectionTest() | reachability / handshake |
| getAuthorizedOperations() | which operations the terminal is provisioned for |
| balance() | card balance inquiry |
| purchase({ mainAmount, … }) | PC-initiated purchase (amount known up front) |
| posStarterPurchaseInit() | step 1 of a POS-initiated purchase: read the card, list routing options |
| posStarterPurchaseFin({ mainAmount, segment?, … }) | step 2: complete a purchase started with posStarterPurchaseInit() |
| posStarterPurchaseCancel() | abort a posStarterPurchaseInit() you're not completing |
| billPayment({ billId, paymentId, … }) | pay a bill |
| billRequest() | read a bill from the inserted card |
| pinCharge({ … }) | buy a PIN charge voucher |
| topupCharge({ mobileNumber, … }) | direct mobile top-up |
| mciBillInquiry({ mciNumber, billType }) | MCI (Hamrah) bill inquiry |
| tciBillInquiry({ tciNumber, billType }) | TCI (telecom) bill inquiry |
| totalReport({ fromDate, toDate, posPin? }) | aggregate totals |
| report({ filter, filterValues, reportType, posPin? }) | detailed rows |
Bill identifier helpers (exported separately, no terminal needed):
validateBill(billId, paymentId), billAmountRials(paymentId), billCategory(billId).
TransactionResult fields: ok, responseCode, responseMessage, terminalId,
traceNumber, serialId, rrn, amount, effectiveAmount, transactionDate,
cardMask, cardHash1, cardHash2, charge voucher fields, plus fields (every raw
DE harvested), timedOut (true if a step never got a response at all, as opposed to
a decline — see "Connection behavior" below).
purchaseId / DE63 — the usual cause of 07
Do not set purchaseId for an ordinary sale. It populates DE63, which asks the
terminal for an identified purchase (خرید شناسهدار) — a distinct transaction type
the vendor added in v1.0.1 of the web service, provisioned separately from ordinary
purchase. A merchant without that entitlement answers responseCode: '07' ("No
permission for this operation"), immediately and before it even prompts for a card.
Because DE63 has no bit of its own in DE48, getAuthorizedOperations() cannot warn
you: a terminal can report every flag true and still refuse every DE63-bearing
purchase. Verified end-to-end against a live terminal — four purchase variants
carrying a purchaseId all returned 07; the same terminal completed a normal sale
(00) the moment DE63 was omitted.
If you want your own order number on the transaction, use referenceData (DE56) or
additionalData (DE48):
await client.purchase({
mainAmount: 10000,
referenceData: 'ORDER-1234', // your reference — NOT purchaseId
});Reserve purchaseId for the case where you actually mean an identified purchase and
know the merchant is entitled to it.
PC-started vs. POS-started purchase
Both flows exist and are independently provisioned, so
getAuthorizedOperations().purchase is only a hint — let DE39 on the real request be
the authority. If you get 07, check purchaseId first (above); that is far more
often the cause than a genuinely disabled flow.
purchase()— PC-started. The PC sends the amount immediately; the customer taps/inserts their card only after that, within the same request's financial step.posStarterPurchaseInit()+posStarterPurchaseFin()— POS-started. The customer taps/inserts their card first (Init), the terminal reports back which payment routing options that card supports (usually just one), and only then does the PC send the amount to complete the sale (Fin). This mirrors a point-of-sale flow where the cashier doesn't know the amount will be finalized until the card is already presented.
const init = await pos.posStarterPurchaseInit(); // prompts customer to tap/insert card
if (init.ok) {
// init.segments is usually a single { code, label } pair; if there's more than
// one, let the customer/cashier pick and pass its `code` as `segment` below.
const segment = init.segments.length > 1 ? init.segments[0].code : undefined;
const fin = await pos.posStarterPurchaseFin({ mainAmount: 10000, segment });
console.log(fin.ok, fin.responseCode, fin.rrn);
} else {
console.log('card read failed:', init.responseCode, init.responseMessage);
}posStarterPurchaseInit() deliberately leaves the connection open on success
(mirroring the reference component) so posStarterPurchaseFin() can complete the
same transaction on it — don't call any other method on this client in between, or
call posStarterPurchaseCancel() first. If Init succeeds but you decide not to
complete the sale (customer backs out, UI abandoned, timeout in your own app),
call posStarterPurchaseCancel() to dispose and close cleanly rather than leaving
the connection open indefinitely.
Wire framing
The framing is asymmetric, which is the easiest thing to get wrong:
PC → POS: [ISO-8583 message] (raw, nothing added)
POS → PC : [2-byte big-endian length][5-byte header 60 00 00 00 00][ISO-8583 message]On responses the length counts the header + ISO bytes. The SDK strips that envelope
on receive and adds nothing on send; you always work with the bare
Iso8583Message.
Both reference implementations agree: the Delphi SendCommand transmits the
MACPack output verbatim — every capture in SSP1126 PC to POS 2007/log/*.txt
logs Send Command With Result : N with N equal to the message length exactly —
and the C# SSP1126.PcPos.dll (NetworkChannel) makes sendMessageHeader a no-op,
never sends a length, and sets setHeader(new byte[0]), while its read path skips
exactly 7 bytes.
Prefixing a request with the response envelope shifts every field by 7 bytes. The
terminal then can't parse the request and answers immediately with a well-formed
rejection — DE39=98 ("Operation cancelled by user"), DE3=000000 — which looks
deceptively like the customer pressed Cancel.
Connection behavior
Every transaction opens a fresh TCP connection, per the reference component. The terminal is not ready to serve a new connection immediately after the previous one closes: it completes the TCP handshake but never answers the first request.
How long it needs depends on how the previous transaction ended. A transaction
that received the terminal's final 17 recovers quickly; one that stopped short
(getAuthorizedOperations(), totalReport(), report(), or anything that failed)
needs substantially longer. The SDK tracks this and picks the gap automatically:
minReconnectGapMs(default 500) — after a clean17. Measured on firmware10.063.00PO: 0 ms and 250 ms stalled, 500 ms and above did not.reconnectGapAfterPartialMs(default 1500) — after a transaction with no final17. Measured by runninggetAuthorizedOperations()then a connection test: 500 ms and 1000 ms both stalled (~12–13 s end to end), 1500 ms completed in ~3 s.
Two more settings bound the cost when it still goes wrong:
firstAckTimeoutMs(default 3000) — the opening15arrives in 3–5 ms when it is coming at all, so waiting 10 s only delays discovering the terminal is not listening. This covers the15only: the terminal sends it immediately and then waits for a card, so a short value here does not shorten the customer's card window.retryOnFirstTimeout(defaulttrue) — safety net. If the opening response still times out on a freshly-opened connection, the SDK closes, waitsretryDelayMs(default 500 ms), reconnects and resends the opening request exactly once.
A timeout on an already-warm connection, or any response after the first, is never
retried — that's a real failure. Check result.timedOut to tell a timeout apart
from a decline.
close() hard-closes the socket once the final Dispose is flushed. A graceful
half-close is not enough: the terminal never sends FIN back, so the connection would
park in FIN-WAIT-2 and the open handle would keep Node's event loop alive — a
script that had finished its work would never exit.
Low-level building blocks (also exported)
Iso8583Message—setStr/setBin/getStr/getBin,packWithMac(),Iso8583Message.unpack()computeMac(body)/verifyMac(rawMessage)— the DE64 DES CBC-MACTcpTransport/SerialTransport— framing-aware channels behind theTransportinterface (connect,send,receive,clearInbox,close);createTransport(config)builds eitherdesCbcEncryptis internal; usecomputeMacfor the MAC
Notes & limitations
- One transaction per connection. The client opens, runs, sends Dispose, and closes — exactly like the reference component. Don't hold a client across transactions; just call the next method.
- Both TCP and Serial transports ship behind one
Transportabstraction and are selected by thetransportoption. Serial isCOMx//dev/tty*, 8-N-1, default baud 19200 (seePROTOCOL.md§1) and uses the optionalserialportpackage. - The DE64 MAC uses a fixed shared key that shipped in the original source. It is an integrity check, not encryption. Operate the link on a trusted network.
- Response-code text is provided in English (the original Persian strings were lost to a code-page conversion in the source dump).
