ecr17-protocol 1.0.1

Italian ECR17 payment protocol (Nexi Group POS terminals) over LAN β€” pure-Rust protocol engine plus an async client and an optional tokio TCP transport.
Documentation

πŸ’³ ecr17-protocol

The Italian ECR17 payment protocol in pure Rust β€” drive Nexi Group POS terminals over LAN from any Rust app, plus a Tauri desktop control panel.

The most complete open-source ECR17 toolkit for Rust & the desktop.

rust-tests frontend-checks e2e crates.io docs.rs License: MIT Built with Rust + Tauri

🐘 Using PHP / Laravel? β†’ padosoft/laravel-ecr17 Β Β·Β  πŸ“± Using React Native / mobile? β†’ padosoft/react-native-ecr17-protocol β€” the same ECR17 protocol, ported to each ecosystem.


πŸ“š Table of contents

🧭 What is ECR17?

ECR17 is the Italian standard protocol β€” supported by Nexi Group terminals β€” that integrates an Electronic Cash Register (ECR) with an EFT-POS payment terminal over a local LAN connection. The cash register sends a request (payment, reversal, status…), the terminal talks to the acquiring host, and replies synchronously.

This crate speaks that protocol from Rust: a pure, #![forbid(unsafe_code)] protocol engine plus an async Ecr17Client and an optional tokio TCP transport β€” no C, no FFI, no bridge.

πŸ“š Official protocol reference (public): https://developer.nexigroup.com/traditionalpos/en-EU/docs/ β€” the authoritative source. Field positions, message codes and lrcMode may vary by terminal/firmware; always check against the official docs.

🎯 Why this exists

Integrating Italian POS terminals has long been needlessly painful. The ECR17 protocol is not publicly documented β€” the specifications are shared under NDA, mostly with established point-of-sale software vendors β€” so everyone else reverse-engineers it by trial and error across terminals and firmware versions. (The classic trap that blocks almost everyone: the LRC is computed over a base of 0x7F, not 0x00 β€” handled here, and configurable per terminal.)

A few community efforts exist for server-side languages, but there was nothing idiomatic for Rust or the desktop. To our knowledge this is the most complete open-source ECR17 toolkit for Rust: the full command set, response parsing, the ACK/NAK + retransmit orchestration, configurable LRC modes, and payment-safety β€” all tested, all async, all pure Rust.

The goal is simple: Rust and desktop developers should no longer struggle to talk to Italian POS terminals. No NDA hunting, no guesswork β€” just client.pay(&req).await.

🀝 Compatibility notes (lrcMode, field quirks per terminal/firmware) are welcome as issues, so we can build, together, the reference the ecosystem never had.

✨ Highlights

  • πŸ¦€ Pure-Rust protocol core β€” framing / LRC / orchestration, #![forbid(unsafe_code)], no FFI.
  • πŸ”„ Async, Future-based API β€” client.pay(&req).await, built on tokio.
  • 🧱 Full command set β€” payment, extended payment, reversal, pre-auth (request / incremental / closure), card verification, close session, totals, last result, ECR printing, reprint, VAS.
  • πŸ›‘οΈ Robust by design β€” fixed-width field validation, defensive response parsing, ACK/NAK handshake with retransmit and timeouts.
  • πŸ’° Money-safe β€” a financial command is never blindly re-sent after a reconnect (double-charge protection), locked by unit tests. Recover a lost response via send_last_result() (spec G).
  • πŸ“‘ Live events β€” progress messages, streamed receipt lines, connection state β€” via callbacks.
  • πŸ”Œ Transport-agnostic β€” I/O lives behind an async Transport trait; the real tokio TCP socket is one feature flag away, and the in-memory FakeTransport makes the whole stack testable with no hardware.
  • πŸ–₯️ Tauri control panel β€” a cross-platform desktop debug console that exercises every command and streams the behind-the-scenes log live.
  • βœ… Heavily tested β€” 108 Rust unit / flow / money-safety tests (LRC, codec, every builder, every parser, full session orchestration) plus 12 Vitest + 12 Playwright e2e for the UI, all green in CI.
  • πŸ€– Vibe-coding batteries included β€” ships first-class AI-agent context (AGENTS.md, CLAUDE.md, docs/LESSON.md, PROGRESS.md). See below.

