Skip to content

padosoft/rust-ecr17-protocol

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

13 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ’³ 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

ecr17-protocol banner

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

ECR17 Control Panel β€” command grid & live log
Command grid & live log (PAN masked)
ECR17 Control Panel β€” dynamic parameters sheet
Dynamic parameters sheet (money β†’ cents)

πŸ“Š 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.

About

Rust package built with rust and tauri for the ECR17 protocol, allowing integration with POS terminals.

Topics

Resources

License

Stars

1 star

Watchers

1 watching

Forks

Contributors