1use crate::error::{Ecr17Error, Result};
11
12const RESERVED: char = '0';
14const FIELD_SEP: char = '\u{1B}';
16
17fn 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
33fn 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
49fn 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
65fn 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#[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)?); m.push(RESERVED); m.push(code); m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); m.push(flag(with_additional_data)); m.push_str("00"); m.push(flag(card_already_present)); m.push(payment_type); m.push_str(&amount_field(amount_cents)?); m.push_str(&left_pad(receipt_text, 128, ' ')?);
104 m.push_str("00000000"); Ok(m) }
107
108fn 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)?); m.push(RESERVED); m.push(code); m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); m.push(flag(with_additional_data)); m.push_str("0000"); m.push_str(&amount_field(amount_cents)?); m.push_str(&left_pad(receipt_text, 128, ' ')?); m.push_str(&left_pad(original_pre_auth_code, 9, RESERVED)?); m.push_str("00000000"); Ok(m) }
131
132fn 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)?); m.push(RESERVED); m.push(code); m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); m.push(flag(with_additional_data)); m.push_str("0000000"); Ok(m) }
148
149#[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#[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#[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
218pub 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
238pub 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
258pub 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)?); m.push(RESERVED); m.push('H'); m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); m.push(flag(with_additional_data)); m.push_str("00"); m.push('0'); m.push(payment_type); m.push_str("0000000000000000"); Ok(m) }
278
279pub 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
288pub 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
297pub 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)?); m.push(RESERVED); m.push('G'); m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); m.push(flag(with_additional_data)); m.push_str("000"); Ok(m) }
312
313pub 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)?); m.push(RESERVED); m.push('E'); m.push(flag(enabled)); Ok(m) }
322
323pub 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)?); m.push(RESERVED); m.push('R'); m.push(flag(to_ecr)); m.push(ticket_type); m.push_str("0000000000"); Ok(m) }
334
335pub 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)?); m.push(RESERVED); m.push('s'); Ok(m) }
343
344pub 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)?); m.push(RESERVED); m.push('S'); m.push_str(&left_pad(cash_register_id, 8, RESERVED)?); m.push_str(&left_pad(stan, 6, RESERVED)?); m.push(RESERVED); m.push(RESERVED); Ok(m) }
356
357pub 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)?); m.push(RESERVED); m.push('K'); m.push_str(&left_pad(ecr_id, 8, RESERVED)?); m.push_str("000"); m.push(RESERVED); m.push_str(&left_pad(&xml_request.len().to_string(), 4, RESERVED)?); m.push_str(xml_request); Ok(m)
372}
373
374pub fn build_additional_tags(
380 terminal_id: &str,
381 tag_content: &str,
382 iso_field: &str,
383 tag_number: &str,
384) -> Result<String> {
385 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)?); m.push(RESERVED); m.push('U'); m.push_str("000000"); m.push_str(&left_pad(iso_field, 2, RESERVED)?); m.push_str(&right_pad(tag_number, 8, ' ')?); m.push(RESERVED); m.push_str("0000"); m.push_str("00000"); m.push_str(tag_content); m.push(FIELD_SEP); Ok(m)
403}
404
405pub fn format_tokenization_tag(recurring: bool, contract_code: &str) -> Result<String> {
409 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"; const C: &str = "87654321"; #[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"); assert_eq!(m.as_bytes()[8], b'0'); assert_eq!(m.as_bytes()[9], b's'); }
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"); assert_eq!(m.as_bytes()[8], b'0'); assert_eq!(m.as_bytes()[9], b'P'); assert_eq!(&m[10..18], "87654321"); assert_eq!(m.as_bytes()[18], b'0'); assert_eq!(&m[19..21], "00"); assert_eq!(m.as_bytes()[21], b'0'); assert_eq!(m.as_bytes()[22], b'0'); assert_eq!(&m[23..31], "00000650"); assert_eq!(&m[31..159], &" ".repeat(128)); assert_eq!(&m[159..167], "00000000"); }
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 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]
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'); assert_eq!(&m[19..21], "00");
550 assert_eq!(m.as_bytes()[21], b'1'); assert_eq!(m.as_bytes()[22], b'2'); assert_eq!(&m[23..31], "00000650"); assert_eq!(&m[31..156], &" ".repeat(125)); assert_eq!(&m[156..159], "ABC"); assert_eq!(&m[159..167], "00000000");
556 }
557
558 #[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)); assert_eq!(&m[156..159], "ABC"); }
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'); assert_eq!(m.as_bytes()[21], b'0'); assert_eq!(m.as_bytes()[22], b'0'); 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"); 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'); assert_eq!(&m[19..21], "00");
613 assert_eq!(m.as_bytes()[21], b'0'); assert_eq!(m.as_bytes()[22], b'1'); 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'); assert_eq!(m.as_bytes()[11], b'0'); 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"); 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"; 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 "); 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 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 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, ""), 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 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}