πŸ“Έ Screenshots

The repo ships a Tauri Control Panel app that exercises every ECR17 command against a real terminal and streams the behind-the-scenes log (sent / progress / receipt / result / error) live β€” with card PANs masked.

πŸ“Š Feature status

Area Status
Packet framing + LRC (4 modes) βœ…
All request builders (P X p i c H U C T G E R K s S) βœ…
Response parsing (E/V/s/T/C/e/K, incl. DCC) βœ…
Session orchestration (ACK/NAK, retransmit, timeout, progress/receipt) βœ…
Async client API + events βœ…
Auto-reconnect, tokenization (U) flow, receipt streaming βœ…
tokio TCP transport (non-destructive liveness probe) βœ… (feature tokio-transport)
Tauri desktop control panel (Win / macOS / Linux) βœ… (CI-built installers)

πŸ“¦ Installation

# Protocol core only (bring your own transport):
cargo add ecr17-protocol

# With the real tokio TCP transport:
cargo add ecr17-protocol --features tokio-transport
cargo add tokio --features full

MSRV 1.85 (the TCP liveness probe uses std::task::Waker::noop).

πŸš€ Quick start

use std::time::Duration;
use ecr17_protocol::{Ecr17Client, Ecr17Config, LrcMode, PaymentRequest, ReversalRequest};
use ecr17_protocol::transport::tcp::TcpTransport; // needs feature "tokio-transport"

#[tokio::main]
async fn main() -> ecr17_protocol::Result<()> {
    let config = Ecr17Config {
        host: "192.168.1.50".into(),   // terminal IP on the LAN
        port: Some(10000),              // configured ECR port
        terminal_id: "12345678".into(),
        cash_register_id: "00000001".into(),
        lrc_mode: Some(LrcMode::Std),
        keep_alive: Some(true),
        auto_reconnect: Some(true),
        connection_timeout_ms: Some(5000),
        response_timeout_ms: Some(60000),
        ack_timeout_ms: Some(2000),
        receipt_drain_ms: None,
        retry_count: Some(3),
        retry_delay_ms: Some(200),
        debug: Some(false),
    };

    let transport = TcpTransport::new(
        config.host.clone(),
        config.port.unwrap_or(10000),
        Duration::from_secs(5),
    );
    let mut client = Ecr17Client::new(transport, config);

    client.connect().await?;

    let result = client.pay(&PaymentRequest {
        amount_cents: 650,               // €6.50
        cash_register_id: None,
        payment_type: None,
        card_already_present: None,
        receipt_text: None,
        tokenization: None,
    }).await?;

    if result.outcome == ecr17_protocol::TransactionOutcome::Ok {
        println!(
            "Approved: auth {:?} PAN {:?}",
            result.auth_code, result.pan, // both Option<String> β€” masked PAN
        );
    } else {
        eprintln!("Declined: {:?}", result.error_description);
    }

    // Reversal ("annullamento") of the last transaction (no STAN β†’ reverses the last):
    client.reverse(&ReversalRequest { cash_register_id: None, stan: None }).await.ok();

    let _status = client.status().await?;   // PosStatusResponse
    client.disconnect().await;
    Ok(())
}

Request structs are plain data β€” construct the fields you need and leave the Option ones None. The optional .. fields map straight onto the wire defaults.

πŸ’° Money-safety

This crate drives a terminal that charges real cards, so payment integrity is a first-class, tested invariant β€” not an afterthought:

  • A financial command is never blindly re-sent. pay, pay_extended, reverse, pre_auth, incremental_auth, pre_auth_closure are not replayed after a transport drop/reconnect (a blind retry can double-charge). The decision lives in one tiny, unit-locked place β€” should_retry_after_reconnect β€” which only ever allows retrying safe/idempotent ops (status, totals).
  • Recover a lost response, don't replay it. If the reply is lost mid-flight, call send_last_result() (the spec's G command) to fetch the real outcome.
  • Proactive drop detection. Nexi terminals close the TCP socket between transactions, so the transport does a non-destructive liveness probe (poll_peek) before sending β€” never writing bytes on the peer's protocol stream β€” so a financial command never even starts on a stale socket.
  • Reusable across reconnects. The session holds no sticky "disconnected" flag; it resets its per-transaction state, so a fresh transaction is never blocked by a previous drop.

