feat: improve CreditCall receipt layout

This commit is contained in:
yurii 2026-09-08 18:04:33 +01:00
parent afb8bb0e9e
commit c8cbd1e7a3
2 changed files with 437 additions and 78 deletions

View File

@ -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)

View File

@ -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)
}
})
}
}