Skip to main content

ecr17_protocol/
protocol.rs

1//! ECR17 application-message builders — the bytes that go between `STX` and `ETX`.
2//!
3//! Every field is fixed-width and validated: a value that overflows its field returns
4//! [`Ecr17Error::FieldOverflow`] so a malformed frame is never produced. Ported from the
5//! reference C++ `Ecr17Protocol`.
6//!
7//! `payment_type` is the single request digit: `'0'` auto, `'1'` debit, `'2'` credit,
8//! `'3'` other (see [`crate::types::PaymentCardType`]).
9
10use crate::error::{Ecr17Error, Result};
11
12/// `'0'` (0x30) filler for reserved numeric fields.
13const RESERVED: char = '0';
14/// End-of-field marker for the privative TAG content (0x1B).
15const FIELD_SEP: char = '\u{1B}';
16
17/// Right-aligns `value` into a fixed-width field, padding on the left with `ch`.
18fn left_pad(value: &str, size: usize, ch: char) -> Result<String> {
19    if value.len() > size {
20        return Err(Ecr17Error::FieldOverflow {
21            value: value.to_string(),
22            width: size,
23        });
24    }
25    let mut s = String::with_capacity(size);
26    for _ in 0..size - value.len() {
27        s.push(ch);
28    }
29    s.push_str(value);
30    Ok(s)
31}
32
33/// Left-aligns `value` into a fixed-width field, padding on the right with `ch`.
34fn right_pad(value: &str, size: usize, ch: char) -> Result<String> {
35    if value.len() > size {
36        return Err(Ecr17Error::FieldOverflow {
37            value: value.to_string(),
38            width: size,
39        });
40    }
41    let mut s = String::with_capacity(size);
42    s.push_str(value);
43    for _ in 0..size - value.len() {
44        s.push(ch);
45    }
46    Ok(s)
47}
48
49/// The 8-byte, right-aligned, zero-filled amount field.
50fn amount_field(amount_cents: i64) -> Result<String> {
51    if amount_cents < 0 {
52        return Err(Ecr17Error::NegativeAmount);
53    }
54    left_pad(&amount_cents.to_string(), 8, RESERVED)
55}
56
57fn flag(on: bool) -> char {
58    if on {
59        '1'
60    } else {
61        '0'
62    }
63}
64
65/// Rejects a payment-type digit outside `'0'..'3'` so a malformed frame is never produced.
66/// The normal path supplies this via [`crate::types::PaymentCardType::as_digit`].
67fn validate_payment_type(payment_type: char) -> Result<()> {
68    if matches!(payment_type, '0'..='3') {
69        Ok(())
70    } else {
71        Err(Ecr17Error::InvalidPaymentType {
72            value: payment_type,
73        })
74    }
75}
76
77// Shared 167-byte payment-family layout (codes 'P', 'X', 'p').
78#[allow(clippy::too_many_arguments)]
79fn build_payment_like(
80    code: char,
81    terminal_id: &str,
82    cash_register_id: &str,
83    amount_cents: i64,
84    payment_type: char,
85    card_already_present: bool,
86    with_additional_data: bool,
87    receipt_text: &str,
88) -> Result<String> {
89    validate_payment_type(payment_type)?;
90    let mut m = String::with_capacity(167);
91    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
92    m.push(RESERVED); // 9  : reserved
93    m.push(code); // 10 : message code
94    m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); // 11 : cash register id
95    m.push(flag(with_additional_data)); // 19 : presence of additional GT data
96    m.push_str("00"); // 20 : reserved
97    m.push(flag(card_already_present)); // 22 : start-with-card-present
98    m.push(payment_type); // 23 : payment type
99    m.push_str(&amount_field(amount_cents)?); // 24 : amount (8)
100                                              // 32 : receipt text (128) — RIGHT-aligned (leading spaces), per the Nexi reference
101                                              // (`buildPaymentLike` uses leftPad here; the layout test asserts the text at the tail).
102                                              // Do NOT switch to right_pad — that would misalign the field vs the terminal.
103    m.push_str(&left_pad(receipt_text, 128, ' ')?);
104    m.push_str("00000000"); // 160: reserved (8)
105    Ok(m) // 167
106}
107
108// Shared 176-byte pre-auth integration/closure layout (codes 'i', 'c').
109fn build_pre_auth_follow_up(
110    code: char,
111    terminal_id: &str,
112    cash_register_id: &str,
113    amount_cents: i64,
114    original_pre_auth_code: &str,
115    with_additional_data: bool,
116    receipt_text: &str,
117) -> Result<String> {
118    let mut m = String::with_capacity(176);
119    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
120    m.push(RESERVED); // 9  : reserved
121    m.push(code); // 10 : message code
122    m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); // 11 : cash register id
123    m.push(flag(with_additional_data)); // 19 : presence of additional GT data
124    m.push_str("0000"); // 20 : reserved (4)
125    m.push_str(&amount_field(amount_cents)?); // 24 : amount (8)
126    m.push_str(&left_pad(receipt_text, 128, ' ')?); // 32 : receipt text (128, right-aligned; see build_payment_like)
127    m.push_str(&left_pad(original_pre_auth_code, 9, RESERVED)?); // 160: original pre-auth code (9)
128    m.push_str("00000000"); // 169: reserved (8)
129    Ok(m) // 176
130}
131
132// Shared 26-byte session command layout (codes 'C', 'T').
133fn build_session_command(
134    code: char,
135    terminal_id: &str,
136    cash_register_id: &str,
137    with_additional_data: bool,
138) -> Result<String> {
139    let mut m = String::with_capacity(26);
140    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
141    m.push(RESERVED); // 9  : reserved
142    m.push(code); // 10 : message code
143    m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); // 11 : cash register id
144    m.push(flag(with_additional_data)); // 19 : presence of additional GT data
145    m.push_str("0000000"); // 20 : reserved (7)
146    Ok(m) // 26
147}
148
149/// Payment `'P'` (167 bytes).
150#[allow(clippy::too_many_arguments)]
151pub fn build_payment(
152    terminal_id: &str,
153    cash_register_id: &str,
154    amount_cents: i64,
155    payment_type: char,
156    card_already_present: bool,
157    with_additional_data: bool,
158    receipt_text: &str,
159) -> Result<String> {
160    build_payment_like(
161        'P',
162        terminal_id,
163        cash_register_id,
164        amount_cents,
165        payment_type,
166        card_already_present,
167        with_additional_data,
168        receipt_text,
169    )
170}
171
172/// Extended payment `'X'` (167 bytes).
173#[allow(clippy::too_many_arguments)]
174pub fn build_extended_payment(
175    terminal_id: &str,
176    cash_register_id: &str,
177    amount_cents: i64,
178    payment_type: char,
179    card_already_present: bool,
180    with_additional_data: bool,
181    receipt_text: &str,
182) -> Result<String> {
183    build_payment_like(
184        'X',
185        terminal_id,
186        cash_register_id,
187        amount_cents,
188        payment_type,
189        card_already_present,
190        with_additional_data,
191        receipt_text,
192    )
193}
194
195/// Pre-auth `'p'` (167 bytes).
196#[allow(clippy::too_many_arguments)]
197pub fn build_pre_auth(
198    terminal_id: &str,
199    cash_register_id: &str,
200    amount_cents: i64,
201    payment_type: char,
202    card_already_present: bool,
203    with_additional_data: bool,
204    receipt_text: &str,
205) -> Result<String> {
206    build_payment_like(
207        'p',
208        terminal_id,
209        cash_register_id,
210        amount_cents,
211        payment_type,
212        card_already_present,
213        with_additional_data,
214        receipt_text,
215    )
216}
217
218/// Incremental pre-auth `'i'` (176 bytes).
219pub fn build_incremental(
220    terminal_id: &str,
221    cash_register_id: &str,
222    amount_cents: i64,
223    original_pre_auth_code: &str,
224    with_additional_data: bool,
225    receipt_text: &str,
226) -> Result<String> {
227    build_pre_auth_follow_up(
228        'i',
229        terminal_id,
230        cash_register_id,
231        amount_cents,
232        original_pre_auth_code,
233        with_additional_data,
234        receipt_text,
235    )
236}
237
238/// Pre-auth closure `'c'` (176 bytes).
239pub fn build_pre_auth_closure(
240    terminal_id: &str,
241    cash_register_id: &str,
242    amount_cents: i64,
243    original_pre_auth_code: &str,
244    with_additional_data: bool,
245    receipt_text: &str,
246) -> Result<String> {
247    build_pre_auth_follow_up(
248        'c',
249        terminal_id,
250        cash_register_id,
251        amount_cents,
252        original_pre_auth_code,
253        with_additional_data,
254        receipt_text,
255    )
256}
257
258/// Card verification `'H'` (39 bytes).
259pub fn build_card_verification(
260    terminal_id: &str,
261    cash_register_id: &str,
262    payment_type: char,
263    with_additional_data: bool,
264) -> Result<String> {
265    validate_payment_type(payment_type)?;
266    let mut m = String::with_capacity(39);
267    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
268    m.push(RESERVED); // 9  : reserved
269    m.push('H'); // 10 : message code
270    m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); // 11 : cash register id
271    m.push(flag(with_additional_data)); // 19 : presence of additional GT data
272    m.push_str("00"); // 20 : reserved (2)
273    m.push('0'); // 22 : standard card verification
274    m.push(payment_type); // 23 : payment type
275    m.push_str("0000000000000000"); // 24 : reserved (16)
276    Ok(m) // 39
277}
278
279/// Close session `'C'` (26 bytes).
280pub fn build_close_session(
281    terminal_id: &str,
282    cash_register_id: &str,
283    with_additional_data: bool,
284) -> Result<String> {
285    build_session_command('C', terminal_id, cash_register_id, with_additional_data)
286}
287
288/// Totals `'T'` (26 bytes).
289pub fn build_totals(
290    terminal_id: &str,
291    cash_register_id: &str,
292    with_additional_data: bool,
293) -> Result<String> {
294    build_session_command('T', terminal_id, cash_register_id, with_additional_data)
295}
296
297/// Send last result `'G'` (22 bytes).
298pub fn build_send_last_result(
299    terminal_id: &str,
300    cash_register_id: &str,
301    with_additional_data: bool,
302) -> Result<String> {
303    let mut m = String::with_capacity(22);
304    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
305    m.push(RESERVED); // 9  : reserved
306    m.push('G'); // 10 : message code
307    m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); // 11 : cash register id
308    m.push(flag(with_additional_data)); // 19 : presence of additional GT data
309    m.push_str("000"); // 20 : reserved (3)
310    Ok(m) // 22
311}
312
313/// Enable/disable ECR printing `'E'` (11 bytes).
314pub fn build_enable_ecr_print(terminal_id: &str, enabled: bool) -> Result<String> {
315    let mut m = String::with_capacity(11);
316    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
317    m.push(RESERVED); // 9  : reserved
318    m.push('E'); // 10 : message code
319    m.push(flag(enabled)); // 11 : enable(1)/disable(0) printing on ECR
320    Ok(m) // 11
321}
322
323/// Reprint ticket `'R'` (22 bytes).
324pub fn build_reprint(terminal_id: &str, to_ecr: bool, ticket_type: char) -> Result<String> {
325    let mut m = String::with_capacity(22);
326    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
327    m.push(RESERVED); // 9  : reserved
328    m.push('R'); // 10 : message code
329    m.push(flag(to_ecr)); // 11 : 1 = send receipt to ECR, 0 = print on terminal
330    m.push(ticket_type); // 12 : ticket type flag
331    m.push_str("0000000000"); // 13 : reserved (10)
332    Ok(m) // 22
333}
334
335/// Status `'s'` (10 bytes; lowercase code per spec).
336pub fn build_status(terminal_id: &str) -> Result<String> {
337    let mut m = String::with_capacity(10);
338    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
339    m.push(RESERVED); // 9  : reserved
340    m.push('s'); // 10 : message code (lowercase per spec)
341    Ok(m) // 10
342}
343
344/// Reversal `'S'` (26 bytes); `stan = "000000"` reverses the last payment with no STAN check.
345pub fn build_reversal(terminal_id: &str, cash_register_id: &str, stan: &str) -> Result<String> {
346    let mut m = String::with_capacity(26);
347    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
348    m.push(RESERVED); // 9  : reserved
349    m.push('S'); // 10 : message code
350    m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); // 11 : cash register id
351    m.push_str(&left_pad(stan, 6, RESERVED)?); // 19 : STAN ("000000" = no check)
352    m.push(RESERVED); // 25 : presence of additional GT data
353    m.push(RESERVED); // 26 : reserved
354    Ok(m) // 26
355}
356
357/// VAS `'K'` (variable, length-prefixed XML, max 1024 bytes).
358pub fn build_vas(terminal_id: &str, ecr_id: &str, xml_request: &str) -> Result<String> {
359    if xml_request.len() > 1024 {
360        return Err(Ecr17Error::VasTooLong);
361    }
362    let mut m = String::new();
363    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
364    m.push(RESERVED); // 9  : reserved
365    m.push('K'); // 10 : message code
366    m.push_str(&left_pad(ecr_id, 8, RESERVED)?); // 11 : ECR identifier
367    m.push_str("000"); // 19 : reserved (3)
368    m.push(RESERVED); // 22 : reserved (1)
369    m.push_str(&left_pad(&xml_request.len().to_string(), 4, RESERVED)?); // 23 : VAS length (4)
370    m.push_str(xml_request); // 27 : VAS request (XML)
371    Ok(m)
372}
373
374/// Additional data for GT / tokenization `'U'` (variable).
375///
376/// `tag_content` is the privative TAG content (1..=100 chars), terminated with `0x1B`
377/// by this builder. Use [`format_tokenization_tag`] to produce it. `iso_field` and
378/// `tag_number` default (in the reference) to `"62"` and `"DF8D01"`.
379pub fn build_additional_tags(
380    terminal_id: &str,
381    tag_content: &str,
382    iso_field: &str,
383    tag_number: &str,
384) -> Result<String> {
385    // Length bounds AND no embedded field separator: a 0x1B inside the content would
386    // prematurely terminate the field and produce a malformed/injectable 'U' message.
387    if tag_content.is_empty() || tag_content.len() > 100 || tag_content.contains(FIELD_SEP) {
388        return Err(Ecr17Error::TagContentInvalid);
389    }
390    let mut m = String::new();
391    m.push_str(&left_pad(terminal_id, 8, RESERVED)?); // 1  : terminal id
392    m.push(RESERVED); // 9  : reserved
393    m.push('U'); // 10 : message code
394    m.push_str("000000"); // 11 : payment type (6) -> standard payment
395    m.push_str(&left_pad(iso_field, 2, RESERVED)?); // 17 : ISO field number (e.g. "62")
396    m.push_str(&right_pad(tag_number, 8, ' ')?); // 19 : TAG number, left-justified, blank-filled
397    m.push(RESERVED); // 27 : reserved (1)
398    m.push_str("0000"); // 28 : exclusive TAG index bytemap (none to send to GT)
399    m.push_str("00000"); // 32 : reserved (5)
400    m.push_str(tag_content); // 37 : privative TAG content (1..=100)
401    m.push(FIELD_SEP); //      end-of-field (0x1B)
402    Ok(m)
403}
404
405/// Formats the TAG 5 content for tokenization (Intesa-style mapping):
406///   `"0COF0TRK<contract>|0FNZ03"` (unscheduled/one-click) or
407///   `"0REC0TRK<contract>|0FNZ03"` (recurring). `recurring` selects `0REC` vs `0COF`.
408pub fn format_tokenization_tag(recurring: bool, contract_code: &str) -> Result<String> {
409    // Alphanumeric (documented) AND bounded: the code is interpolated into a structured
410    // TAG (`...0TRK<code>|0FNZ03`), so a `|` or a control char could produce an ambiguous
411    // or malformed TAG value.
412    if contract_code.is_empty()
413        || contract_code.len() > 18
414        || !contract_code.bytes().all(|b| b.is_ascii_alphanumeric())
415    {
416        return Err(Ecr17Error::ContractCodeInvalid);
417    }
418    let service = if recurring { "0REC" } else { "0COF" };
419    Ok(format!("{service}0TRK{contract_code}|0FNZ03"))
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    const T: &str = "12345678"; // terminal id
427    const C: &str = "87654321"; // cash register id
428
429    // --- test_protocol.cpp -------------------------------------------------
430
431    #[test]
432    fn status_message_layout() {
433        let m = build_status("42").unwrap();
434        assert_eq!(m.len(), 10);
435        assert_eq!(&m[0..8], "00000042"); // terminal id, left-padded
436        assert_eq!(m.as_bytes()[8], b'0'); // reserved
437        assert_eq!(m.as_bytes()[9], b's'); // lowercase message code per spec
438    }
439
440    #[test]
441    fn status_message_keeps_full_width_id() {
442        assert_eq!(build_status("12345678").unwrap(), "123456780s");
443    }
444
445    #[test]
446    fn payment_message_is_167_bytes() {
447        assert_eq!(
448            build_payment("1", "2", 650, '0', false, false, "")
449                .unwrap()
450                .len(),
451            167
452        );
453    }
454
455    #[test]
456    fn payment_message_field_layout() {
457        let m = build_payment("12345678", "87654321", 650, '0', false, false, "").unwrap();
458        assert_eq!(m.len(), 167);
459        assert_eq!(&m[0..8], "12345678"); // terminal id
460        assert_eq!(m.as_bytes()[8], b'0'); // reserved
461        assert_eq!(m.as_bytes()[9], b'P'); // message code
462        assert_eq!(&m[10..18], "87654321"); // cash register id
463        assert_eq!(m.as_bytes()[18], b'0'); // presence of additional data
464        assert_eq!(&m[19..21], "00"); // reserved
465        assert_eq!(m.as_bytes()[21], b'0'); // start-with-card
466        assert_eq!(m.as_bytes()[22], b'0'); // payment type
467        assert_eq!(&m[23..31], "00000650"); // amount, right aligned
468        assert_eq!(&m[31..159], &" ".repeat(128)); // text field
469        assert_eq!(&m[159..167], "00000000"); // trailing reserved
470    }
471
472    #[test]
473    fn payment_message_amount_max_fits() {
474        let m = build_payment("1", "2", 99_999_999, '0', false, false, "").unwrap();
475        assert_eq!(&m[23..31], "99999999");
476    }
477
478    #[test]
479    fn payment_rejects_negative_amount() {
480        assert_eq!(
481            build_payment("1", "2", -1, '0', false, false, ""),
482            Err(Ecr17Error::NegativeAmount)
483        );
484    }
485
486    #[test]
487    fn payment_rejects_amount_overflowing_field() {
488        // 9 digits does not fit the 8-byte amount field.
489        assert!(matches!(
490            build_payment("1", "2", 100_000_000, '0', false, false, ""),
491            Err(Ecr17Error::FieldOverflow { .. })
492        ));
493    }
494
495    #[test]
496    fn payment_rejects_oversized_terminal_id() {
497        assert!(matches!(
498            build_payment("123456789", "2", 650, '0', false, false, ""),
499            Err(Ecr17Error::FieldOverflow { .. })
500        ));
501    }
502
503    #[test]
504    fn status_rejects_oversized_terminal_id() {
505        assert!(matches!(
506            build_status("123456789"),
507            Err(Ecr17Error::FieldOverflow { .. })
508        ));
509    }
510
511    #[test]
512    fn reversal_message_layout() {
513        let m = build_reversal("12345678", "87654321", "000123").unwrap();
514        assert_eq!(m.len(), 26);
515        assert_eq!(&m[0..8], "12345678");
516        assert_eq!(m.as_bytes()[8], b'0');
517        assert_eq!(m.as_bytes()[9], b'S');
518        assert_eq!(&m[10..18], "87654321");
519        assert_eq!(&m[18..24], "000123");
520        assert_eq!(m.as_bytes()[24], b'0');
521        assert_eq!(m.as_bytes()[25], b'0');
522    }
523
524    #[test]
525    fn reversal_default_stan_is_no_check() {
526        let m = build_reversal("12345678", "87654321", "000000").unwrap();
527        assert_eq!(&m[18..24], "000000");
528    }
529
530    #[test]
531    fn reversal_rejects_oversized_stan() {
532        assert!(matches!(
533            build_reversal("1", "2", "1234567"),
534            Err(Ecr17Error::FieldOverflow { .. })
535        ));
536    }
537
538    // --- test_protocol_commands.cpp ---------------------------------------
539
540    #[test]
541    fn extended_payment_layout_and_flags() {
542        let m = build_extended_payment(T, C, 650, '2', true, true, "ABC").unwrap();
543        assert_eq!(m.len(), 167);
544        assert_eq!(&m[0..8], T);
545        assert_eq!(m.as_bytes()[8], b'0');
546        assert_eq!(m.as_bytes()[9], b'X');
547        assert_eq!(&m[10..18], C);
548        assert_eq!(m.as_bytes()[18], b'1'); // withAdditionalData
549        assert_eq!(&m[19..21], "00");
550        assert_eq!(m.as_bytes()[21], b'1'); // cardAlreadyPresent
551        assert_eq!(m.as_bytes()[22], b'2'); // payment type
552        assert_eq!(&m[23..31], "00000650"); // amount
553        assert_eq!(&m[31..156], &" ".repeat(125)); // text left-padding
554        assert_eq!(&m[156..159], "ABC"); // text right-aligned
555        assert_eq!(&m[159..167], "00000000");
556    }
557
558    // Locks the intentional RIGHT-alignment of the 128-byte receipt-text field (leading
559    // spaces, text at the tail) — matches the Nexi reference; do not "fix" to left-align.
560    #[test]
561    fn payment_receipt_text_is_right_aligned() {
562        let m = build_payment(T, C, 650, '0', false, false, "ABC").unwrap();
563        assert_eq!(&m[31..156], &" ".repeat(125)); // 125 leading spaces
564        assert_eq!(&m[156..159], "ABC"); // text right-aligned at the tail
565    }
566
567    #[test]
568    fn pre_auth_uses_code_lower_p() {
569        let m = build_pre_auth(T, C, 1000, '0', false, false, "").unwrap();
570        assert_eq!(m.len(), 167);
571        assert_eq!(m.as_bytes()[9], b'p');
572        assert_eq!(&m[23..31], "00001000");
573    }
574
575    #[test]
576    fn payment_defaults_match_basic_layout() {
577        let m = build_payment(T, C, 650, '0', false, false, "").unwrap();
578        assert_eq!(m.len(), 167);
579        assert_eq!(m.as_bytes()[9], b'P');
580        assert_eq!(m.as_bytes()[18], b'0'); // no additional data by default
581        assert_eq!(m.as_bytes()[21], b'0'); // card not present by default
582        assert_eq!(m.as_bytes()[22], b'0'); // auto payment type by default
583        assert_eq!(&m[31..159], &" ".repeat(128));
584    }
585
586    #[test]
587    fn incremental_layout() {
588        let m = build_incremental(T, C, 1000, "123456789", false, "").unwrap();
589        assert_eq!(m.len(), 176);
590        assert_eq!(m.as_bytes()[9], b'i');
591        assert_eq!(&m[19..23], "0000");
592        assert_eq!(&m[23..31], "00001000");
593        assert_eq!(&m[159..168], "123456789"); // original pre-auth code
594        assert_eq!(&m[168..176], "00000000");
595    }
596
597    #[test]
598    fn pre_auth_closure_layout() {
599        let m = build_pre_auth_closure(T, C, 500, "000000042", false, "").unwrap();
600        assert_eq!(m.len(), 176);
601        assert_eq!(m.as_bytes()[9], b'c');
602        assert_eq!(&m[159..168], "000000042");
603    }
604
605    #[test]
606    fn card_verification_layout() {
607        let m = build_card_verification(T, C, '1', false).unwrap();
608        assert_eq!(m.len(), 39);
609        assert_eq!(m.as_bytes()[9], b'H');
610        assert_eq!(&m[10..18], C);
611        assert_eq!(m.as_bytes()[18], b'0'); // no additional data
612        assert_eq!(&m[19..21], "00");
613        assert_eq!(m.as_bytes()[21], b'0'); // standard verification
614        assert_eq!(m.as_bytes()[22], b'1'); // payment type
615        assert_eq!(&m[23..39], &"0".repeat(16));
616    }
617
618    #[test]
619    fn close_session_layout() {
620        let m = build_close_session(T, C, false).unwrap();
621        assert_eq!(m.len(), 26);
622        assert_eq!(m.as_bytes()[9], b'C');
623        assert_eq!(&m[10..18], C);
624        assert_eq!(m.as_bytes()[18], b'0');
625        assert_eq!(&m[19..26], &"0".repeat(7));
626    }
627
628    #[test]
629    fn totals_layout() {
630        let m = build_totals(T, C, false).unwrap();
631        assert_eq!(m.len(), 26);
632        assert_eq!(m.as_bytes()[9], b'T');
633    }
634
635    #[test]
636    fn send_last_result_layout() {
637        let m = build_send_last_result(T, C, false).unwrap();
638        assert_eq!(m.len(), 22);
639        assert_eq!(m.as_bytes()[9], b'G');
640        assert_eq!(&m[19..22], "000");
641    }
642
643    #[test]
644    fn enable_ecr_print_layout() {
645        assert_eq!(build_enable_ecr_print(T, true).unwrap(), "123456780E1");
646        assert_eq!(build_enable_ecr_print(T, false).unwrap(), "123456780E0");
647    }
648
649    #[test]
650    fn reprint_layout() {
651        let m = build_reprint(T, true, '0').unwrap();
652        assert_eq!(m.len(), 22);
653        assert_eq!(m.as_bytes()[9], b'R');
654        assert_eq!(m.as_bytes()[10], b'1'); // send to ECR
655        assert_eq!(m.as_bytes()[11], b'0'); // ticket type default
656        assert_eq!(&m[12..22], &"0".repeat(10));
657    }
658
659    #[test]
660    fn vas_layout_and_length_prefix() {
661        let m = build_vas(T, C, "<x/>").unwrap();
662        assert_eq!(m.len(), 30);
663        assert_eq!(m.as_bytes()[9], b'K');
664        assert_eq!(&m[10..18], C);
665        assert_eq!(&m[18..21], "000");
666        assert_eq!(m.as_bytes()[21], b'0');
667        assert_eq!(&m[22..26], "0004"); // length of "<x/>"
668        assert_eq!(&m[26..], "<x/>");
669    }
670
671    #[test]
672    fn vas_rejects_oversized_request() {
673        assert_eq!(
674            build_vas(T, C, &"x".repeat(1025)),
675            Err(Ecr17Error::VasTooLong)
676        );
677    }
678
679    #[test]
680    fn additional_tags_layout() {
681        let content = "0COF0TRK123|0FNZ03"; // 18 chars
682        let m = build_additional_tags(T, content, "62", "DF8D01").unwrap();
683        assert_eq!(m.len(), 36 + content.len() + 1);
684        assert_eq!(m.as_bytes()[9], b'U');
685        assert_eq!(&m[10..16], "000000");
686        assert_eq!(&m[16..18], "62");
687        assert_eq!(&m[18..26], "DF8D01  "); // left-justified, blank-filled
688        assert_eq!(m.as_bytes()[26], b'0');
689        assert_eq!(&m[27..31], "0000");
690        assert_eq!(&m[31..36], "00000");
691        assert_eq!(&m[36..36 + content.len()], content);
692        assert_eq!(m.as_bytes()[m.len() - 1], 0x1B);
693    }
694
695    #[test]
696    fn additional_tags_rejects_bad_content() {
697        assert_eq!(
698            build_additional_tags(T, "", "62", "DF8D01"),
699            Err(Ecr17Error::TagContentInvalid)
700        );
701        assert_eq!(
702            build_additional_tags(T, &"x".repeat(101), "62", "DF8D01"),
703            Err(Ecr17Error::TagContentInvalid)
704        );
705        // An embedded field separator (0x1B) would prematurely terminate the field.
706        assert_eq!(
707            build_additional_tags(T, "abc\u{1B}def", "62", "DF8D01"),
708            Err(Ecr17Error::TagContentInvalid)
709        );
710    }
711
712    #[test]
713    fn tokenization_tag_format() {
714        assert_eq!(
715            format_tokenization_tag(false, "1666354841608").unwrap(),
716            "0COF0TRK1666354841608|0FNZ03"
717        );
718        assert_eq!(
719            format_tokenization_tag(true, "ABC").unwrap(),
720            "0REC0TRKABC|0FNZ03"
721        );
722        assert_eq!(
723            format_tokenization_tag(false, ""),
724            Err(Ecr17Error::ContractCodeInvalid)
725        );
726        assert_eq!(
727            format_tokenization_tag(false, &"x".repeat(19)),
728            Err(Ecr17Error::ContractCodeInvalid)
729        );
730        // Non-alphanumeric (e.g. the '|' TAG separator or a space) is rejected.
731        assert_eq!(
732            format_tokenization_tag(false, "AB|CD"),
733            Err(Ecr17Error::ContractCodeInvalid)
734        );
735        assert_eq!(
736            format_tokenization_tag(true, "ab cd"),
737            Err(Ecr17Error::ContractCodeInvalid)
738        );
739    }
740
741    #[test]
742    fn incremental_rejects_oversized_pre_auth_code() {
743        assert!(matches!(
744            build_incremental(T, C, 100, "1234567890", false, ""), // 10 digits > 9-byte field
745            Err(Ecr17Error::FieldOverflow { .. })
746        ));
747    }
748
749    #[test]
750    fn builders_reject_invalid_payment_type() {
751        assert_eq!(
752            build_payment(T, C, 100, '9', false, false, ""),
753            Err(Ecr17Error::InvalidPaymentType { value: '9' })
754        );
755        assert_eq!(
756            build_card_verification(T, C, 'x', false),
757            Err(Ecr17Error::InvalidPaymentType { value: 'x' })
758        );
759        // All valid digits are accepted.
760        for d in ['0', '1', '2', '3'] {
761            assert!(build_payment(T, C, 100, d, false, false, "").is_ok());
762            assert!(build_card_verification(T, C, d, false).is_ok());
763        }
764    }
765
766    #[test]
767    fn pre_auth_rejects_negative_amount() {
768        assert_eq!(
769            build_pre_auth(T, C, -1, '0', false, false, ""),
770            Err(Ecr17Error::NegativeAmount)
771        );
772    }
773}