Skip to content

Commit

Permalink
Implementing CustomReturnCode validation (#966)
Browse files Browse the repository at this point in the history
* Implementing CustomReturnCode validation
  • Loading branch information
emarcey committed Jul 23, 2021
1 parent 977abd1 commit b525358
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 5 deletions.
22 changes: 18 additions & 4 deletions addenda99.go
Expand Up @@ -69,6 +69,8 @@ type Addenda99 struct {
validator
// converters is composed for ACH to GoLang Converters
converters

validateOpts *ValidateOpts
}

// ReturnCode holds a return Code, Reason/Title, and Description
Expand Down Expand Up @@ -142,14 +144,26 @@ func (Addenda99 *Addenda99) Validate() error {
return fieldError("TypeCode", ErrAddendaTypeCode, Addenda99.TypeCode)
}

_, ok := returnCodeDict[Addenda99.ReturnCode]
if !ok {
// Return Addenda requires a valid ReturnCode
return fieldError("ReturnCode", ErrAddenda99ReturnCode, Addenda99.ReturnCode)
if Addenda99.validateOpts == nil || !Addenda99.validateOpts.CustomReturnCodes {
_, ok := returnCodeDict[Addenda99.ReturnCode]
if !ok {
// Return Addenda requires a valid ReturnCode
return fieldError("ReturnCode", ErrAddenda99ReturnCode, Addenda99.ReturnCode)
}
}

return nil
}

// SetValidation stores ValidateOpts on the Batch which are to be used to override
// the default NACHA validation rules.
func (Addenda99 *Addenda99) SetValidation(opts *ValidateOpts) {
if Addenda99 == nil {
return
}
Addenda99.validateOpts = opts
}

// OriginalTraceField returns a zero padded OriginalTrace string
func (Addenda99 *Addenda99) OriginalTraceField() string {
return Addenda99.stringField(Addenda99.OriginalTrace, 15)
Expand Down
46 changes: 46 additions & 0 deletions addenda99_test.go
Expand Up @@ -391,3 +391,49 @@ func TestAddenda99__MissingFileHeaderControl(t *testing.T) {
t.Errorf("got %d entries", len(entries))
}
}

func TestAddenda99__SetValidation(t *testing.T) {
addenda99 := NewAddenda99()
addenda99.SetValidation(&ValidateOpts{
CustomReturnCodes: true,
})

addenda99.ReturnCode = "@#$" // can safely say this will never be a real ReasonCode
if err := addenda99.Validate(); err != nil {
t.Fatal(err)
}

addenda99.SetValidation(&ValidateOpts{
CustomReturnCodes: false,
})
if err := addenda99.Validate(); err == nil {
t.Fatal("Did not flag invalid reason code")
}
addenda99 = nil
addenda99.SetValidation(&ValidateOpts{})
}

func TestAddenda99__CustomReturnCode(t *testing.T) {
mockBatch := mockBatch()
// Add a Addenda Return to the mock batch
if len(mockBatch.Entries) != 1 {
t.Fatal("Expected 1 batch entry")
}
addenda99 := NewAddenda99()
addenda99.SetValidation(&ValidateOpts{
CustomReturnCodes: true,
})

addenda99.ReturnCode = "@#$" // can safely say this will never be a real ReasonCode
mockBatch.Entries[0].Addenda99 = addenda99
mockBatch.Entries[0].Category = CategoryReturn
mockBatch.Entries[0].AddendaRecordIndicator = 1

// replace last 2 of TraceNumber
if err := mockBatch.build(); err != nil {
t.Errorf("%T: %s", err, err)
}
if err := mockBatch.verify(); err != nil {
t.Errorf("%T: %s", err, err)
}
}
80 changes: 80 additions & 0 deletions examples/example_customReturnCode_test.go
@@ -0,0 +1,80 @@
// 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"
"strings"

"github.com/moov-io/ach"
)