These rules are guarded by regression tests (financial_command_not_replayed_on_drop, safe_command_retried_after_reconnect, recovers_and_succeeds_after_reconnect, …) that must stay green.

βš™οΈ Configuration

Ecr17Config: host (required), port?, terminal_id (required), cash_register_id (required), lrc_mode?, keep_alive?, auto_reconnect?, connection_timeout_ms?, response_timeout_ms?, ack_timeout_ms?, receipt_drain_ms?, retry_count?, retry_delay_ms?, debug?.

All fields serde-camelCase on the wire (cash_register_id ⇄ cashRegisterId), so the same config round-trips cleanly to the Tauri frontend / any JSON consumer.

πŸ“– API reference

Every command is async and performs a full request/response exchange. new / configuration are synchronous.

Method Command Returns
connect() / disconnect() / is_connected() β€” Result<()> / () / bool
status() s PosStatusResponse
pay(&req) / pay_extended(&req) P / X PaymentResult
reverse(&req) S ReversalResult
pre_auth(&req) / incremental_auth(&req) / pre_auth_closure(&req) p / i / c PreAuthResult / PaymentResult
verify_card(&req) H CardVerificationResult
close_session() / totals() C / T CloseSessionResult / TotalsResult
send_last_result() G PaymentResult
enable_ecr_printing(bool) / reprint(bool) E / R Result<()>
vas(&xml) K VasResult

Commands require an open connection (connect() first) and error on timeout / retransmission exhaustion / disconnect.

πŸ“‘ Events

client.set_on_progress(|e| println!("progress: {}", e.message));
client.set_on_receipt_line(|l| append_to_receipt(l.text));
client.set_on_connection_state_change(|s| println!("connection: {s:?}"));

Callbacks are Fn(..) + Send + Sync + 'static. They are invoked with no client lock held, so a callback may safely call back into the client.

🧾 Tokenization & receipts

// Tokenization: attach a contract to a payment / preAuth / verifyCard. The 'U'
// additional-data message is sent automatically (P -> ACK -> U -> ACK -> result).
use ecr17_protocol::{TokenizationRequest, TokenizationService};

let req = PaymentRequest {
    amount_cents: 1000,
    tokenization: Some(TokenizationRequest {
        service: TokenizationService::Recurring,
        contract_code: "1666354841608".into(),
    }),
    cash_register_id: None, payment_type: None,
    card_already_present: None, receipt_text: None,
};

// Receipts printed by the ECR: enable printing, set receipt_drain_ms in the config,
// and receive lines via the event.
client.enable_ecr_printing(true).await?;
client.set_on_receipt_line(|l| append_to_receipt(l.text));

πŸ” Protocol cheat-sheet

App frame: STX(0x02) Β· payload Β· ETX(0x03) Β· LRC. Progress: SOH(0x01) Β· 20 chars Β· EOT(0x04) (no LRC). Confirmation: ACK(0x06) / NAK(0x15) Β· ETX Β· LRC. LRC = 0x7F XOR-folded; the framing bytes folded in are selectable via lrc_mode (stx / std / noext / stx_noext). Status code is lowercase s; a P payment request is 167 bytes; receipts arrive as one or more S messages. Outcome map: 00β†’ok, 01β†’ko, 05β†’cardNotPresent, 09β†’unknownTag.

πŸ—οΈ Architecture

