Repository: moov-io/ach
Pull request #1216 (2023-04-21 14:29:55)

CTX: fix addenda validation and setters/getters
Fixes: https://github.com/moov-io/ach/issues/1208


Patch:
diff --git a/batch.go b/batch.go
index ab8e1e22..85ca17c4 100644
--- a/batch.go
+++ b/batch.go
@@ -991,7 +991,12 @@ func (batch *Batch) addendaFieldInclusionReturn(entry *EntryDetail) error {
 		return batch.Error("Addenda02", ErrBatchAddendaCategory, entry.Category)
 	}
 	if entry.Addenda05 != nil {
-		return batch.Error("Addenda05", ErrBatchAddendaCategory, entry.Category)
+		switch batch.Header.StandardEntryClassCode {
+		case CTX:
+			// do nothing, CTX allows Addneda05 records
+		default:
+			return batch.Error("Addenda05", ErrBatchAddendaCategory, entry.Category)
+		}
 	}
 	if entry.Addenda98 != nil {
 		return batch.Error("Addenda98", ErrBatchAddendaCategory, entry.Category)
diff --git a/batchCTX.go b/batchCTX.go
index b502dabf..3a12ca5f 100644
--- a/batchCTX.go
+++ b/batchCTX.go
@@ -56,17 +56,26 @@ func (batch *BatchCTX) Validate() error {
 	}
 
 	for _, entry := range batch.Entries {
+		addendaCount := len(entry.Addenda05)
 
 		// Trapping this error, as entry.CTXAddendaRecordsField() can not be greater than 9999
-		if len(entry.Addenda05) > 9999 {
+		if addendaCount > 9999 {
 			return batch.Error("AddendaCount", NewErrBatchAddendaCount(len(entry.Addenda05), 9999))
 		}
 
+		// Add to addendaCount so Corrections and Returns compare AddendaRecordIndicator correctly
+		if entry.Addenda98 != nil {
+			addendaCount += 1
+		}
+		if entry.Addenda99 != nil {
+			addendaCount += 1
+		}
+
 		// validate CTXAddendaRecord Field is equal to the actual number of Addenda records
 		// use 0 value if there is no Addenda records
-		addendaRecords, _ := strconv.Atoi(entry.CATXAddendaRecordsField())
-		if len(entry.Addenda05) != addendaRecords {
-			return batch.Error("AddendaCount", NewErrBatchExpectedAddendaCount(len(entry.Addenda05), addendaRecords))
+		indicator, _ := strconv.Atoi(entry.CATXAddendaRecordsField())
+		if addendaCount != indicator {
+			return batch.Error("AddendaCount", NewErrBatchExpectedAddendaCount(addendaCount, indicator))
 		}
 		// Verify TransactionCode for prenotes and regular entries
 		switch entry.TransactionCode {
diff --git a/batchErrors.go b/batchErrors.go
index 577850a9..9eff7106 100644
--- a/batchErrors.go
+++ b/batchErrors.go
@@ -291,7 +291,7 @@ type ErrBatchExpectedAddendaCount struct {
 // NewErrBatchExpectedAddendaCount creates a new error of the ErrBatchExpectedAddendaCount type
 func NewErrBatchExpectedAddendaCount(found, expected int) ErrBatchExpectedAddendaCount {
 	return ErrBatchExpectedAddendaCount{
-		Message:       fmt.Sprintf("%v addendum found where %v are Expected for this batch type", found, expected),
+		Message:       fmt.Sprintf("%v addendum found where %v are expected for this batch type", found, expected),
 		FoundCount:    found,
 		ExpectedCount: expected,
 	}
diff --git a/entryDetail.go b/entryDetail.go
index cbd7ff4c..cec33719 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -492,23 +492,41 @@ func (ed *EntryDetail) SetOriginalTraceNumber(s string) {
 
 // SetCATXAddendaRecords setter for CTX and ATX AddendaRecords characters 1-4 of underlying IndividualName
 func (ed *EntryDetail) SetCATXAddendaRecords(i int) {
-	ed.IndividualName = ed.numericField(i, 4)
+	count := ed.numericField(i, 4)
+	current := ed.IndividualName
+	if utf8.RuneCountInString(current) > 4 {
+		ed.IndividualName = count + current[4:]
+	} else {
+		ed.IndividualName = count + ed.alphaField(" ", 16) + "  "
+	}
 }
 
 // SetCATXReceivingCompany setter for CTX and ATX ReceivingCompany characters 5-20 underlying IndividualName
 // Position 21-22 of underlying Individual Name are reserved blank space for CTX "  "
 func (ed *EntryDetail) SetCATXReceivingCompany(s string) {
-	ed.IndividualName = ed.IndividualName + ed.alphaField(s, 16) + "  "
+	current := ed.IndividualName
+	if utf8.RuneCountInString(current) > 4 {
+		count := current[:4]
+		ed.IndividualName = count + ed.alphaField(s, 16) + "  "
+	} else {
+		ed.IndividualName = "0000" + ed.alphaField(s, 16) + "  "
+	}
 }
 
 // CATXAddendaRecordsField is used in CTX and ATX files, characters 1-4 of underlying IndividualName field
 func (ed *EntryDetail) CATXAddendaRecordsField() string {
-	return ed.parseStringField(ed.IndividualName[0:4])
+	if utf8.RuneCountInString(ed.IndividualName) < 5 {
+		return ed.IndividualName
+	}
+	return ed.parseStringField(ed.IndividualName[:4])
 }
 
 // CATXReceivingCompanyField is used in CTX and ATX files, characters 5-20 of underlying IndividualName field
 func (ed *EntryDetail) CATXReceivingCompanyField() string {
-	return ed.parseStringField(ed.IndividualName[4:20])
+	if utf8.RuneCountInString(ed.IndividualName) < 4 {
+		return ""
+	}
+	return ed.IndividualName[4:]
 }
 
 // CATXReservedField is used in CTX and ATX files, characters 21-22 of underlying IndividualName field
diff --git a/go.mod b/go.mod
index f961ded9..4254fa67 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
 	github.com/moov-io/base v0.40.2
 	github.com/prometheus/client_golang v1.15.0
 	github.com/stretchr/testify v1.8.2
+	golang.org/x/oauth2 v0.5.0
 	golang.org/x/text v0.9.0
 )
 
@@ -31,7 +32,9 @@ require (
 	github.com/prometheus/procfs v0.9.0 // indirect
 	github.com/rickar/cal/v2 v2.1.13 // indirect
 	github.com/rogpeppe/go-internal v1.10.0 // indirect
-	golang.org/x/sys v0.7.0 // indirect
+	golang.org/x/net v0.7.0 // indirect
+	golang.org/x/sys v0.6.0 // indirect
+	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/protobuf v1.30.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 5f6987bb..29c492e6 100644
--- a/go.sum
+++ b/go.sum
@@ -17,6 +17,7 @@ github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBj
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
@@ -68,16 +69,28 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
+golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=


Test patch:
diff --git a/batchATX_test.go b/batchATX_test.go
index 6a47e015..674248b8 100644
--- a/batchATX_test.go
+++ b/batchATX_test.go
@@ -21,6 +21,7 @@ import (
 	"testing"
 
 	"github.com/moov-io/base"
+	"github.com/stretchr/testify/require"
 )
 
 // mockBatchATXHeader creates a BatchATX BatchHeader
@@ -366,11 +367,8 @@ func BenchmarkBatchATXAddendaRecords(b *testing.B) {
 // testBatchATXReceivingCompany validates ATXReceivingCompany
 func testBatchATXReceivingCompany(t testing.TB) {
 	mockBatch := mockBatchATX(t)
-	//mockBatch.GetEntries()[0].SetCATXReceivingCompany("Receiver")
-
-	if mockBatch.GetEntries()[0].CATXReceivingCompanyField() != "Receiver Company" {
-		t.Errorf("expected %v got %v", "Receiver Company", mockBatch.GetEntries()[0].CATXReceivingCompanyField())
-	}
+	mockBatch.GetEntries()[0].SetCATXReceivingCompany("Receiver Company")
+	require.Equal(t, "Receiver Company  ", mockBatch.GetEntries()[0].CATXReceivingCompanyField())
 }
 
 // TestBatchATXReceivingCompany tests validating ATXReceivingCompany
diff --git a/batchCTX_test.go b/batchCTX_test.go
index 000b41eb..2b120fa1 100644
--- a/batchCTX_test.go
+++ b/batchCTX_test.go
@@ -21,6 +21,7 @@ import (
 	"testing"
 
 	"github.com/moov-io/base"
+	"github.com/stretchr/testify/require"
 )
 
 // mockBatchCTXHeader creates a BatchCTX BatchHeader
@@ -349,11 +350,8 @@ func BenchmarkBatchCTXAddendaRecords(b *testing.B) {
 // testBatchCTXReceivingCompany validates CTXReceivingCompany
 func testBatchCTXReceivingCompany(t testing.TB) {
 	mockBatch := mockBatchCTX(t)
-	//mockBatch.GetEntries()[0].SetCATXReceivingCompany("Receiver")
-
-	if mockBatch.GetEntries()[0].CATXReceivingCompanyField() != "Receiver Company" {
-		t.Errorf("expected %v got %v", "Receiver Company", mockBatch.GetEntries()[0].CATXReceivingCompanyField())
-	}
+	mockBatch.GetEntries()[0].SetCATXReceivingCompany("Receiver Company")
+	require.Equal(t, "Receiver Company  ", mockBatch.GetEntries()[0].CATXReceivingCompanyField())
 }
 
 // TestBatchCTXReceivingCompany tests validating CTXReceivingCompany
diff --git a/batchTRX_test.go b/batchTRX_test.go
index 89479358..9f6f5922 100644
--- a/batchTRX_test.go
+++ b/batchTRX_test.go
@@ -21,6 +21,7 @@ import (
 	"testing"
 
 	"github.com/moov-io/base"
+	"github.com/stretchr/testify/require"
 )
 
 // mockBatchTRXHeader creates a BatchTRX BatchHeader
@@ -408,11 +409,8 @@ func BenchmarkBatchTRXAddendaRecords(b *testing.B) {
 // testBatchTRXReceivingCompany validates TRXReceivingCompany
 func testBatchTRXReceivingCompany(t testing.TB) {
 	mockBatch := mockBatchTRX(t)
-	//mockBatch.GetEntries()[0].SetCATXReceivingCompany("Receiver")
-
-	if mockBatch.GetEntries()[0].CATXReceivingCompanyField() != "Receiver Company" {
-		t.Errorf("expected %v got %v", "Receiver Company", mockBatch.GetEntries()[0].CATXReceivingCompanyField())
-	}
+	mockBatch.GetEntries()[0].SetCATXReceivingCompany("Receiver Company")
+	require.Equal(t, "Receiver Company  ", mockBatch.GetEntries()[0].CATXReceivingCompanyField())
 }
 
 // TestBatchTRXReceivingCompany tests validating TRXReceivingCompany
diff --git a/test/issues/issue1208_test.go b/test/issues/issue1208_test.go
new file mode 100644
index 00000000..4d95369a
--- /dev/null
+++ b/test/issues/issue1208_test.go
@@ -0,0 +1,103 @@
+// Licensed to The Moov Authors under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. The Moov Authors licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package issues
+
+import (
+	"testing"
+
+	"github.com/moov-io/ach"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestIssue1208(t *testing.T) {
+	batchHeader := ach.NewBatchHeader()
+	batchHeader.ServiceClassCode = 200
+	batchHeader.CompanyDiscretionaryData = "1"
+	batchHeader.CompanyName = "2"
+	batchHeader.CompanyIdentification = "3"
+	batchHeader.StandardEntryClassCode = "CTX"
+	batchHeader.CompanyEntryDescription = "5"
+	batchHeader.CompanyDescriptiveDate = "6"
+	batchHeader.EffectiveEntryDate = "7"
+	batchHeader.ODFIIdentification = "8"
+
+	batch, err := ach.NewBatch(batchHeader)
+	require.NoError(t, err)
+
+	batch.SetValidation(&ach.ValidateOpts{
+		CustomTraceNumbers: true,
+	})
+
+	entryDetail := ach.NewEntryDetail()
+
+	// Verify some helpers for CTX
+	require.Equal(t, "", entryDetail.CATXAddendaRecordsField())
+	require.Equal(t, "", entryDetail.CATXReceivingCompanyField())
+	require.Equal(t, "", entryDetail.IndividualName)
+
+	// Set CTX examples from Github issue
+	entryDetail.TransactionCode = 26
+	entryDetail.RDFIIdentification = "87654321"
+	entryDetail.CheckDigit = "2"
+	entryDetail.DFIAccountNumber = "123456789"
+	entryDetail.Amount = 123
+	entryDetail.IdentificationNumber = "10"
+	// CTX uses characters 1-4 of IndividualName to store AddendaRecords count
+	entryDetail.SetCATXAddendaRecords(0)
+	entryDetail.SetCATXReceivingCompany("Whatevs")
+	entryDetail.DiscretionaryData = "DD"
+	entryDetail.TraceNumber = "500"
+	entryDetail.Category = ach.CategoryForward
+	require.Equal(t, "0000", entryDetail.CATXAddendaRecordsField())
+	require.Equal(t, "Whatevs           ", entryDetail.CATXReceivingCompanyField())
+	require.Equal(t, "0000Whatevs           ", entryDetail.IndividualName)
+
+	batch.AddEntry(entryDetail)
+
+	require.NoError(t, batch.Create())
+	require.NoError(t, batch.Validate())
+
+	addenda99 := ach.NewAddenda99()
+	addenda99.ReturnCode = "R05"
+	addenda99.OriginalTrace = "500"
+	addenda99.TraceNumber = "600"
+	addenda99.OriginalDFI = "12345678"
+	entryDetail.Addenda99 = addenda99
+	entryDetail.SetCATXAddendaRecords(1)
+	entryDetail.Category = ach.CategoryReturn
+	require.Equal(t, "0001", entryDetail.CATXAddendaRecordsField())
+	require.Equal(t, "Whatevs           ", entryDetail.CATXReceivingCompanyField())
+	require.Equal(t, "0001Whatevs           ", entryDetail.IndividualName)
+
+	require.NoError(t, batch.Create())
+	require.NoError(t, batch.Validate())
+
+	addenda05 := ach.NewAddenda05()
+	addenda05.SequenceNumber = 1
+	addenda05.PaymentRelatedInformation = "ASC X12 Data"
+	addenda05.EntryDetailSequenceNumber = 0000001
+	entryDetail.AddAddenda05(addenda05)
+	entryDetail.SetCATXAddendaRecords(2)
+	require.Equal(t, "0002", entryDetail.CATXAddendaRecordsField())
+	require.Equal(t, "Whatevs           ", entryDetail.CATXReceivingCompanyField())
+	require.Equal(t, "0002Whatevs           ", entryDetail.IndividualName)
+
+	require.NoError(t, batch.Create())
+	require.NoError(t, batch.Validate())
+}