Repository: moov-io/ach
Pull request #728 (2020-06-03 17:54:26)

entries: allow custom TransactionCodes
Allowing custom TransactionCodes allows extra features to be written using ACH files. This is commonly used for products created in the past few years. 

There's also a branch with https://github.com/moov-io/ach/pull/729 -- https://github.com/moov-io/ach/tree/custom-entry-fields 


Patch:
diff --git a/batch.go b/batch.go
index 4cc8b710..6ae447ff 100644
--- a/batch.go
+++ b/batch.go
@@ -979,11 +979,18 @@ func (batch *Batch) ValidTranCodeForServiceClassCode(entry *EntryDetail) error {
 		return batch.Error("TransactionCode", ErrBatchTransactionCode, entry.TransactionCode)
 	}
 
+	if entry.validateOpts != nil && entry.validateOpts.CheckTransactionCode != nil {
+		// We're unable to validate the ServiceClassCode with custom TransactionCode validation.
+		return nil
+	}
+
 	switch batch.Header.ServiceClassCode {
 	case AutomatedAccountingAdvices:
 		return batch.Error("ServiceClassCode", ErrBatchServiceClassCode, batch.Header.ServiceClassCode)
 
 	case MixedDebitsAndCredits:
+		return nil
+
 	case CreditsOnly:
 		if entry.CreditOrDebit() != "C" {
 			return batch.Error("TransactionCode", NewErrBatchServiceClassTranCode(batch.Header.ServiceClassCode, entry.TransactionCode))
diff --git a/batchHeader.go b/batchHeader.go
index 1abf1a16..ee4a08af 100644
--- a/batchHeader.go
+++ b/batchHeader.go
@@ -122,6 +122,8 @@ type BatchHeader struct {
 
 	// converters is composed for ACH to golang Converters
 	converters
+
+	validateOpts *ValidateOpts
 }
 
 const (
@@ -211,6 +213,15 @@ func (bh *BatchHeader) String() string {
 	return buf.String()
 }
 
+// SetValidation stores ValidateOpts on the Batch which are to be used to override
+// the default NACHA validation rules.
+func (bh *BatchHeader) SetValidation(opts *ValidateOpts) {
+	if bh == nil {
+		return
+	}
+	bh.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 (bh *BatchHeader) Validate() error {
@@ -220,8 +231,12 @@ func (bh *BatchHeader) Validate() error {
 	if bh.recordType != "5" {
 		return fieldError("recordType", NewErrRecordType(5), bh.recordType)
 	}
-	if err := bh.isServiceClass(bh.ServiceClassCode); err != nil {
-		return fieldError("ServiceClassCode", err, bh.ServiceClassCode)
+	if bh.validateOpts == nil || bh.validateOpts.CheckTransactionCode == nil {
+		// Ensure the ServiceClassCode follows NACHA standards if we have no TransactionCode
+		// validation overrides. Custom TransactionCode's don't allow for standard validation.
+		if err := bh.isServiceClass(bh.ServiceClassCode); err != nil {
+			return fieldError("ServiceClassCode", err, bh.ServiceClassCode)
+		}
 	}
 	if err := bh.isSECCode(bh.StandardEntryClassCode); err != nil {
 		return fieldError("StandardEntryClassCode", err, bh.StandardEntryClassCode)
diff --git a/entryDetail.go b/entryDetail.go
index 247967d9..108b45e6 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -88,6 +88,8 @@ type EntryDetail struct {
 	validator
 	// converters is composed for ACH to golang Converters
 	converters
+
+	validateOpts *ValidateOpts
 }
 
 const (
@@ -241,6 +243,15 @@ func (ed *EntryDetail) String() string {
 	return buf.String()
 }
 
+// SetValidation stores ValidateOpts on the Batch which are to be used to override
+// the default NACHA validation rules.
+func (ed *EntryDetail) SetValidation(opts *ValidateOpts) {
+	if ed == nil {
+		return
+	}
+	ed.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 (ed *EntryDetail) Validate() error {
@@ -250,8 +261,14 @@ func (ed *EntryDetail) Validate() error {
 	if ed.recordType != "6" {
 		return fieldError("recordType", NewErrRecordType(6), ed.recordType)
 	}
-	if err := ed.isTransactionCode(ed.TransactionCode); err != nil {
-		return fieldError("TransactionCode", err, strconv.Itoa(ed.TransactionCode))
+	if ed.validateOpts != nil && ed.validateOpts.CheckTransactionCode != nil {
+		if err := ed.validateOpts.CheckTransactionCode(ed.TransactionCode); err != nil {
+			return fieldError("TransactionCode", err, strconv.Itoa(ed.TransactionCode))
+		}
+	} else {
+		if err := ed.isTransactionCode(ed.TransactionCode); err != nil {
+			return fieldError("TransactionCode", err, strconv.Itoa(ed.TransactionCode))
+		}
 	}
 	if err := ed.isAlphanumeric(ed.DFIAccountNumber); err != nil {
 		return fieldError("DFIAccountNumber", err, ed.DFIAccountNumber)
diff --git a/file.go b/file.go
index e1c3f257..64a2a4ed 100644
--- a/file.go
+++ b/file.go
@@ -549,6 +549,9 @@ type ValidateOpts struct {
 	// This also allows for custom TraceNumbers which aren't prefixed with
 	// a routing number as required by the NACHA specification.
 	BypassDestinationValidation bool `json:"bypassDestinationValidation"`
+
+	// CheckTransactionCode allows for custom validation of TransactionCode values
+	CheckTransactionCode func(code int) error
 }
 
 // ValidateWith performs NACHA format rule checks on each record according to their specification
diff --git a/validators.go b/validators.go
index 02de4a80..5e73ad8a 100644
--- a/validators.go
+++ b/validators.go
@@ -285,6 +285,11 @@ func (v *validator) isTypeCode(code string) error {
 //		"3" designates a credit, or
 //		"8" designates a debit.
 func (v *validator) isTransactionCode(code int) error {
+	return StandardTransactionCode(code)
+}
+
+// StandardTransactionCode checks the provided TransactionCode to verify it is a valid NACHA value.
+func StandardTransactionCode(code int) error {
 	switch code {
 	// TransactionCode if the receivers account is:
 	case


Test patch:
diff --git a/batchHeader_test.go b/batchHeader_test.go
index 4ac0fe83..2c2501e7 100644
--- a/batchHeader_test.go
+++ b/batchHeader_test.go
@@ -563,3 +563,12 @@ func TestBatchHeader__LiftEffectiveEntryDate(t *testing.T) {
 		t.Error("expected error")
 	}
 }
+
+func TestBatchHeader__SetValidation(t *testing.T) {
+	bh := NewBatchHeader()
+	bh.SetValidation(&ValidateOpts{})
+
+	// nil out
+	bh = nil
+	bh.SetValidation(&ValidateOpts{})
+}
diff --git a/entryDetail_test.go b/entryDetail_test.go
index b2258b44..563f5653 100644
--- a/entryDetail_test.go
+++ b/entryDetail_test.go
@@ -20,6 +20,7 @@ package ach
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"path/filepath"
 	"strconv"
 	"strings"
@@ -737,3 +738,23 @@ func TestEntryDetail__ParseNOC(t *testing.T) {
 		t.Errorf("EntryDetail.Category=%s\n  %#v", entries[0].Category, entries[0])
 	}
 }
+
+func TestEntryDetail__SetValidation(t *testing.T) {
+	ed := mockEntryDetail()
+	ed.SetValidation(&ValidateOpts{
+		CheckTransactionCode: func(code int) error {
+			if code == 999 {
+				return nil
+			}
+			return fmt.Errorf("unexpected TransactionCode: %d", code)
+		},
+	})
+	ed.TransactionCode = 999
+	if err := ed.Validate(); err != nil {
+		t.Fatal(err)
+	}
+
+	// nil out
+	ed = nil
+	ed.SetValidation(&ValidateOpts{})
+}
diff --git a/examples/example_custom_transaction_code_test.go b/examples/example_custom_transaction_code_test.go
new file mode 100644
index 00000000..9d51e569
--- /dev/null
+++ b/examples/example_custom_transaction_code_test.go
@@ -0,0 +1,87 @@
+// 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 examples
+
+import (
+	"fmt"
+	"log"
+
+	"github.com/moov-io/ach"
+)
+
+// Example_customTransactionCode writes an ACH file with a non-standard NACHA TransactionCodes
+func Example_customTransactionCode() {
+	validationOpts := &ach.ValidateOpts{
+		// CheckTransactionCode lets us override the standard set of NACHA
+		// codes for applications which use custom TransactionCode values.
+		CheckTransactionCode: func(code int) error {
+			// Is it a custom TransactionCode of ours?
+			if code == 78 {
+				return nil
+			}
+			// Is it a NACHA standard code?
+			return ach.StandardTransactionCode(code)
+		},
+	}
+
+	fh := mockFileHeader()
+
+	bh := ach.NewBatchHeader()
+	bh.ServiceClassCode = ach.CreditsOnly
+	bh.CompanyName = "My Company"
+	bh.CompanyIdentification = fh.ImmediateOrigin
+	bh.StandardEntryClassCode = ach.PPD
+	bh.CompanyEntryDescription = "Cash Back"
+	// fix EffectiveEntryDate for consistent output
+	bh.EffectiveEntryDate = "190816"
+	bh.ODFIIdentification = "987654320"
+	bh.SetValidation(validationOpts)
+
+	entry := ach.NewEntryDetail()
+	entry.TransactionCode = 78 // example of a custom code
+	entry.SetRDFI("123456780")
+	entry.DFIAccountNumber = "12567"
+	entry.Amount = 100000000
+	entry.SetTraceNumber(bh.ODFIIdentification, 2)
+	entry.IndividualName = "Jane Smith"
+	entry.SetValidation(validationOpts)
+
+	// build the batch
+	batch := ach.NewBatchPPD(bh)
+	batch.AddEntry(entry)
+
+	if err := batch.Create(); err != nil {
+		log.Fatalf("Unexpected error building batch: %s\n", err)
+	}
+
+	// build the file
+	file := ach.NewFile()
+	file.SetHeader(fh)
+	file.AddBatch(batch)
+	if err := file.Create(); err != nil {
+		log.Fatalf("Unexpected error building file: %s\n", err)
+	}
+
+	fmt.Printf("TransactionCode=%d\n", file.Batches[0].GetEntries()[0].TransactionCode)
+	fmt.Printf("%s\n", file.Batches[0].GetEntries()[0].String())
+
+	// Output:
+	// TransactionCode=78
+	// 67812345678012567            0100000000               Jane Smith              0987654320000002
+
+}