Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing CustomReturnCode validation #966

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vxio What are your thoughts on a CheckReturnCode func(code string) error function that allows validating codes like "R97" as the issue mentions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For ref: https://moov-io.slack.com/archives/CD9J8EJKX/p1626989492134600?thread_ts=1626981488.133100&cid=CD9J8EJKX

I went with the bool so that I can call from an API endpoint more easily with the base docker image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea that's a good idea. We can add a validation func in a separate PR.

}

// 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