Repository: moov-io/ach
Pull request #1470 (2024-08-20 18:04:37)

fix: cleanup ENR parsing and generation, add tests


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bc94300..0407fc2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+## v1.42.0 (Released 2024-08-20)
+
+BREAKING CHANGES
+
+This release of moov-io/ach adjusts the type of `ENRPaymentInformation.EnrolleeClassificationCode` to be a string (was int). This change is needed to properly support the values "A" (consumer) and "B" (company).
+
+IMPROVEMENTS
+
+- fix: cleanup ENR parsing and generation, add tests
+
 ## v1.41.1 (Released 2024-08-14)
 
 IMPROVEMENTS
diff --git a/batchENR.go b/batchENR.go
index bf15318c..3b2f5279 100644
--- a/batchENR.go
+++ b/batchENR.go
@@ -21,6 +21,7 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"unicode/utf8"
 )
 
 // BatchENR is a non-monetary entry that enrolls a person with an agency of the US government
@@ -128,12 +129,40 @@ type ENRPaymentInformation struct {
 	//  1: (yes) - Initiated by someone other than named beneficiary
 	//  A: Enrollee is a consumer
 	//  b: Enrollee is a company
-	EnrolleeClassificationCode int
+	EnrolleeClassificationCode string
 }
 
 func (info *ENRPaymentInformation) String() string {
-	line := "TransactionCode: %d, RDFIIdentification: %s, CheckDigit: %s, DFIAccountNumber: %s, IndividualIdentification: %v, IndividualName: %s, EnrolleeClassificationCode: %d"
-	return fmt.Sprintf(line, info.TransactionCode, info.RDFIIdentification, info.CheckDigit, info.DFIAccountNumber, info.IndividualIdentification != "", info.IndividualName, info.EnrolleeClassificationCode)
+	// Stretch the companies name across two fields
+	var individualName string
+	if strings.EqualFold(info.EnrolleeClassificationCode, "B") {
+		// First fifteen characters are added
+		individualName = strings.TrimSpace(fmt.Sprintf("%15.15s", info.IndividualName)) + "*"
+
+		// Add on a second field if needed
+		runes := utf8.RuneCountInString(info.IndividualName)
+		if runes > 15 {
+			individualName += strings.TrimSpace(fmt.Sprintf("%7.7s", info.IndividualName[15:]))
+		}
+	} else {
+		// Format the Individual's name by Surname first
+		nameParts := strings.Fields(info.IndividualName)
+
+		if len(nameParts) > 1 {
+			// Surname comes fist
+			nameParts = append(nameParts[len(nameParts)-1:], nameParts[:len(nameParts)-1]...)
+		}
+		individualName = strings.Join(nameParts, "*")
+	}
+
+	return fmt.Sprintf(`%v*%v*%v*%v*%v*%v*%v\`,
+		info.TransactionCode,
+		info.RDFIIdentification,
+		info.CheckDigit,
+		info.DFIAccountNumber,
+		info.IndividualIdentification,
+		individualName,
+		info.EnrolleeClassificationCode)
 }
 
 // ParsePaymentInformation returns an ENRPaymentInformation for a given Addenda05 record. The information is parsed from the addenda's
@@ -150,9 +179,13 @@ func (batch *BatchENR) ParsePaymentInformation(addenda05 *Addenda05) (*ENRPaymen
 	if err != nil {
 		return nil, fmt.Errorf("ENR: unable to parse TransactionCode (%s) from Addenda05.ID=%s", parts[0], addenda05.ID)
 	}
-	enrolleeCode, err := strconv.Atoi(parts[7])
-	if err != nil {
-		return nil, fmt.Errorf("ENR: unable to parse EnrolleeClassificationCode (%s) from Addenda05.ID=%s", parts[7], addenda05.ID)
+
+	enrolleeClassificationCode := parts[7]
+
+	individualName := fmt.Sprintf("%s %s", parts[6], parts[5])
+	if strings.EqualFold(enrolleeClassificationCode, "B") {
+		// Business Names can be fill two field lengths
+		individualName = fmt.Sprintf("%s%s", parts[5], parts[6])
 	}
 
 	return &ENRPaymentInformation{
@@ -161,7 +194,7 @@ func (batch *BatchENR) ParsePaymentInformation(addenda05 *Addenda05) (*ENRPaymen
 		CheckDigit:                 parts[2],
 		DFIAccountNumber:           parts[3],
 		IndividualIdentification:   parts[4],
-		IndividualName:             fmt.Sprintf("%s %s", parts[6], parts[5]),
-		EnrolleeClassificationCode: enrolleeCode,
+		IndividualName:             individualName,
+		EnrolleeClassificationCode: enrolleeClassificationCode,
 	}, nil
 }
diff --git a/docs/Gemfile b/docs/Gemfile
index edbad2de..dc3f49d9 100644
--- a/docs/Gemfile
+++ b/docs/Gemfile
@@ -25,4 +25,4 @@ platforms :mingw, :x64_mingw, :mswin, :jruby do
 end
 
 # Performance-booster for watching directories on Windows
-gem "wdm", "~> 0.2.0", :platforms => [:mingw, :x64_mingw, :mswin]
+gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin]
diff --git a/go.mod b/go.mod
index df967f3d..a106ae07 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/juju/ansiterm v1.0.0
 	github.com/moov-io/base v0.51.1
 	github.com/moov-io/iso4217 v0.3.0
-	github.com/prometheus/client_golang v1.20.0
+	github.com/prometheus/client_golang v1.19.1
 	github.com/stretchr/testify v1.9.0
 	golang.org/x/net v0.28.0
 	golang.org/x/sync v0.8.0
@@ -24,7 +24,6 @@ require (
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
-	github.com/klauspost/compress v1.17.9 // indirect
 	github.com/lunixbochs/vtclean v1.0.0 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/go.sum b/go.sum
index fba634b4..97a6a779 100644
--- a/go.sum
+++ b/go.sum
@@ -26,16 +26,12 @@ github.com/igrmk/treemap/v2 v2.0.1 h1:Jhy4z3yhATvYZMWCmxsnHO5NnNZBdueSzvxh6353l+
 github.com/igrmk/treemap/v2 v2.0.1/go.mod h1:PkTPvx+8OHS8/41jnnyVY+oVsfkaOUZGcr+sfonosd4=
 github.com/juju/ansiterm v1.0.0 h1:gmMvnZRq7JZJx6jkfSq9/+2LMrVEwGwt7UR6G+lmDEg=
 github.com/juju/ansiterm v1.0.0/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
 github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
 github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -54,8 +50,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI=
-github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
+github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
 github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
 github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
 github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=


Test patch:
diff --git a/batchENR_test.go b/batchENR_test.go
index 3500b11c..9d0f5891 100644
--- a/batchENR_test.go
+++ b/batchENR_test.go
@@ -21,6 +21,7 @@ import (
 	"testing"
 
 	"github.com/moov-io/base"
+	"github.com/stretchr/testify/require"
 )
 
 // mockBatchENRHeader creates a ENR batch header
@@ -289,9 +290,85 @@ func TestBatchENR__PaymentInformation(t *testing.T) {
 	if v := info.IndividualName; v != "JOHN DOE" {
 		t.Errorf("IndividualName: %s", v)
 	}
-	if v := info.EnrolleeClassificationCode; v != 1 {
-		t.Errorf("EnrolleeClassificationCode: %d", v)
+	if v := info.EnrolleeClassificationCode; v != "1" {
+		t.Errorf("EnrolleeClassificationCode: %v", v)
 	}
+
+	b := &BatchENR{}
+	addenda05 = NewAddenda05()
+
+	t.Run("Nacha Examples", func(t *testing.T) {
+		// Consumer
+		input := `22*12200004*3*123987654321*777777777*DOE*JOHN*0\`
+		expected := &ENRPaymentInformation{
+			TransactionCode:            22,
+			RDFIIdentification:         "12200004",
+			CheckDigit:                 "3",
+			DFIAccountNumber:           "123987654321",
+			IndividualIdentification:   "777777777",
+			IndividualName:             "JOHN DOE",
+			EnrolleeClassificationCode: "0",
+		}
+		addenda05.PaymentRelatedInformation = input
+		parsed, err := b.ParsePaymentInformation(addenda05)
+		require.NoError(t, err)
+		require.Equal(t, expected, parsed)
+		require.Equal(t, input, parsed.String())
+
+		// Consumer
+		input = `27*12200004*3*123987654321*777777777*DOE*JOHN*A\`
+		addenda05.PaymentRelatedInformation = input
+		parsed, err = b.ParsePaymentInformation(addenda05)
+		require.NoError(t, err)
+
+		expected = &ENRPaymentInformation{
+			TransactionCode:            27,
+			RDFIIdentification:         "12200004",
+			CheckDigit:                 "3",
+			DFIAccountNumber:           "123987654321",
+			IndividualIdentification:   "777777777",
+			IndividualName:             "JOHN DOE",
+			EnrolleeClassificationCode: "A",
+		}
+		require.Equal(t, expected, parsed)
+		require.Equal(t, input, parsed.String())
+
+		// Company
+		input = `27*12200004*3*987654321123*876543210*ABCELECTRONICIN*DUSTRIE*B\`
+		addenda05.PaymentRelatedInformation = input
+		parsed, err = b.ParsePaymentInformation(addenda05)
+		require.NoError(t, err)
+
+		expected = &ENRPaymentInformation{
+			TransactionCode:            27,
+			RDFIIdentification:         "12200004",
+			CheckDigit:                 "3",
+			DFIAccountNumber:           "987654321123",
+			IndividualIdentification:   "876543210",
+			IndividualName:             "ABCELECTRONICINDUSTRIE",
+			EnrolleeClassificationCode: "B",
+		}
+		require.Equal(t, expected, parsed)
+		require.Equal(t, input, parsed.String())
+
+		// Company
+		input = `27*12200004*3*987654321123*876543210*ELECTRIC**B\`
+		addenda05.PaymentRelatedInformation = input
+		parsed, err = b.ParsePaymentInformation(addenda05)
+		require.NoError(t, err)
+
+		expected = &ENRPaymentInformation{
+			TransactionCode:            27,
+			RDFIIdentification:         "12200004",
+			CheckDigit:                 "3",
+			DFIAccountNumber:           "987654321123",
+			IndividualIdentification:   "876543210",
+			IndividualName:             "ELECTRIC",
+			EnrolleeClassificationCode: "B",
+		}
+		require.Equal(t, expected, parsed)
+		require.Equal(t, input, parsed.String())
+	})
 }
 
 // TestBatchENRValidTranCodeForServiceClassCode validates a transactionCode based on ServiceClassCode