From b5253583e94e9b03f66bb33b19860ac08b302e88 Mon Sep 17 00:00:00 2001 From: Evan Marcey <42551883+emarcey@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:48:34 -0400 Subject: [PATCH] Implementing CustomReturnCode validation (#966) * Implementing CustomReturnCode validation --- addenda99.go | 22 ++++- addenda99_test.go | 46 +++++++++++ examples/example_customReturnCode_test.go | 80 +++++++++++++++++++ file.go | 4 + go.mod | 2 +- reader.go | 1 + reader_test.go | 21 +++++ server/files.go | 4 + .../return-PPD-custom-reason-code.ach | 10 +++ 9 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 examples/example_customReturnCode_test.go create mode 100644 test/testdata/return-PPD-custom-reason-code.ach diff --git a/addenda99.go b/addenda99.go index 9502e94eb..b6566fffd 100644 --- a/addenda99.go +++ b/addenda99.go @@ -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 @@ -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) diff --git a/addenda99_test.go b/addenda99_test.go index 00a0e07cf..e1bd0457e 100644 --- a/addenda99_test.go +++ b/addenda99_test.go @@ -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) + } +} diff --git a/examples/example_customReturnCode_test.go b/examples/example_customReturnCode_test.go new file mode 100644 index 000000000..c9ce01184 --- /dev/null +++ b/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 +} diff --git a/file.go b/file.go index acda35f94..a34fc1137 100644 --- a/file.go +++ b/file.go @@ -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. diff --git a/go.mod b/go.mod index 1bbd5f754..2960f33a3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/reader.go b/reader.go index b4a0b6fb7..d651eac18 100644 --- a/reader.go +++ b/reader.go @@ -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) } diff --git a/reader_test.go b/reader_test.go index 9f6771709..3a27f48a3 100644 --- a/reader_test.go +++ b/reader_test.go @@ -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()) diff --git a/server/files.go b/server/files.go index 648bad924..1c6f01d67 100644 --- a/server/files.go +++ b/server/files.go @@ -120,6 +120,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac customTraceNumbers = "customTraceNumbers" allowZeroBatches = "allowZeroBatches" bypassCompanyIdentificationMatch = "bypassCompanyIdentificationMatch" + customReturnCodes = "customReturnCodes" ) validationNames := []string{ @@ -129,6 +130,7 @@ func decodeCreateFileRequest(_ context.Context, request *http.Request) (interfac customTraceNumbers, allowZeroBatches, bypassCompanyIdentificationMatch, + customReturnCodes, } for _, name := range validationNames { @@ -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 } } diff --git a/test/testdata/return-PPD-custom-reason-code.ach b/test/testdata/return-PPD-custom-reason-code.ach new file mode 100644 index 000000000..09e9ce160 --- /dev/null +++ b/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