diff --git a/internal/printer/printer.go b/internal/printer/printer.go index 1881a99..332f2d9 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -245,95 +245,174 @@ func printCardholderReceipt(cardholderReceipt string) error { return nil } +// receiptColumns follows the existing 44-column normal-size text convention. +const receiptColumns = 44 + func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) { var buf bytes.Buffer + rendered := make([]bool, len(entries)) + take := func(id string) string { + for i, entry := range entries { + if !rendered[i] && entry.ReceiptEntryId == id && entry.Value != "" { + rendered[i] = true + return receiptEntryText(entry) + } + } + return "" + } - write := func(b []byte) { buf.Write(b) } - // writeStr := func(s string) { buf.WriteString(s) } - writeln := func(s string) { - buf.WriteString(s) + // Set and restore only text state; leave printer setup and transport alone. + normal := func() { buf.Write([]byte{ESC, 'a', 0, ESC, 'E', BOLD_OFF, GS, '!', NORMAL_FONT}) } + normal() + hasRows, newSection := false, false + beginLine := func(center bool) { + if hasRows && newSection { + buf.WriteString(strings.Repeat("-", receiptColumns) + "\n") + } + hasRows, newSection = true, false + if center { + buf.Write([]byte{ESC, 'a', CENTER}) + } + } + text := func(value string, bold bool) { + if bold { + buf.Write([]byte{ESC, 'E', BOLD_ON}) + } + buf.WriteString(value) + if bold { + buf.Write([]byte{ESC, 'E', BOLD_OFF}) + } + } + line := func(value string, bold, center bool) { + if value == "" { + return + } + beginLine(center) + text(value, bold) buf.WriteByte('\n') + normal() + } + pair := func(left, right string, boldRight bool) { + if gap, fits := receiptPairGap(left, right); fits { + beginLine(false) + text(left+gap, false) + text(right, boldRight) + buf.WriteByte('\n') + normal() + return + } + line(left, false, false) + line(right, boldRight, false) } - // Build a lookup map by ReceiptEntryId - m := make(map[string]ReceiptEntryXML, len(entries)) - for _, e := range entries { - m[e.ReceiptEntryId] = e + line(take("Recipient"), true, true) + line(take("MerchantName"), false, true) + newSection = true + line(take("CardScheme"), false, false) + line(take("PanMasked"), false, false) + pair(take("Aid"), take("PanSequence"), false) + pair(take("TransactionSource"), take("TransactionType"), false) + newSection = true + // Align the provider amount without parsing or modifying its value. + for i, entry := range entries { + if entry.ReceiptEntryId == "TotalAmount" && entry.Value != "" { + rendered[i] = true + label := entry.Label + if label == "" { + label = "TOTAL" + } + label = strings.TrimSuffix(label, ":") + ":" + if gap, fits := receiptPairGap(label, entry.Value); fits { + line(label+gap+entry.Value, true, false) + } else { + line(receiptEntryText(entry), true, false) + } + break + } } - - // Utility to center text - // center := func(s string) { - // write([]byte{ESC, 'a', CENTER}) - // writeln(s) - // write([]byte{ESC, 'a', 0}) - // } - - // Utility for bold lines - // boldOn := func() { write([]byte{GS, '!', WIDER_FONT}) } - // boldOff := func() { write([]byte{GS, '!', NORMAL_FONT}) } - - // boldOn() - - // 1) Header: CARDHOLDER COPY - writeln(m["Recipient"].Value) - // writeStr("\n\n") - // 2) Merchant name - writeln(m["MerchantName"].Value) - // writeStr("\n\n") - // 3) AID - writeln("AID: " + m["Aid"].Value) - - // 4) Card scheme & pan - writeln(fmt.Sprintf("%s Card: %s", m["CardScheme"].Value, m["PanMasked"].Value)) - - // 5) PAN Seq No - writeln("PAN Seq No: " + m["PanSequence"].Value) - - // 6) Transaction source - writeln(m["TransactionSource"].Value) - - // 7) Transaction type (Sale/Account Verification) - writeln(m["TransactionType"].Value) - - // 8) Total - // assuming value like "GBP1.50" — insert space after currency - total := m["TotalAmount"].Value - if !strings.HasPrefix(total, "GBP") && len(total) > 3 { - total = total[:3] + " " + total[3:] + pair(take("CardholderVerification"), take("TransactionResult"), true) + newSection = true + line(take("AuthCode"), false, false) + line(take("AuthReference"), false, false) + line(take("TransactionSeqNumber"), false, false) + pair(take("MerchantIdMasked"), take("TerminalIdMasked"), false) + line(take("AuthDateTime"), false, false) + footer := take("RetentionMessage") + // Preserve every remaining occurrence, including unknown IDs, in provider order. + for i, entry := range entries { + if !rendered[i] && entry.Value != "" { + line(receiptEntryText(entry), false, false) + } } - writeln("TOTAL: " + total) - - // 9) Cardholder verification - writeln(m["CardholderVerification"].Value) - - // 10) Approved/Declined - writeln(m["TransactionResult"].Value) - - // 11) Auth code - writeln("Auth Code: " + m["AuthCode"].Value) - - // 12) Reference - writeln("Ref: " + m["AuthReference"].Value) - - // 13) Merchant & terminal IDs - writeln("MID: " + m["MerchantIdMasked"].Value) - writeln("TID: " + m["TerminalIdMasked"].Value) - - // 14) Date/time - writeln(m["AuthDateTime"].Value) - - // 15) Retention message - writeln(m["RetentionMessage"].Value) - - // boldOff() - - // finally feed & cut - write([]byte{ESC, 'd', 7}) // Feed 5 lines - write([]byte{GS, 'V', 1}) - + newSection = true + line(footer, false, true) + normal() + buf.Write([]byte{ESC, 'd', 7}) + buf.Write([]byte{GS, 'V', 1}) return buf.Bytes(), nil } +func receiptEntryText(entry ReceiptEntryXML) string { + label := entry.Label + if label == "" { + switch entry.ReceiptEntryId { + case "Recipient", "MerchantName", "CardScheme", "TransactionSource", "TransactionType", + "CardholderVerification", "TransactionResult", "AuthDateTime", "RetentionMessage": + return entry.Value + case "PanMasked": + label = "Card" + case "Aid": + label = "AID" + case "PanSequence": + label = "PAN Seq No" + case "TotalAmount": + label = "TOTAL" + case "AuthCode": + label = "Auth Code" + case "AuthReference": + label = "Ref" + case "TransactionSeqNumber": + label = "Transaction Seq. No" + case "MerchantIdMasked": + label = "MID" + case "TerminalIdMasked": + label = "TID" + default: + var readable strings.Builder + runes := []rune(entry.ReceiptEntryId) + for i, r := range runes { + if i > 0 && r >= 'A' && r <= 'Z' && + ((runes[i-1] >= 'a' && runes[i-1] <= 'z') || + (i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z')) { + readable.WriteByte(' ') + } + if r == '_' || r == '-' { + r = ' ' + } + readable.WriteRune(r) + } + label = strings.TrimSpace(readable.String()) + } + } + if label == "" { + return entry.Value + } + return strings.TrimSuffix(label, ":") + ": " + entry.Value +} + +func receiptPairGap(left, right string) (string, bool) { + if left == "" || right == "" || len(left)+2+len(right) > receiptColumns { + return "", false + } + // Only printable ASCII has a predictable single-column width here. + for _, r := range left + right { + if r < ' ' || r > '~' { + return "", false + } + } + return strings.Repeat(" ", receiptColumns-len(left)-len(right)), true +} + func printLogo(path string) ([]byte, error) { const maxLogoWidth = 384 f, err := os.Open(path) diff --git a/internal/printer/receipt_test.go b/internal/printer/receipt_test.go new file mode 100644 index 0000000..b86e387 --- /dev/null +++ b/internal/printer/receipt_test.go @@ -0,0 +1,280 @@ +package printer + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +type receiptTestChar struct { + value byte + center, bold bool +} + +type receiptTestOutput struct { + lines []string + chars []receiptTestChar + feeds, cuts int +} + +// readReceiptText understands only the commands emitted by this receipt builder. +func readReceiptText(t *testing.T, data []byte) receiptTestOutput { + t.Helper() + var out receiptTestOutput + var line strings.Builder + var center, bold bool + for i := 0; i < len(data); { + if data[i] == ESC || data[i] == GS { + if i+2 >= len(data) { + t.Fatalf("BuildCardholderReceipt control bytes = %v, want complete command", data[i:]) + } + prefix, command, value := data[i], data[i+1], data[i+2] + switch { + case prefix == ESC && command == 'a': + if value != 0 && value != CENTER { + t.Errorf("receipt alignment = %d, want left or center", value) + } + center = value == CENTER + case prefix == ESC && command == 'E': + if value != BOLD_OFF && value != BOLD_ON { + t.Errorf("receipt bold = %d, want on or off", value) + } + bold = value == BOLD_ON + case prefix == GS && command == '!': + if value != NORMAL_FONT { + t.Errorf("receipt size = %d, want normal size", value) + } + case prefix == ESC && command == 'd': + out.feeds++ + if value != 7 || center || bold || i != len(data)-6 { + t.Errorf("receipt feed = %d, center=%t bold=%t offset=%d, want final seven-line feed in normal state", value, center, bold, i) + } + case prefix == GS && command == 'V': + out.cuts++ + if value != 1 || i != len(data)-3 { + t.Errorf("receipt cut = %d at %d, want final GS V 1", value, i) + } + default: + t.Fatalf("BuildCardholderReceipt command = %v, want supported text command", data[i:i+3]) + } + i += 3 + continue + } + if data[i] == '\n' { + out.lines = append(out.lines, line.String()) + line.Reset() + } else { + line.WriteByte(data[i]) + out.chars = append(out.chars, receiptTestChar{value: data[i], center: center, bold: bold}) + } + i++ + } + if line.Len() != 0 || center || bold || out.feeds != 1 || out.cuts != 1 { + t.Errorf("receipt termination: trailing=%q center=%t bold=%t feeds=%d cuts=%d, want normal state and one feed/cut", line.String(), center, bold, out.feeds, out.cuts) + } + return out +} + +func buildReceiptText(t *testing.T, entries []ReceiptEntryXML) receiptTestOutput { + t.Helper() + before := append([]ReceiptEntryXML(nil), entries...) + data, err := BuildCardholderReceipt(entries) + if err != nil { + t.Fatalf("BuildCardholderReceipt(%v) error = %v, want nil", entries, err) + } + if !reflect.DeepEqual(entries, before) { + t.Errorf("BuildCardholderReceipt input = %v, want unchanged %v", entries, before) + } + if !bytes.HasSuffix(data, []byte{ESC, 'd', 7, GS, 'V', 1}) { + t.Errorf("BuildCardholderReceipt suffix = %v, want unchanged feed/cut", data) + } + return readReceiptText(t, data) +} + +func sampleReceipt(recipient string) []ReceiptEntryXML { + return []ReceiptEntryXML{ + {ReceiptEntryId: "Recipient", Value: recipient}, + {ReceiptEntryId: "MerchantName", Value: "Standard Test Merchant"}, + {ReceiptEntryId: "Aid", Value: "A0000000031010", Label: "AID"}, + {ReceiptEntryId: "CardScheme", Value: "VISA DEBIT"}, + {ReceiptEntryId: "PanMasked", Value: "************1133", Label: "Card"}, + {ReceiptEntryId: "PanSequence", Value: "01", Label: "PAN Seq No"}, + {ReceiptEntryId: "TransactionSource", Value: "ICC"}, + {ReceiptEntryId: "TransactionType", Value: "SALE"}, + {ReceiptEntryId: "TotalAmount", Value: "GBP120.00", Label: "TOTAL"}, + {ReceiptEntryId: "CardholderVerification", Value: "PIN VERIFIED"}, + {ReceiptEntryId: "TransactionResult", Value: "APPROVED"}, + {ReceiptEntryId: "AuthCode", Value: "D0A35D", Label: "Auth Code"}, + {ReceiptEntryId: "AuthReference", Value: "ChipDNA-Sale-20260908144731", Label: "Ref"}, + {ReceiptEntryId: "MerchantIdMasked", Value: "******7897", Label: "MID"}, + {ReceiptEntryId: "TerminalIdMasked", Value: "****9508", Label: "TID"}, + {ReceiptEntryId: "TransactionSeqNumber", Value: "00001059", Label: "Transaction Seq. No"}, + {ReceiptEntryId: "AuthDateTime", Value: "08/09/2026 14:48:06"}, + {ReceiptEntryId: "RetentionMessage", Value: "Please retain for your records"}, + } +} + +func TestBuildCardholderReceiptCompleteLayout(t *testing.T) { + for _, recipient := range []string{"CARDHOLDER COPY", "MERCHANT COPY"} { + t.Run(recipient, func(t *testing.T) { + entries := sampleReceipt(recipient) + out := buildReceiptText(t, entries) + // Ignore padding used for left/right placement, not semantic row order. + var rows []string + for _, line := range out.lines { + rows = append(rows, strings.Join(strings.Fields(line), " ")) + } + rule := strings.Repeat("-", 44) + want := []string{recipient, "Standard Test Merchant", rule, "VISA DEBIT", "Card: ************1133", + "AID: A0000000031010 PAN Seq No: 01", "ICC SALE", rule, "TOTAL: GBP120.00", "PIN VERIFIED APPROVED", rule, + "Auth Code: D0A35D", "Ref: ChipDNA-Sale-20260908144731", "Transaction Seq. No: 00001059", + "MID: ******7897 TID: ****9508", "08/09/2026 14:48:06", rule, "Please retain for your records"} + if !reflect.DeepEqual(rows, want) { + t.Errorf("BuildCardholderReceipt(%q) rows = %q, want %q", recipient, rows, want) + } + for _, entry := range entries { + if !strings.Contains(strings.Join(out.lines, "\n"), entry.Value) { + t.Errorf("BuildCardholderReceipt(%q) lost %s=%q", recipient, entry.ReceiptEntryId, entry.Value) + } + } + for _, line := range out.lines { + if line == "" || len(line) > 44 { + t.Errorf("BuildCardholderReceipt(%q) row = %q, want compact nonempty row within 44 columns", recipient, line) + } + } + if len(out.lines) > 19 { + t.Errorf("BuildCardholderReceipt(%q) rows = %d, want representative receipt at most 19", recipient, len(out.lines)) + } + }) + } +} + +func TestBuildCardholderReceiptFormattingState(t *testing.T) { + out := buildReceiptText(t, sampleReceipt("CARDHOLDER COPY")) + var plain strings.Builder + for _, char := range out.chars { + plain.WriteByte(char.value) + } + for _, tc := range []struct { + text string + center, bold bool + }{ + {"CARDHOLDER COPY", true, true}, {"Standard Test Merchant", true, false}, + {"VISA DEBIT", false, false}, {"TOTAL:", false, true}, {"GBP120.00", false, true}, + {"PIN VERIFIED", false, false}, {"APPROVED", false, true}, {"Auth Code:", false, false}, + {"Please retain for your records", true, false}, + } { + start := strings.Index(plain.String(), tc.text) + if start < 0 { + t.Fatalf("receipt text lacks %q", tc.text) + } + for _, char := range out.chars[start : start+len(tc.text)] { + if char.center != tc.center || char.bold != tc.bold { + t.Errorf("receipt style for %q = center:%t bold:%t, want center:%t bold:%t", tc.text, char.center, char.bold, tc.center, tc.bold) + break + } + } + } +} + +func TestBuildCardholderReceiptMissingFields(t *testing.T) { + for _, tc := range []struct { + name string + entries []ReceiptEntryXML + want []string + }{ + {name: "empty"}, + {name: "empty values", entries: []ReceiptEntryXML{{ReceiptEntryId: "Aid", Label: "AID"}, {ReceiptEntryId: "Unknown", Label: "Unused"}}}, + {name: "one partner", entries: []ReceiptEntryXML{{ReceiptEntryId: "PanSequence", Value: "02"}}, want: []string{"PAN Seq No: 02"}}, + {name: "result only", entries: []ReceiptEntryXML{{ReceiptEntryId: "TransactionResult", Value: "DECLINED"}}, want: []string{"DECLINED"}}, + } { + t.Run(tc.name, func(t *testing.T) { + out := buildReceiptText(t, tc.entries) + if !reflect.DeepEqual(out.lines, tc.want) { + t.Errorf("BuildCardholderReceipt(%v) rows = %q, want %q", tc.entries, out.lines, tc.want) + } + }) + } +} + +func TestBuildCardholderReceiptPreservesRemainingEntries(t *testing.T) { + entries := []ReceiptEntryXML{ + {ReceiptEntryId: "AuthCode", Value: ""}, + {ReceiptEntryId: "FooBar", Value: "first-extra", Priority: "9"}, + {ReceiptEntryId: "AuthCode", Value: "primary", Label: "Provider authorization"}, + {ReceiptEntryId: "AuthCode", Value: "duplicate", Label: "Second authorization"}, + {ReceiptEntryId: "FooBar", Value: "second-extra", Label: "Foo", Priority: "1"}, + {ReceiptEntryId: "RetentionMessage", Value: "Retain this"}, + {ReceiptEntryId: "RetentionMessage", Value: "Extra retention"}, + {ReceiptEntryId: "HTTPCode", Value: "provider-code"}, + {Value: "unlabelled-value"}, + } + out := buildReceiptText(t, entries) + want := []string{"Provider authorization: primary", "Foo Bar: first-extra", "Second authorization: duplicate", + "Foo: second-extra", "Extra retention", "HTTP Code: provider-code", "unlabelled-value", strings.Repeat("-", 44), "Retain this"} + if !reflect.DeepEqual(out.lines, want) { + t.Errorf("BuildCardholderReceipt remaining rows = %q, want %q", out.lines, want) + } +} + +func TestBuildCardholderReceiptPairBoundaries(t *testing.T) { + for _, tc := range []struct { + name, left, right string + paired bool + }{ + {"short", "ICC", "SALE", true}, + {"exact width", strings.Repeat("L", 20), strings.Repeat("R", 22), true}, + {"overflow", strings.Repeat("L", 21), strings.Repeat("R", 22), false}, + {"unicode", "TAP \u00c9", "SALE", false}, + {"multiline", "ICC\nFALLBACK", "SALE", false}, + {"left missing", "", "SALE", false}, + {"right missing", "ICC", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + out := buildReceiptText(t, []ReceiptEntryXML{{ReceiptEntryId: "TransactionSource", Value: tc.left}, {ReceiptEntryId: "TransactionType", Value: tc.right}}) + var want []string + if tc.paired { + want = []string{tc.left + strings.Repeat(" ", 44-len(tc.left)-len(tc.right)) + tc.right} + } else { + if tc.left != "" { + want = append(want, strings.Split(tc.left, "\n")...) + } + if tc.right != "" { + want = append(want, tc.right) + } + } + if !reflect.DeepEqual(out.lines, want) { + t.Errorf("BuildCardholderReceipt pair(%q,%q) rows = %q, want %q", tc.left, tc.right, out.lines, want) + } + }) + } +} + +func TestBuildCardholderReceiptLongValuesAndLabels(t *testing.T) { + ref := strings.Repeat("long-reference-", 8) + verify := "CONSUMER DEVICE VERIFICATION WITH ADDITIONAL PROVIDER TEXT" + out := buildReceiptText(t, []ReceiptEntryXML{ + {ReceiptEntryId: "CardholderVerification", Value: verify}, + {ReceiptEntryId: "TransactionResult", Value: "DECLINED"}, + {ReceiptEntryId: "AuthReference", Label: "Provider reference:", Value: ref}, + {ReceiptEntryId: "MerchantIdMasked", Label: strings.Repeat("Merchant", 6), Value: "******7897"}, + {ReceiptEntryId: "TerminalIdMasked", Value: "****9508"}, + }) + want := []string{verify, "DECLINED", strings.Repeat("-", 44), "Provider reference: " + ref, + strings.Repeat("Merchant", 6) + ": ******7897", "TID: ****9508"} + if !reflect.DeepEqual(out.lines, want) { + t.Errorf("BuildCardholderReceipt long fields rows = %q, want %q", out.lines, want) + } +} + +func TestBuildCardholderReceiptProviderAmounts(t *testing.T) { + for _, amount := range []string{"GBP120.00", "EUR 42,50", "USD1.23", "amount supplied verbatim", strings.Repeat("9", 50)} { + t.Run(amount, func(t *testing.T) { + out := buildReceiptText(t, []ReceiptEntryXML{{ReceiptEntryId: "TotalAmount", Label: "Provider total", Value: amount}}) + if len(out.lines) != 1 || !strings.HasPrefix(out.lines[0], "Provider total:") || !strings.HasSuffix(out.lines[0], amount) { + t.Errorf("BuildCardholderReceipt amount(%q) rows = %q, want provider label and unchanged amount", amount, out.lines) + } + }) + } +}