Repository: moov-io/ach
Pull request #1162 (2023-01-12 06:29:08)

Add 'PreserveSpaces' opt
This is driven by [this issue from a few days ago](https://github.com/moov-io/ach/issues/1159)

- Added `SetValidation` to `BatchControl` so opts could be set before parsing
- Added `parseStringFieldWithOpts` to `converters` struct
- Modified parsing for `FileHeader`, `BatchHeader`, and `BatchControl`  to respect the new opt where spaces were normally trimmed
- Modified the parsing logic for `EntryDetail` to not trim spaces for RDFI, CheckDigit, and Trace Number (I believe these all need numeric values and use every character to be considered valid anyway, but please correct me if I'm wrong)


Patch:
diff --git a/batchControl.go b/batchControl.go
index 0c14819b..23e4f99e 100644
--- a/batchControl.go
+++ b/batchControl.go
@@ -79,6 +79,15 @@ type BatchControl struct {
 	validator
 	// converters is composed for ACH to golang Converters
 	converters
+
+	validateOpts *ValidateOpts
+}
+
+func (bc *BatchControl) SetValidation(opts *ValidateOpts) {
+	if bc == nil {
+		return
+	}
+	bc.validateOpts = opts
 }
 
 // Parse takes the input record string and parses the EntryDetail values
@@ -102,12 +111,12 @@ func (bc *BatchControl) Parse(record string) {
 	// 33-44 Number of cents of credit entries within the batch
 	bc.TotalCreditEntryDollarAmount = bc.parseNumField(record[32:44])
 	// 45-54 This is the same as the "Company identification" field in previous Batch Header Record
-	bc.CompanyIdentification = strings.TrimSpace(record[44:54])
+	bc.CompanyIdentification = bc.parseStringFieldWithOpts(record[44:54], bc.validateOpts)
 	// 55-73 Seems to always be blank
-	bc.MessageAuthenticationCode = strings.TrimSpace(record[54:73])
+	bc.MessageAuthenticationCode = bc.parseStringFieldWithOpts(record[54:73], bc.validateOpts)
 	// 74-79 Always blank (just fill with spaces)
 	// 80-87 This is the same as the "ODFI identification" field in previous Batch Header Record
-	bc.ODFIIdentification = bc.parseStringField(record[79:87])
+	bc.ODFIIdentification = bc.parseStringFieldWithOpts(record[79:87], bc.validateOpts)
 	// 88-94 This is the same as the "Batch number" field in previous Batch Header Record
 	bc.BatchNumber = bc.parseNumField(record[87:94])
 }
diff --git a/batchHeader.go b/batchHeader.go
index 429b66c2..8a2822ff 100644
--- a/batchHeader.go
+++ b/batchHeader.go
@@ -156,22 +156,22 @@ func (bh *BatchHeader) Parse(record string) {
 	// 2-4 MixedCreditsAnDebits (200), CreditsOnly (220), DebitsOnly (225)
 	bh.ServiceClassCode = bh.parseNumField(record[1:4])
 	// 5-20 Your company's name. This name may appear on the receivers' statements prepared by the RDFI.
-	bh.CompanyName = strings.TrimSpace(record[4:20])
+	bh.CompanyName = bh.parseStringFieldWithOpts(record[4:20], bh.validateOpts)
 	// 21-40 Optional field you may use to describe the batch for internal accounting purposes
-	bh.CompanyDiscretionaryData = strings.TrimSpace(record[20:40])
+	bh.CompanyDiscretionaryData = bh.parseStringFieldWithOpts(record[20:40], bh.validateOpts)
 	// 41-50 A 10-digit number assigned to you by the ODFI once they approve you to
 	// originate ACH files through them. This is the same as the "Immediate origin" field in File Header Record
-	bh.CompanyIdentification = strings.TrimSpace(record[40:50])
+	bh.CompanyIdentification = bh.parseStringFieldWithOpts(record[40:50], bh.validateOpts)
 	// 51-53 If the entries are PPD (credits/debits towards consumer account), use PPD.
 	// If the entries are CCD (credits/debits towards corporate account), use CCD.
 	// The difference between the 2 SEC codes are outside of the scope of this post.
 	bh.StandardEntryClassCode = record[50:53]
 	// 54-63 Your description of the transaction. This text will appear on the receivers' bank statement.
 	// For example: "Payroll   "
-	bh.CompanyEntryDescription = strings.TrimSpace(record[53:63])
+	bh.CompanyEntryDescription = bh.parseStringFieldWithOpts(record[53:63], bh.validateOpts)
 	// 64-69 The date you choose to identify the transactions in YYMMDD format.
 	// This date may be printed on the receivers' bank statement by the RDFI
-	bh.CompanyDescriptiveDate = strings.TrimSpace(record[63:69])
+	bh.CompanyDescriptiveDate = bh.parseStringFieldWithOpts(record[63:69], bh.validateOpts)
 	// 70-75 Date transactions are to be posted to the receivers' account.
 	// You almost always want the transaction to post as soon as possible, so put tomorrow's date in YYMMDD format
 	bh.EffectiveEntryDate = bh.validateSimpleDate(record[69:75])
@@ -182,7 +182,7 @@ func (bh *BatchHeader) Parse(record string) {
 	bh.OriginatorStatusCode = bh.parseNumField(record[78:79])
 	// 80-87 Your ODFI's routing number without the last digit. The last digit is simply a
 	// checksum digit, which is why it is not necessary
-	bh.ODFIIdentification = bh.parseStringField(record[79:87])
+	bh.ODFIIdentification = bh.parseStringFieldWithOpts(record[79:87], bh.validateOpts)
 	// 88-94 Sequential number of this Batch Header Record
 	// For example, put "1" if this is the first Batch Header Record in the file
 	bh.BatchNumber = bh.parseNumField(record[87:94])
diff --git a/converters.go b/converters.go
index a7cca040..b5b21567 100644
--- a/converters.go
+++ b/converters.go
@@ -37,6 +37,14 @@ func (c *converters) parseStringField(r string) (s string) {
 	return s
 }
 
+func (c *converters) parseStringFieldWithOpts(r string, opts *ValidateOpts) string {
+	if opts != nil && opts.PreserveSpaces {
+		return r
+	} else {
+		return c.parseStringField(r)
+	}
+}
+
 // formatSimpleDate takes a YYMMDD date and formats it for the fixed-width ACH file format
 func (c *converters) formatSimpleDate(s string) string {
 	if s == "" {
diff --git a/docs/create-file.md b/docs/create-file.md
index 07efb36f..8b9af3dc 100644
--- a/docs/create-file.md
+++ b/docs/create-file.md
@@ -58,6 +58,7 @@ Example: `POST /files/create?requireABAOrigin=true&bypassDestination=true`
 | `unequalAddendaCounts`             | `UnequalAddendaCounts`             |
 | `unequalServiceClassCode`          | `UnequalServiceClassCode`          |
 | `unorderedBatchNumbers`            | `AllowUnorderedBatchNumbers`       |
+| `preserveSpaces`                   | `PreserveSpaces`                   |
 
 ## Upload a raw ACH file
 
diff --git a/entryDetail.go b/entryDetail.go
index 67265c16..b597f15d 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -204,9 +204,9 @@ func (ed *EntryDetail) Parse(record string) {
 	// 2-3 is checking credit 22 debit 27 savings credit 32 debit 37
 	ed.TransactionCode = ed.parseNumField(record[1:3])
 	// 4-11 the RDFI's routing number without the last digit.
-	ed.RDFIIdentification = ed.parseStringField(record[3:11])
+	ed.RDFIIdentification = record[3:11]
 	// 12-12 The last digit of the RDFI's routing number
-	ed.CheckDigit = ed.parseStringField(record[11:12])
+	ed.CheckDigit = record[11:12]
 	// 13-29 The receiver's bank account number you are crediting/debiting
 	ed.DFIAccountNumber = record[12:29]
 	// 30-39 Number of cents you are debiting/crediting this account
@@ -216,14 +216,14 @@ func (ed *EntryDetail) Parse(record string) {
 	// 55-76 The name of the receiver, usually the name on the bank account
 	ed.IndividualName = record[54:76]
 	// 77-78 allows ODFIs to include codes of significance only to them, normally blank
-	//
+
 	// For WEB and TEL batches this field is the PaymentType which is either R(reoccurring) or S(single)
 	ed.DiscretionaryData = record[76:78]
 	// 79-79 1 if addenda exists 0 if it does not
 	ed.AddendaRecordIndicator = ed.parseNumField(record[78:79])
-	// 80-94 An internal identification (alphanumeric) that you use to uniquely identify
+	// 80-94 An internal identification (numeric) that you use to uniquely identify
 	// this Entry Detail Record This number should be unique to the transaction and will help identify the transaction in case of an inquiry
-	ed.TraceNumber = strings.TrimSpace(record[79:94])
+	ed.TraceNumber = record[79:94]
 }
 
 // String writes the EntryDetail struct to a 94 character string.
diff --git a/file.go b/file.go
index bfaa77f4..8cd2c7a5 100644
--- a/file.go
+++ b/file.go
@@ -681,6 +681,9 @@ type ValidateOpts struct {
 
 	// UnequalAddendaCounts skips checking that Addenda Count fields match their expected and computed values.
 	UnequalAddendaCounts bool `json:"unequalAddendaCounts"`
+
+	// PreserveSpaces keeps the spacing before and after values that normally have spaces trimmed during parsing.
+	PreserveSpaces bool `json:"preserveSpaces"`
 }
 
 // ValidateWith performs checks on each record according to Nacha guidelines.
diff --git a/fileHeader.go b/fileHeader.go
index 12649398..b134804f 100644
--- a/fileHeader.go
+++ b/fileHeader.go
@@ -143,11 +143,11 @@ func (fh *FileHeader) Parse(record string) {
 	// 40 always "1"
 	fh.formatCode = "1"
 	// 41-63 The name of the ODFI. example "SILICON VALLEY BANK    "
-	fh.ImmediateDestinationName = strings.TrimSpace(record[40:63])
+	fh.ImmediateDestinationName = fh.parseStringFieldWithOpts(record[40:63], fh.validateOpts)
 	// 64-86 ACH operator or sending point that is sending the file
-	fh.ImmediateOriginName = strings.TrimSpace(record[63:86])
+	fh.ImmediateOriginName = fh.parseStringFieldWithOpts(record[63:86], fh.validateOpts)
 	// 97-94 Optional field that may be used to describe the ACH file for internal accounting purposes
-	fh.ReferenceCode = strings.TrimSpace(record[86:94])
+	fh.ReferenceCode = fh.parseStringFieldWithOpts(record[86:94], fh.validateOpts)
 }
 
 func trimRoutingNumberLeadingZero(s string) string {
diff --git a/reader.go b/reader.go
index eb983731..524d05c2 100644
--- a/reader.go
+++ b/reader.go
@@ -387,6 +387,7 @@ func (r *Reader) parseBatchHeader() error {
 
 	// Ensure we have a valid batch header before building a batch.
 	bh := NewBatchHeader()
+	bh.SetValidation(r.File.validateOpts)
 	bh.Parse(r.line)
 	if err := maybeValidate(bh, r.File.validateOpts); err != nil {
 		return r.parseError(err)
@@ -411,8 +412,8 @@ func (r *Reader) parseEntryDetail() error {
 	}
 	if r.currentBatch.GetHeader().StandardEntryClassCode != ADV {
 		ed := NewEntryDetail()
-		ed.Parse(r.line)
 		ed.SetValidation(r.File.validateOpts)
+		ed.Parse(r.line)
 		if err := maybeValidate(ed, r.File.validateOpts); err != nil {
 			return r.parseError(err)
 		}
@@ -547,6 +548,7 @@ func (r *Reader) parseBatchControl() error {
 				return r.parseError(err)
 			}
 		} else {
+			r.currentBatch.GetControl().SetValidation(r.File.validateOpts)
 			r.currentBatch.GetControl().Parse(r.line)
 			if err := maybeValidate(r.currentBatch.GetControl(), r.File.validateOpts); err != nil {
 				return r.parseError(err)
diff --git a/server/files.go b/server/files.go
index 12a456ae..79c1d7ea 100644
--- a/server/files.go
+++ b/server/files.go
@@ -137,6 +137,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
 		unorderedBatchNumbers            = "unorderedBatchNumbers"
 		allowInvalidCheckDigit           = "allowInvalidCheckDigit"
 		unequalAddendaCounts             = "unequalAddendaCounts"
+		preserveSpaces                   = "preserveSpaces"
 	)
 
 	validationNames := []string{
@@ -154,6 +155,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
 		unorderedBatchNumbers,
 		allowInvalidCheckDigit,
 		unequalAddendaCounts,
+		preserveSpaces,
 	}
 
 	for _, name := range validationNames {
@@ -199,6 +201,8 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
 			req.validateOpts.AllowInvalidCheckDigit = true
 		case unequalAddendaCounts:
 			req.validateOpts.UnequalAddendaCounts = true
+		case preserveSpaces:
+			req.validateOpts.PreserveSpaces = true
 		}
 	}
 


Test patch:
diff --git a/batchControl_test.go b/batchControl_test.go
index 80df23ed..94cc2d10 100644
--- a/batchControl_test.go
+++ b/batchControl_test.go
@@ -315,3 +315,24 @@ func BenchmarkBatchControlLength(b *testing.B) {
 		testBatchControlLength(b)
 	}
 }
+
+func TestBatchControl__SetValidation(t *testing.T) {
+	bh := NewBatchControl()
+	bh.SetValidation(&ValidateOpts{})
+
+	// nil out
+	bh = nil
+	bh.SetValidation(&ValidateOpts{})
+}
+
+func TestBatchControl__IsValidWithPreserveSpacesOpt(t *testing.T) {
+	var line = "82200000010023138010000000000000000100000000231380104                          121042880000001"
+	bc := NewBatchControl()
+	bc.SetValidation(&ValidateOpts{
+		PreserveSpaces: true,
+	})
+	bc.Parse(line)
+	if err := bc.Validate(); err != nil {
+		t.Error(err)
+	}
+}
diff --git a/batchHeader_test.go b/batchHeader_test.go
index 97a3395f..d5de6a9f 100644
--- a/batchHeader_test.go
+++ b/batchHeader_test.go
@@ -523,3 +523,15 @@ func TestBatchHeader__SetValidation(t *testing.T) {
 	bh = nil
 	bh.SetValidation(&ValidateOpts{})
 }
+
+func TestBatchHeader__IsValidWithPreserveSpacesOpt(t *testing.T) {
+	var line = "5220Name on Account                     231380104 CCDVndr Pay        190816   1031300010000001"
+	bh := NewBatchHeader()
+	bh.SetValidation(&ValidateOpts{
+		PreserveSpaces: true,
+	})
+	bh.Parse(line)
+	if err := bh.Validate(); err != nil {
+		t.Error(err)
+	}
+}
diff --git a/converters_test.go b/converters_test.go
index 2231a0cb..f6cb7b49 100644
--- a/converters_test.go
+++ b/converters_test.go
@@ -161,6 +161,34 @@ func BenchmarkParseStringField(b *testing.B) {
 	}
 }
 
+func TestParseStringFieldWithOpts__PreservesSpacesWithOptTrue(t *testing.T) {
+	c := converters{}
+	opts := ValidateOpts{
+		PreserveSpaces: true,
+	}
+
+	expected := " 012345"
+
+	result := c.parseStringFieldWithOpts(" 012345", &opts)
+	if result != expected {
+		t.Errorf("Expected: '%v', Actual: '%v'", expected, result)
+	}
+}
+
+func TestParseStringFieldWithOpts__DoesntPreserveSpacesWithOptFalse(t *testing.T) {
+	c := converters{}
+	opts := ValidateOpts{
+		PreserveSpaces: false,
+	}
+
+	expected := "012345"
+
+	result := c.parseStringFieldWithOpts(" 012345", &opts)
+	if result != expected {
+		t.Errorf("Expected: '%v', Actual: '%v'", expected, result)
+	}
+}
+
 // testRTNFieldShort ensures zero padding and right justified
 func testRTNFieldShort(t testing.TB) {
 	c := converters{}
diff --git a/fileHeader_test.go b/fileHeader_test.go
index 8d4d24a6..625f70e1 100644
--- a/fileHeader_test.go
+++ b/fileHeader_test.go
@@ -793,3 +793,15 @@ func TestFileHeader__ValidateDestination(t *testing.T) {
 		t.Error(err)
 	}
 }
+
+func TestFileHeader__IsValidWithPreserveSpacesOpt(t *testing.T) {
+	var line = "101 076401251 0764012511807291511A094101achdestname            companyname                    "
+	fh := NewFileHeader()
+	fh.SetValidation(&ValidateOpts{
+		PreserveSpaces: true,
+	})
+	fh.Parse(line)
+	if err := fh.Validate(); err != nil {
+		t.Error(err)
+	}
+}
diff --git a/reader_test.go b/reader_test.go
index c439b0a7..429f7fdc 100644
--- a/reader_test.go
+++ b/reader_test.go
@@ -2048,3 +2048,37 @@ func TestReadFile_SkipValidation(t *testing.T) {
 	output := strings.TrimSpace(buf.String())
 	require.Equal(t, string(bs), output)
 }
+
+func TestReadFile_PreserveSpacesOptEnabled(t *testing.T) {
+	path := filepath.Join("test", "testdata", "ppd-debit.ach")
+	fd, err := os.Open(path)
+	if err != nil {
+		t.Errorf("problem reading %s: %v", path, err)
+	}
+	defer fd.Close()
+
+	reader := NewReader(fd)
+	reader.SetValidation(&ValidateOpts{PreserveSpaces: true})
+	file, err := reader.Read()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expectedImmediateDestinationName := "Federal Reserve Bank   "
+
+	if fileHeaderImmediateDestinationName := file.Header.ImmediateDestinationName; fileHeaderImmediateDestinationName != expectedImmediateDestinationName {
+		t.Errorf("Expected Immediate Destination Name: '%s', Actual: '%s'", expectedImmediateDestinationName, fileHeaderImmediateDestinationName)
+	}
+
+	expectedCompanyId := "121042882 "
+
+	batch := file.Batches[0]
+
+	if batchHeaderCompanyId := batch.GetHeader().CompanyIdentification; batchHeaderCompanyId != expectedCompanyId {
+		t.Errorf("Expected company id: '%s', Actual: '%s'", expectedCompanyId, batchHeaderCompanyId)
+	}
+
+	if batchControlCompanyId := batch.GetControl().CompanyIdentification; batchControlCompanyId != expectedCompanyId {
+		t.Errorf("Expected company id: '%s', Actual: '%s'", expectedCompanyId, batchControlCompanyId)
+	}
+}
diff --git a/server/files_test.go b/server/files_test.go
index 2d825d46..6c75306f 100644
--- a/server/files_test.go
+++ b/server/files_test.go
@@ -249,6 +249,12 @@ func TestFiles__decodeCreateFileRequest__validateOpts(t *testing.T) {
 				AllowInvalidCheckDigit: true,
 			},
 		},
+		{
+			query: "?preserveSpaces=true",
+			expect: ach.ValidateOpts{
+				PreserveSpaces: true,
+			},
+		},
 	}
 
 	for _, tc := range tests {