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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
<div align="center">

# πŸ’³ 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](https://github.com/padosoft/rust-ecr17-protocol/actions/workflows/rust-tests.yml/badge.svg?branch=main)](https://github.com/padosoft/rust-ecr17-protocol/actions/workflows/rust-tests.yml)
[![frontend-checks](https://github.com/padosoft/rust-ecr17-protocol/actions/workflows/frontend-checks.yml/badge.svg?branch=main)](https://github.com/padosoft/rust-ecr17-protocol/actions/workflows/frontend-checks.yml)
[![e2e](https://github.com/padosoft/rust-ecr17-protocol/actions/workflows/e2e.yml/badge.svg?branch=main)](https://github.com/padosoft/rust-ecr17-protocol/actions/workflows/e2e.yml)
[![crates.io](https://img.shields.io/crates/v/ecr17-protocol?style=flat-square&logo=rust)](https://crates.io/crates/ecr17-protocol)
[![docs.rs](https://img.shields.io/docsrs/ecr17-protocol?style=flat-square&logo=docsdotrs)](https://docs.rs/ecr17-protocol)
[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](https://github.com/padosoft/rust-ecr17-protocol/blob/main/LICENSE)
[![Built with Rust + Tauri](https://img.shields.io/badge/built%20with-Rust%20%2B%20Tauri-8B5CF6?style=flat-square&logo=tauri)](https://tauri.app)

<img src="https://raw.githubusercontent.com/padosoft/rust-ecr17-protocol/main/resources/banner.png" alt="ecr17-protocol banner" width="100%" />

</div>

> 🐘 **Using PHP / Laravel?** β†’ **[padosoft/laravel-ecr17]https://github.com/padosoft/laravel-ecr17**
> &nbsp;Β·&nbsp; πŸ“± **Using React Native / mobile?** β†’ **[padosoft/react-native-ecr17-protocol]https://github.com/padosoft/react-native-ecr17-protocol**
> β€” the same ECR17 protocol, ported to each ecosystem.

---

## πŸ“š Table of contents

- [What is ECR17?]#-what-is-ecr17
- [Why this exists]#-why-this-exists
- [Highlights]#-highlights
- [Screenshots]#-screenshots
- [Feature status]#-feature-status
- [Installation]#-installation
- [Quick start]#-quick-start
- [Money-safety]#-money-safety
- [Configuration]#%EF%B8%8F-configuration
- [API reference]#-api-reference
- [Events]#-events
- [Tokenization & receipts]#-tokenization--receipts
- [Protocol cheat-sheet]#-protocol-cheat-sheet
- [Architecture]#%EF%B8%8F-architecture
- [The Tauri control panel]#%EF%B8%8F-the-tauri-control-panel
- [Testing]#-testing
- [Other ports]#-other-ports
- [Vibe-coding batteries included]#-vibe-coding-batteries-included
- [License]#-license

## 🧭 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]#-vibe-coding-batteries-included.

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

<table>
  <tr>
    <td align="center" width="62%">
      <img src="https://raw.githubusercontent.com/padosoft/rust-ecr17-protocol/main/resources/screenshots/control-panel.png" alt="ECR17 Control Panel β€” command grid & live log" width="100%" /><br/>
      <sub>Command grid &amp; live log (PAN masked)</sub>
    </td>
    <td align="center" width="38%">
      <img src="https://raw.githubusercontent.com/padosoft/rust-ecr17-protocol/main/resources/screenshots/params-sheet.png" alt="ECR17 Control Panel β€” dynamic parameters sheet" width="100%" /><br/>
      <sub>Dynamic parameters sheet (money β†’ cents)</sub>
    </td>
  </tr>
</table>

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

```bash
# 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

```rust
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

```rust
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

```rust
// 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

```bash
# 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:

```bash
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]https://github.com/padosoft/rust-ecr17-protocol | Rust core + tokio + Tauri desktop |
| **React Native** | [padosoft/react-native-ecr17-protocol]https://github.com/padosoft/react-native-ecr17-protocol | C++ core + Nitro (iOS/Android) |
| **Laravel** | [padosoft/laravel-ecr17]https://github.com/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`]https://github.com/padosoft/rust-ecr17-protocol/blob/main/AGENTS.md** /
  **[`CLAUDE.md`]https://github.com/padosoft/rust-ecr17-protocol/blob/main/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`]https://github.com/padosoft/rust-ecr17-protocol/blob/main/docs/LESSON.md** β€” accumulated, verified engineering lessons
  (Rust/Tauri APIs, toolchain traps, protocol facts, money-safety) β€” the gotchas
  already solved.
- **[`PROGRESS.md`]https://github.com/padosoft/rust-ecr17-protocol/blob/main/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](https://github.com/padosoft/rust-ecr17-protocol/blob/main/LICENSE) Β© [padosoft](https://github.com/padosoft)

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