Repository: moov-io/ach
Pull request #1114 (2022-10-24 23:20:15)

Add new ValidateOpt option to allow invalid check digit in EntryDetail
@adamdecaf this is driven by [the issue from last week](https://github.com/moov-io/ach/issues/1107)

- Added AllowInvalidCheckDigit opt to ValidateOpt, and ensure we set validation for EntryDetail when being parsed by Reader.
- Added new ValidateOpt option to create-file.md
- Added tests to reader_test.go and entryDetail_test.go to check for errors when validating with new AllowInvalidCheckDigit option



Patch:
diff --git a/docs/create-file.md b/docs/create-file.md
index eda58584..45893daa 100644
--- a/docs/create-file.md
+++ b/docs/create-file.md
@@ -55,6 +55,7 @@ Example: `POST /files/create?requireABAOrigin=true&bypassDestination=true`
 | `requireABAOrigin`                 | `RequireABAOrigin`                 |
 | `unequalServiceClassCode`          | `UnequalServiceClassCode`          |
 | `unorderedBatchNumbers`            | `AllowUnorderedBatchNumbers`       |
+| `allowInvalidCheckDigit`           | `AllowInvalidCheckDigit`           |
 
 ## Upload a raw ACH file
 
diff --git a/entryDetail.go b/entryDetail.go
index f8454439..7808bf7c 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -287,16 +287,19 @@ func (ed *EntryDetail) Validate() error {
 		return fieldError("DiscretionaryData", err, ed.DiscretionaryData)
 	}
 
-	calculated := ed.CalculateCheckDigit(ed.RDFIIdentificationField())
+	if ed.validateOpts == nil || ed.validateOpts.AllowInvalidCheckDigit == false {
+		calculated := ed.CalculateCheckDigit(ed.RDFIIdentificationField())
 
-	edCheckDigit, err := strconv.Atoi(ed.CheckDigit)
-	if err != nil {
-		return fieldError("CheckDigit", err, ed.CheckDigit)
-	}
+		edCheckDigit, err := strconv.Atoi(ed.CheckDigit)
+		if err != nil {
+			return fieldError("CheckDigit", err, ed.CheckDigit)
+		}
 
-	if calculated != edCheckDigit {
-		return fieldError("RDFIIdentification", NewErrValidCheckDigit(calculated), ed.CheckDigit)
+		if calculated != edCheckDigit {
+			return fieldError("RDFIIdentification", NewErrValidCheckDigit(calculated), ed.CheckDigit)
+		}
 	}
+
 	return nil
 }
 
diff --git a/file.go b/file.go
index ac24e330..b9c216b8 100644
--- a/file.go
+++ b/file.go
@@ -671,6 +671,10 @@ type ValidateOpts struct {
 
 	// AllowUnorderedBatchNumebrs allows a file to be read with unordered batch numbers.
 	AllowUnorderedBatchNumbers bool `json:"allowUnorderedBatchNumbers"`
+
+	// AllowInvalidCheckDigit allows the CheckDigit field in EntryDetail to differ from
+	// the expected calculation
+	AllowInvalidCheckDigit bool `json:"allowInvalidCheckDigit"`
 }
 
 // ValidateWith performs checks on each record according to Nacha guidelines.
diff --git a/reader.go b/reader.go
index 8bb8b72e..097c1d94 100644
--- a/reader.go
+++ b/reader.go
@@ -404,6 +404,7 @@ func (r *Reader) parseEntryDetail() error {
 	if r.currentBatch.GetHeader().StandardEntryClassCode != ADV {
 		ed := NewEntryDetail()
 		ed.Parse(r.line)
+		ed.SetValidation(r.File.validateOpts)
 		if err := ed.Validate(); err != nil {
 			return r.parseError(err)
 		}
diff --git a/server/files.go b/server/files.go
index 488d3eb4..253152d1 100644
--- a/server/files.go
+++ b/server/files.go
@@ -134,6 +134,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
 		customReturnCodes                = "customReturnCodes"
 		unequalServiceClassCode          = "unequalServiceClassCode"
 		unorderedBatchNumbers            = "unorderedBatchNumbers"
+		allowInvalidCheckDigit           = "allowInvalidCheckDigit"
 	)
 
 	validationNames := []string{
@@ -148,6 +149,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
 		customReturnCodes,
 		unequalServiceClassCode,
 		unorderedBatchNumbers,
+		allowInvalidCheckDigit,
 	}
 
 	for _, name := range validationNames {
@@ -187,6 +189,8 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
 			req.validateOpts.UnequalServiceClassCode = true
 		case unorderedBatchNumbers:
 			req.validateOpts.AllowUnorderedBatchNumbers = true
+		case allowInvalidCheckDigit:
+			req.validateOpts.AllowInvalidCheckDigit = true
 		}
 	}
 


Test patch:
diff --git a/entryDetail_test.go b/entryDetail_test.go
index 6e51f882..10ca2462 100644
--- a/entryDetail_test.go
+++ b/entryDetail_test.go
@@ -723,3 +723,46 @@ func TestEntryDetail__LargeAmountStrings(t *testing.T) {
 		}
 	}
 }
