Repository: moov-io/ach
Pull request #1085 (2022-09-19 17:22:21)

feat: add ValidateOpts for IATBatch and IATEntryDetail
Fixes: https://github.com/moov-io/ach/issues/1084


Patch:
diff --git a/file.go b/file.go
index 158815f4..e28646d8 100644
--- a/file.go
+++ b/file.go
@@ -356,6 +356,8 @@ func (f *File) setBatchesFromJSON(bs []byte) error {
 
 		iatBatch := iatBatches.IATBatches[i]
 		iatBatch.ID = iatBatch.Header.ID
+		iatBatch.SetValidation(f.validateOpts)
+
 		for _, e := range iatBatch.Entries {
 			setIATEntryRecordType(e)
 		}
diff --git a/iatBatch.go b/iatBatch.go
index 25fe7d04..2df6642d 100644
--- a/iatBatch.go
+++ b/iatBatch.go
@@ -42,6 +42,8 @@ type IATBatch struct {
 	category string
 	// Converters is composed for ACH to GoLang Converters
 	converters
+
+	validateOpts *ValidateOpts
 }
 
 // NewIATBatch takes a BatchHeader and returns a matching SEC code batch type that is a batcher. Returns an error if the SEC code is not supported.
@@ -90,11 +92,14 @@ func (iatBatch *IATBatch) verify() error {
 		// wrap the field error in to a batch error for a consistent api
 		return iatBatch.Error("FieldError", err)
 	}
+
 	// validate batch header and control codes are the same
-	if iatBatch.Header.ServiceClassCode != iatBatch.Control.ServiceClassCode {
+	if (iatBatch.validateOpts == nil || !iatBatch.validateOpts.UnequalServiceClassCode) &&
+		iatBatch.Header.ServiceClassCode != iatBatch.Control.ServiceClassCode {
 		return iatBatch.Error("ServiceClassCode",
 			NewErrBatchHeaderControlEquality(iatBatch.Header.ServiceClassCode, iatBatch.Control.ServiceClassCode))
 	}
+
 	// Control ODFIIdentification must be the same as batch header
 	if iatBatch.Header.ODFIIdentification != iatBatch.Control.ODFIIdentification {
 		return iatBatch.Error("ODFIIdentification",
@@ -108,8 +113,10 @@ func (iatBatch *IATBatch) verify() error {
 	if err := iatBatch.isBatchEntryCount(); err != nil {
 		return err
 	}
-	if err := iatBatch.isSequenceAscending(); err != nil {
-		return err
+	if iatBatch.validateOpts == nil || !iatBatch.validateOpts.CustomTraceNumbers {
+		if err := iatBatch.isSequenceAscending(); err != nil {
+			return err
+		}
 	}
 	if err := iatBatch.isBatchAmount(); err != nil {
 		return err
@@ -117,11 +124,13 @@ func (iatBatch *IATBatch) verify() error {
 	if err := iatBatch.isEntryHash(); err != nil {
 		return err
 	}
-	if err := iatBatch.isTraceNumberODFI(); err != nil {
-		return err
-	}
-	if err := iatBatch.isAddendaSequence(); err != nil {
-		return err
+	if iatBatch.validateOpts == nil || !iatBatch.validateOpts.CustomTraceNumbers {
+		if err := iatBatch.isTraceNumberODFI(); err != nil {
+			return err
+		}
+		if err := iatBatch.isAddendaSequence(); err != nil {
+			return err
+		}
 	}
 	if err := iatBatch.isCategory(); err != nil {
 		return err
@@ -169,7 +178,14 @@ func (iatBatch *IATBatch) build() error {
 
 		// Add a sequenced TraceNumber if one is not already set.
 		if currentTraceNumberODFI != batchHeaderODFI {
-			iatBatch.Entries[i].SetTraceNumber(iatBatch.Header.ODFIIdentification, seq)
+			if opts := iatBatch.validateOpts; opts == nil {
+				iatBatch.Entries[i].SetTraceNumber(iatBatch.Header.ODFIIdentification, seq)
+			} else {
+				// Automatically set the TraceNumber if we are validating Origin and don't have custom trace numbers
+				if !opts.BypassOriginValidation && !opts.CustomTraceNumbers {
+					iatBatch.Entries[i].SetTraceNumber(iatBatch.Header.ODFIIdentification, seq)
+				}
+			}
 		}
 
 		if entry.Category != CategoryNOC {
@@ -378,8 +394,10 @@ func (iatBatch *IATBatch) calculateBatchAmounts() (credit int, debit int) {
 func (iatBatch *IATBatch) isSequenceAscending() error {
 	lastSeq := "-1"
 	for _, entry := range iatBatch.Entries {
-		if entry.TraceNumber <= lastSeq {
-			return iatBatch.Error("TraceNumber", NewErrBatchAscending(lastSeq, entry.TraceNumber))
+		if iatBatch.validateOpts == nil || !iatBatch.validateOpts.CustomTraceNumbers {
+			if entry.TraceNumber <= lastSeq {
+				return iatBatch.Error("TraceNumber", NewErrBatchAscending(lastSeq, entry.TraceNumber))
+			}
 		}
 		lastSeq = entry.TraceNumber
 	}
@@ -414,13 +432,15 @@ func (iatBatch *IATBatch) calculateEntryHash() int {
 // isTraceNumberODFI checks if the first 8 positions of the entry detail trace number
 // match the batch header ODFI
 func (iatBatch *IATBatch) isTraceNumberODFI() error {
+	if iatBatch.validateOpts != nil && iatBatch.validateOpts.BypassOriginValidation {
+		return nil
+	}
 	for _, entry := range iatBatch.Entries {
 		if iatBatch.Header.ODFIIdentificationField() != entry.TraceNumberField()[:8] {
 			return iatBatch.Error("ODFIIdentificationField",
 				NewErrBatchTraceNumberNotODFI(iatBatch.Header.ODFIIdentificationField(), entry.TraceNumberField()[:8]))
 		}
 	}
-
 	return nil
 }
 
@@ -592,3 +612,12 @@ func (iatBatch *IATBatch) Validate() error {
 	}
 	return nil
 }
+
+// SetValidation stores ValidateOpts on the Batch which are to be used to override
+// the default NACHA validation rules.
+func (iatBatch *IATBatch) SetValidation(opts *ValidateOpts) {
+	if iatBatch == nil {
+		return
+	}
+	iatBatch.validateOpts = opts
+}
diff --git a/iatEntryDetail.go b/iatEntryDetail.go
index ca4cc3b9..c20e1692 100644
--- a/iatEntryDetail.go
+++ b/iatEntryDetail.go
@@ -127,6 +127,8 @@ type IATEntryDetail struct {
 	validator
 	// converters is composed for ACH to golang Converters
 	converters
+
+	validateOpts *ValidateOpts
 }
 
 // NewIATEntryDetail returns a new IATEntryDetail with default values for non exported fields
@@ -193,14 +195,29 @@ func (iatEd *IATEntryDetail) String() string {
 	return buf.String()
 }
 
+// SetValidation stores ValidateOpts on the EntryDetail which are to be used to override
+// the default NACHA validation rules.
+func (iatEd *IATEntryDetail) SetValidation(opts *ValidateOpts) {
+	if iatEd == nil {
+		return
+	}
+	iatEd.validateOpts = opts
+}
+
 // Validate performs NACHA format rule checks on the record and returns an error if not Validated
 // The first error encountered is returned and stops that parsing.
 func (iatEd *IATEntryDetail) Validate() error {
 	if err := iatEd.fieldInclusion(); err != nil {
 		return err
 	}
-	if err := iatEd.isTransactionCode(iatEd.TransactionCode); err != nil {
-		return fieldError("TransactionCode", err, strconv.Itoa(iatEd.TransactionCode))
+	if iatEd.validateOpts != nil && iatEd.validateOpts.CheckTransactionCode != nil {
+		if err := iatEd.validateOpts.CheckTransactionCode(iatEd.TransactionCode); err != nil {
+			return fieldError("TransactionCode", err, strconv.Itoa(iatEd.TransactionCode))
+		}
+	} else {
+		if err := iatEd.isTransactionCode(iatEd.TransactionCode); err != nil {
+			return fieldError("TransactionCode", err, strconv.Itoa(iatEd.TransactionCode))
+		}
 	}
 	if err := iatEd.isAlphanumeric(iatEd.DFIAccountNumber); err != nil {
 		return fieldError("DFIAccountNumber", err, iatEd.DFIAccountNumber)
diff --git a/reader.go b/reader.go
index ac8884fb..8bb8b72e 100644
--- a/reader.go
+++ b/reader.go
@@ -289,6 +289,7 @@ func (r *Reader) parseLine() error {
 		} else {
 			batch := r.IATCurrentBatch
 			r.IATCurrentBatch = IATBatch{}
+			batch.SetValidation(r.File.validateOpts)
 			r.File.AddIATBatch(batch)
 			if err := batch.Validate(); err != nil {
 				r.recordName = "Batches"


Test patch:
diff --git a/iatBatch_test.go b/iatBatch_test.go
index 5bdb1c2a..ea7e6b2e 100644
--- a/iatBatch_test.go
+++ b/iatBatch_test.go
@@ -1994,3 +1994,65 @@ func TestDiscussion1077(t *testing.T) {
 	require.NoError(t, b.build())
 	require.Equal(t, 63399382, b.calculateEntryHash())
 }
+
+func TestIATBatch__isTraceNumberODFI(t *testing.T) {
+	iatBatch := mockIATBatch(t)
+
+	// Set invalid TraceNumber and ensure it's rejected
+	iatBatch.Entries[0].TraceNumber = "333333333333333" // invalid
+	if err := iatBatch.isTraceNumberODFI(); err == nil {
+		t.Error("expected error")
+	}
+	require.Error(t, iatBatch.Validate())
+
+	// Set a shorter trace number (0's for routing number) and
+	// ensure it's rejected as well.
+	iatBatch.Entries[0].TraceNumber = "3" // invalid
+	if err := iatBatch.isTraceNumberODFI(); err == nil {
+		t.Error("expected error")
+	}
+
+	// Allow the failure with an invalid TraceNumber
+	iatBatch.SetValidation(&ValidateOpts{
+		BypassOriginValidation: true,
+	})
+	require.ErrorContains(t, iatBatch.Validate(),
+		`TraceNumber 0000001 does not match proceeding entry detail trace number 0000003`)
+}
+
+func TestIATBatch__CustomTraceNumbers(t *testing.T) {
+	iatBatch := mockIATBatch(t)
+	iatBatch.SetValidation(&ValidateOpts{
+		BypassOriginValidation: false,
+		CustomTraceNumbers:     false,
+	})
+
+	for i := range iatBatch.Entries {
+		iatBatch.Entries[i].TraceNumber = "333344445"
+	}
+
+	if err := iatBatch.build(); err != nil {
+		t.Fatal(err)
+	}
+
+	if iatBatch.Entries[0].TraceNumber != "231380100000001" {
+		t.Errorf("unexpected trace number: %v", iatBatch.Entries[0].TraceNumber)
+	}
+}
+
+func TestIATBatchInvalidServiceClassCode(t *testing.T) {
+	iatBatch := mockIATBatch(t)
+	require.NoError(t, iatBatch.build())
+
+	iatBatch.Control.ServiceClassCode = DebitsOnly
+	err := iatBatch.verify()
+	if !base.Match(err, NewErrBatchHeaderControlEquality("280", "220")) {
+		t.Errorf("%T: %s", err, err)
+	}
+
+	iatBatch.SetValidation(&ValidateOpts{
+		UnequalServiceClassCode: true,
+	})
+	err = iatBatch.verify()
+	require.NoError(t, err)
+}