crates/ecr17-protocol/src/
β”œβ”€β”€ lrc.rs        # LRC (4 modes, base 0x7F) + LrcMode
β”œβ”€β”€ codec.rs      # framing: STXΒ·ETXΒ·SOHΒ·EOTΒ·ACKΒ·NAK + LRC (encode/decode)
β”œβ”€β”€ types.rs      # request/result/enum data model (serde, camelCase)
β”œβ”€β”€ protocol.rs   # request builders (all commands), fixed-width + validated
β”œβ”€β”€ response.rs   # response field parsers -> plain structs (incl. DCC, PAN mask)
β”œβ”€β”€ retry.rs      # πŸ’° RetryPolicy β€” a financial command is never replayed
β”œβ”€β”€ transport.rs  # async Transport trait + in-memory FakeTransport (tests)
β”œβ”€β”€ session.rs    # ACK/NAK + retransmit + timeout + receipt-drain orchestration
β”œβ”€β”€ client.rs     # Ecr17Client async API + events + auto/proactive reconnect
└── transport/tcp.rs   # tokio TCP transport (feature "tokio-transport")
app/
β”œβ”€β”€ src-tauri/    # Rust backend: managed Ecr17Client, one #[tauri::command] per cmd, events
└── src/          # React 19 + TypeScript + Vite control panel (typed IPC, useEcr17 hook)

Design. The codec / protocol / response layers are pure and sync β€” trivially unit-testable with no runtime. I/O lives behind the async Transport trait, so the session/client run against a scripted FakeTransport in tests (including simulated mid-exchange drops) and a real tokio socket in production.

πŸ–₯️ The Tauri control panel

app/ is a cross-platform Tauri 2 desktop app (React 19 + TypeScript + Vite) that holds an Ecr17Client in managed state and exposes one #[tauri::command] per protocol command. It’s the fastest way to poke a terminal: pick a command, fill the dynamically-generated parameters sheet (money fields coerce € β†’ cents), hit run, and watch the sent/progress/receipt/result log stream live β€” PANs masked. Native installers (Windows .msi/NSIS, macOS .dmg, Linux .deb/AppImage) are built in CI and attached to each GitHub Release.

πŸ§ͺ Testing

# Exactly what CI (rust-tests) runs:
cargo test --workspace --all-features            # 108 unit / flow / money-safety tests
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all -- --check

# Frontend (from app/):
bun run typecheck && bun run test  # 12 Vitest
bun run e2e                        # 12 Playwright (Tauri IPC mocked)

The Rust tests cover LRC, packet (de)framing edge cases, every builder's byte layout, every response parser, and the documented payment / reversal / re-pay / progress / receipt / NAK-retransmit / timeout / reconnect-recovery flows (against an in-memory FakeTransport).

Against a real terminal (opt-in)

An #[ignore]d integration test runs the core over a real TCP socket. It is skipped unless ECR17_TEST_HOST is set:

ECR17_TEST_HOST=192.168.1.50 ECR17_TEST_PORT=10000 \
  cargo test --features tokio-transport -- --ignored real_terminal

🧩 Other ports

The same ECR17 protocol, maintained across ecosystems:

Port Repo Stack
Rust / Tauri (this) padosoft/rust-ecr17-protocol Rust core + tokio + Tauri desktop
React Native padosoft/react-native-ecr17-protocol C++ core + Nitro (iOS/Android)
Laravel padosoft/laravel-ecr17 PHP / Laravel package + console

πŸ€– Vibe-coding batteries included

Building on an undocumented payment protocol is exactly where AI assistants get things subtly wrong. This repo ships the context to prevent that, so an agent (or a new contributor) is productive and safe from minute one:

  • AGENTS.md / CLAUDE.md β€” project guide, the mandatory per-task workflow, CI strategy, and the money-critical rules (e.g. never blindly retry a payment).
  • docs/LESSON.md β€” accumulated, verified engineering lessons (Rust/Tauri APIs, toolchain traps, protocol facts, money-safety) β€” the gotchas already solved.
  • PROGRESS.md β€” crash-safe resume state across sessions.

The result: less hallucination, fewer footguns, and changes that respect the payment-safety invariants by default.

πŸ“„ License

MIT Β© padosoft

Disclaimer: independent integration library. "ECR17", "Nexi" and related marks belong to their respective owners and are referenced for interoperability only.