+
+func TestEntryDetail__InvalidCheckDigitAllowedWithOpt(t *testing.T) {
+	ed := mockEntryDetail()
+	ed.SetValidation(&ValidateOpts{
+		AllowInvalidCheckDigit: true,
+	})
+	ed.CheckDigit = "6"
+	if err := ed.Validate(); err != nil {
+		t.Fatal(err)
+	}
+
+	// nil out
+	ed = nil
+	ed.SetValidation(&ValidateOpts{})
+}
+
+func TestEntryDetail__InvalidCheckDigitNotAllowedWithoutOpt(t *testing.T) {
+	ed := mockEntryDetail()
+	ed.SetValidation(&ValidateOpts{
+		AllowInvalidCheckDigit: false,
+	})
+	ed.CheckDigit = "6"
+	if err := ed.Validate(); err == nil {
+		t.Fatal(err)
+	}
+
+	// nil out
+	ed = nil
+	ed.SetValidation(&ValidateOpts{})
+}
+
+func TestEntryDetail__InvalidCheckDigitNotAllowedWithNullValidateOpt(t *testing.T) {
+	ed := mockEntryDetail()
+	ed.SetValidation(nil)
+	ed.CheckDigit = "6"
+	if err := ed.Validate(); err == nil {
+		t.Fatal(err)
+	}
+
+	// nil out
+	ed = nil
+	ed.SetValidation(&ValidateOpts{})
+}
diff --git a/reader_test.go b/reader_test.go
index 21304283..90b63341 100644
--- a/reader_test.go
+++ b/reader_test.go
@@ -331,6 +331,43 @@ func TestPPDDebitFixedLengthRead__InvalidLength(t *testing.T) {
 	}
 }
 
+func TestPPDInvalidEntryCheckDigit_NoErrorWithValidateOpt(t *testing.T) {
+	f, err := os.Open(filepath.Join("test", "testdata", "ppd-debit-invalid-entryDetail-checkDigit.ach"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+
+	r := NewReader(f)
+	r.SetValidation(&ValidateOpts{
+		AllowInvalidCheckDigit: true,
+	})
+	file, err := r.Read()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := file.Validate(); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestPPDInvalidEntryCheckDigit_ErrorWithoutValidateOpt(t *testing.T) {
+	f, err := os.Open(filepath.Join("test", "testdata", "ppd-debit-invalid-entryDetail-checkDigit.ach"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+
+	r := NewReader(f)
+	file, err := r.Read()
+	if err == nil {
+		t.Fatal(err)
+	}
+	if err := file.Validate(); err == nil {
+		t.Fatal(err)
+	}
+}
+
 // testRecordTypeUnknown validates record type unknown
 func testRecordTypeUnknown(t testing.TB) {
 	var line = "301 076401251 0764012510807291511A094101achdestname            companyname                    "
diff --git a/test/testdata/ppd-debit-invalid-entryDetail-checkDigit.ach b/test/testdata/ppd-debit-invalid-entryDetail-checkDigit.ach
new file mode 100644
index 00000000..09609215
--- /dev/null
+++ b/test/testdata/ppd-debit-invalid-entryDetail-checkDigit.ach
@@ -0,0 +1,10 @@
+101 23138010401210428821906240000A094101Federal Reserve Bank   My Bank Name
+5225Name on Account                     121042882 PPDREG.SALARY      190625   1121042880000001
+62723138010512345678         0100000000               Receiver Account Name   0121042880000001
+82250000010023138010000100000000000000000000121042882                          121042880000001
+9000001000001000000010023138010000100000000000000000000
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
\ No newline at end of file