// Example_customReturnCode writes an ACH file with a non-standard Return Code
func Example_customReturnCode() {
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"
bh.EffectiveEntryDate = "190816" // need EffectiveEntryDate to be fixed so it can match output
bh.ODFIIdentification = "987654320"

entry := ach.NewEntryDetail()
entry.TransactionCode = 22 // example of a custom code
entry.SetRDFI("123456780")
entry.DFIAccountNumber = "12567"
entry.Amount = 100000000
entry.SetTraceNumber(bh.ODFIIdentification, 2)
entry.IndividualName = "Jane Smith"
addenda99 := ach.NewAddenda99()
addenda99.ReturnCode = "abc"
entry.Addenda99 = addenda99
entry.Category = ach.CategoryReturn
entry.AddendaRecordIndicator = 1
entry.Addenda99.SetValidation(&ach.ValidateOpts{
CustomReturnCodes: true,
})

// 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.Println(file.Batches[0].GetEntries()[0].String())
fmt.Printf("ReturnCode=%s\n", strings.TrimSpace(file.Batches[0].GetEntries()[0].Addenda99.ReturnCode))

// Output:
// TransactionCode=22
// 62212345678012567 0100000000 Jane Smith 1987654320000002
// ReturnCode=abc
}
4 changes: 4 additions & 0 deletions file.go
Expand Up @@ -594,6 +594,10 @@ type ValidateOpts struct {
// BypassCompanyIdentificationMatch allows batches in which the Company Identification field
// in the batch header and control do not match.
BypassCompanyIdentificationMatch bool `json:"bypassCompanyIdentificationMatch"`

// CustomReturnCodes can be set to skip validation for the Return Code field in an Addenda99
// This allows for non-standard/deprecated return codes (e.g. R97)
CustomReturnCodes bool `json:"customReturnCodes"`
}

// ValidateWith performs checks on each record according to Nacha guidelines.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -11,7 +11,7 @@ require (
github.com/mattn/go-isatty v0.0.13
github.com/moov-io/base v0.21.0
github.com/prometheus/client_golang v1.11.0
github.com/stretchr/testify v1.7.0 // indirect
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
golang.org/x/oauth2 v0.0.0-20210622215436-a8dc77f794b6 // indirect
google.golang.org/appengine v1.6.7 // indirect
Expand Down
1 change: 1 addition & 0 deletions reader.go
Expand Up @@ -459,6 +459,7 @@ func (r *Reader) parseAddenda() error {
case "99":
addenda99 := NewAddenda99()
addenda99.Parse(r.line)
addenda99.SetValidation(r.File.validateOpts)
if err := addenda99.Validate(); err != nil {
return r.parseError(err)
}
Expand Down
21 changes: 21 additions & 0 deletions reader_test.go
Expand Up @@ -1642,6 +1642,27 @@ func TestReturnACHFile(t *testing.T) {
}
}

// TestReturnACHFile test loading PPD return file with a custom return code
func TestReturnACHFileCustomReasonCode(t *testing.T) {
f, err := os.Open(filepath.Join("test", "testdata", "return-PPD-custom-reason-code.ach"))
if err != nil {
t.Fatal(err)
}
defer f.Close()

r := NewReader(f)
r.SetValidation(&ValidateOpts{
CustomReturnCodes: true,
})
data, err := r.Read()
if err != nil {
t.Fatal(err)
}
if err := data.Validate(); err != nil {
t.Fatal(err)
}
}

// TestADVReturnError returns a Parse Error
func TestADVReturnError(t *testing.T) {
file := NewFile().SetHeader(mockFileHeader())
Expand Down
4 changes: 4 additions & 0 deletions server/files.go
Expand Up @@ -120,6 +120,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
customTraceNumbers = "customTraceNumbers"
allowZeroBatches = "allowZeroBatches"
bypassCompanyIdentificationMatch = "bypassCompanyIdentificationMatch"
customReturnCodes = "customReturnCodes"
)

validationNames := []string{
Expand All @@ -129,6 +130,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
customTraceNumbers,
allowZeroBatches,
bypassCompanyIdentificationMatch,
customReturnCodes,
}

for _, name := range validationNames {
Expand Down Expand Up @@ -158,6 +160,8 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac
req.validateOpts.AllowZeroBatches = true
case bypassCompanyIdentificationMatch:
req.validateOpts.BypassCompanyIdentificationMatch = true
case customReturnCodes:
req.validateOpts.CustomReturnCodes = true
}
}

Expand Down
10 changes: 10 additions & 0 deletions test/testdata/return-PPD-custom-reason-code.ach
@@ -0,0 +1,10 @@
101 092221172 1724443902107221026A094101Fake destination Fake origin
5200dummy company PAYROLL 1234567 PPDDIR DEP 072221210702 1092221170000001
6210922211721234567 0000106161xxxxxxx3105 Jane Doe DD1092221170000001
799R97092221172022300 12330515 092221170000001
820000000200092221170000000000000000001061611234567 092221170000001
9000001000001000000020009222117000000000000000000106161
9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999

0 comments on commit b525358

Please sign in to comment.