saman-payment-pos
    Preparing search index...

    saman-payment-pos

    SSP1126 (Saman Electronic Payment)

    npm npm GitHub documentation Build, Test and Publish

    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.

    npm install --save saman-payment-pos
    
    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();

    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' }); // SerialTransport

    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).

    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.

    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.

    The framing is asymmetric, which is the easiest thing to get wrong:

    PCPOS:  [ISO-8583 message]                                        (raw, nothing added)
    POSPC : [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.

    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 clean 17. Measured on firmware 10.063.00PO: 0 ms and 250 ms stalled, 500 ms and above did not.
    • reconnectGapAfterPartialMs (default 1500) — after a transaction with no final 17. Measured by running getAuthorizedOperations() 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 opening 15 arrives in 3–5 ms when it is coming at all, so waiting 10 s only delays discovering the terminal is not listening. This covers the 15 only: 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 (default true) — safety net. If the opening response still times out on a freshly-opened connection, the SDK closes, waits retryDelayMs (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.

    • Iso8583MessagesetStr/setBin/getStr/getBin, packWithMac(), Iso8583Message.unpack()
    • computeMac(body) / verifyMac(rawMessage) — the DE64 DES CBC-MAC
    • TcpTransport / SerialTransport — framing-aware channels behind the Transport interface (connect, send, receive, clearInbox, close); createTransport(config) builds either
    • desCbcEncrypt is internal; use computeMac for the MAC
    • 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 Transport abstraction and are selected by the transport option. Serial is COMx//dev/tty*, 8-N-1, default baud 19200 (see PROTOCOL.md §1) and uses the optional serialport package.
    • 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).