Repository: moov-io/ach
Pull request #1263 (2023-07-18 20:20:45)

feat: add Addenda98Refused type
Fixes: https://github.com/moov-io/ach/issues/1235


Patch:
diff --git a/addenda98.go b/addenda98.go
index 8c8e8282..387e51fb 100644
--- a/addenda98.go
+++ b/addenda98.go
@@ -40,7 +40,7 @@ type Addenda98 struct {
 	OriginalTrace string `json:"originalTrace"`
 	// OriginalDFI field contains the Receiving DFI Identification (addenda.RDFIIdentification) as originally included on the forward Entry or Prenotification that the RDFI is returning or correcting.
 	OriginalDFI string `json:"originalDFI"`
-	// CorrectedData
+	// CorrectedData is the corrected data
 	CorrectedData string `json:"correctedData"`
 	// TraceNumber matches the Entry Detail Trace Number of the entry being returned.
 	//
@@ -215,6 +215,14 @@ func makeChangeCodeDict() map[string]*ChangeCode {
 	return dict
 }
 
+func IsRefusedChangeCode(code string) bool {
+	switch strings.ToUpper(code) {
+	case "C61", "C62", "C63", "C64", "C65", "C66", "C67", "C68", "C69":
+		return true
+	}
+	return false
+}
+
 // CorrectedData is a struct returned from our helper method for parsing the NOC/COR
 // corrected data from Addenda98 records.
 //
diff --git a/addenda98_refused.go b/addenda98_refused.go
new file mode 100644
index 00000000..d30df169
--- /dev/null
+++ b/addenda98_refused.go
@@ -0,0 +1,189 @@
+// 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 ach
+
+import (
+	"strings"
+	"unicode/utf8"
+)
+
+type Addenda98Refused struct {
+	// ID is a client defined string used as a reference to this record.
+	ID string `json:"id"`
+
+	// TypeCode Addenda types code '98'
+	TypeCode string `json:"typeCode"`
+
+	// RefusedChangeCode is the code specifying why the Notification of Change is being refused.
+	RefusedChangeCode string `json:"refusedChangeCode"`
+
+	// OriginalTrace This field contains the Trace Number as originally included on the forward Entry or Prenotification.
+	// The RDFI must include the Original Entry Trace Number in the Addenda Record of an Entry being returned to an ODFI,
+	// in the Addenda Record of an 98, within an Acknowledgment Entry, or with an RDFI request for a copy of an authorization.
+	OriginalTrace string `json:"originalTrace"`
+
+	// OriginalDFI field contains the Receiving DFI Identification (addenda.RDFIIdentification) as originally included on the
+	// forward Entry or Prenotification that the RDFI is returning or correcting.
+	OriginalDFI string `json:"originalDFI"`
+
+	// CorrectedData is the corrected data
+	CorrectedData string `json:"correctedData"`
+
+	// ChangeCode field contains a standard code used by an ACH Operator or RDFI to describe the reason for a change Entry.
+	ChangeCode string `json:"changeCode"`
+
+	// TraceSequenceNumber is the last seven digits of the TraceNumber in the original Notification of Change
+	TraceSequenceNumber string `json:"traceSequenceNumber"`
+
+	// TraceNumber matches the Entry Detail Trace Number of the entry being returned.
+	//
+	// Use TraceNumberField for a properly formatted string representation.
+	TraceNumber string `json:"traceNumber"`
+
+	// validator is composed for data validation
+	validator
+	// converters is composed for ACH to GoLang Converters
+	converters
+}
+
+// NewAddenda98Refused returns an reference to an instantiated Addenda98Refused with default values
+func NewAddenda98Refused() *Addenda98Refused {
+	addenda98Refused := &Addenda98Refused{
+		TypeCode: "98",
+	}
+	return addenda98Refused
+}
+
+// Parse takes the input record string and parses the Addenda98Refused values
+//
+// Parse provides no guarantee about all fields being filled in. Callers should make a Validate call to confirm successful parsing and data validity.
+func (addenda98Refused *Addenda98Refused) Parse(record string) {
+	if utf8.RuneCountInString(record) != 94 {
+		return
+	}
+
+	// 1-1 Always 7
+	// 2-3 Always "98"
+	addenda98Refused.TypeCode = strings.TrimSpace(record[1:3])
+	addenda98Refused.RefusedChangeCode = strings.TrimSpace(record[3:6])
+	addenda98Refused.OriginalTrace = strings.TrimSpace(record[6:21])
+	// Positions 22-27 are Reserved
+	addenda98Refused.OriginalDFI = addenda98Refused.parseStringField(record[27:35])
+	addenda98Refused.CorrectedData = strings.TrimSpace(record[35:64])
+	addenda98Refused.ChangeCode = strings.TrimSpace(record[64:67])
+	addenda98Refused.TraceSequenceNumber = strings.TrimSpace(record[67:74])
+	// Positions 75-79 are Reserved
+	addenda98Refused.TraceNumber = strings.TrimSpace(record[79:94])
+}
+
+// String writes the Addenda98 struct to a 94 character string
+func (addenda98Refused *Addenda98Refused) String() string {
+	if addenda98Refused == nil {
+		return ""
+	}
+
+	var buf strings.Builder
+	buf.Grow(94)
+	buf.WriteString(entryAddendaPos)
+	buf.WriteString(addenda98Refused.TypeCode)
+	buf.WriteString(addenda98Refused.RefusedChangeCode)
+	buf.WriteString(addenda98Refused.OriginalTraceField())
+	buf.WriteString(strings.Repeat(" ", 6))
+	buf.WriteString(addenda98Refused.OriginalDFIField())
+	buf.WriteString(addenda98Refused.CorrectedDataField())
+	buf.WriteString(addenda98Refused.ChangeCode)
+	buf.WriteString(addenda98Refused.TraceSequenceNumberField())
+	buf.WriteString(strings.Repeat(" ", 5))
+	buf.WriteString(addenda98Refused.TraceNumberField())
+	return buf.String()
+}
+
+// Validate verifies NACHA rules for Addenda98
+func (addenda98Refused *Addenda98Refused) Validate() error {
+	if addenda98Refused.TypeCode == "" {
+		return fieldError("TypeCode", ErrConstructor, addenda98Refused.TypeCode)
+	}
+	// Type Code must be 98
+	if addenda98Refused.TypeCode != "98" {
+		return fieldError("TypeCode", ErrAddendaTypeCode, addenda98Refused.TypeCode)
+	}
+
+	// RefusedChangeCode must be valid
+	_, ok := changeCodeDict[addenda98Refused.RefusedChangeCode]
+	if !ok {
+		return fieldError("RefusedChangeCode", ErrAddenda98RefusedChangeCode, addenda98Refused.RefusedChangeCode)
+	}
+
+	// Addenda98 Record must contain the corrected information corresponding to the Change Code used
+	if addenda98Refused.CorrectedData == "" {
+		return fieldError("CorrectedData", ErrAddenda98CorrectedData, addenda98Refused.CorrectedData)
+	}
+
+	// ChangeCode must be valid
+	_, ok = changeCodeDict[addenda98Refused.ChangeCode]
+	if !ok {
+		return fieldError("ChangeCode", ErrAddenda98ChangeCode, addenda98Refused.ChangeCode)
+	}
+
+	// TraceSequenceNumber must be valid
+	if addenda98Refused.TraceSequenceNumber == "" {
+		return fieldError("TraceSequenceNumber", ErrAddenda98RefusedTraceSequenceNumber, addenda98Refused.TraceSequenceNumber)
+	}
+
+	return nil
+}
+
+func (addenda98Refused *Addenda98Refused) RefusedChangeCodeField() *ChangeCode {
+	code, ok := changeCodeDict[addenda98Refused.RefusedChangeCode]
+	if ok {
+		return code
+	}
+	return nil
+}
+
+// OriginalTraceField returns a zero padded OriginalTrace string
+func (addenda98Refused *Addenda98Refused) OriginalTraceField() string {
+	return addenda98Refused.stringField(addenda98Refused.OriginalTrace, 15)
+}
+
+// OriginalDFIField returns a zero padded OriginalDFI string
+func (addenda98Refused *Addenda98Refused) OriginalDFIField() string {
+	return addenda98Refused.stringField(addenda98Refused.OriginalDFI, 8)
+}
+
+// CorrectedDataField returns a space padded CorrectedData string
+func (addenda98Refused *Addenda98Refused) CorrectedDataField() string {
+	return addenda98Refused.alphaField(addenda98Refused.CorrectedData, 29)
+}
+
+func (addenda98Refused *Addenda98Refused) ChangeCodeField() *ChangeCode {
+	code, ok := changeCodeDict[addenda98Refused.ChangeCode]
+	if ok {
+		return code
+	}
+	return nil
+}
+
+func (addenda98Refused *Addenda98Refused) TraceSequenceNumberField() string {
+	return addenda98Refused.stringField(addenda98Refused.TraceSequenceNumber, 7)
+}
+
+// TraceNumberField returns a zero padded traceNumber string
+func (addenda98Refused *Addenda98Refused) TraceNumberField() string {
+	return addenda98Refused.stringField(addenda98Refused.TraceNumber, 15)
+}
diff --git a/batch.go b/batch.go
index 7ee096e4..60346968 100644
--- a/batch.go
+++ b/batch.go
@@ -595,6 +595,11 @@ func (batch *Batch) isFieldInclusion() error {
 					return err
 				}
 			}
+			if entry.Addenda98Refused != nil {
+				if err := entry.Addenda98Refused.Validate(); err != nil {
+					return err
+				}
+			}
 			if entry.Addenda99 != nil {
 				if err := entry.Addenda99.Validate(); err != nil {
 					return err
@@ -857,6 +862,11 @@ func (batch *Batch) isAddendaSequence() error {
 				return batch.Error("AddendaRecordIndicator", ErrBatchAddendaIndicator)
 			}
 		}
+		if entry.Addenda98Refused != nil {
+			if entry.AddendaRecordIndicator != 1 {
+				return batch.Error("AddendaRecordIndicator", ErrBatchAddendaIndicator)
+			}
+		}
 		if entry.Addenda99 != nil {
 			if entry.AddendaRecordIndicator != 1 {
 				return batch.Error("AddendaRecordIndicator", ErrBatchAddendaIndicator)
@@ -956,7 +966,7 @@ func (batch *Batch) addendaFieldInclusionForward(entry *EntryDetail) error {
 		}
 	}
 	if batch.Header.StandardEntryClassCode != COR {
-		if entry.Addenda98 != nil {
+		if entry.Addenda98 != nil || entry.Addenda98Refused != nil {
 			return batch.Error("Addenda98", ErrBatchAddendaCategory, entry.Category)
 		}
 	}
@@ -975,7 +985,7 @@ func (batch *Batch) addendaFieldInclusionNOC(entry *EntryDetail) error {
 		return batch.Error("Addenda05", ErrBatchAddendaCategory, entry.Category)
 	}
 	if batch.Header.StandardEntryClassCode != COR {
-		if entry.Addenda98 != nil {
+		if entry.Addenda98 != nil || entry.Addenda98Refused != nil {
 			return batch.Error("Addenda98", ErrFieldInclusion)
 		}
 	}
@@ -998,7 +1008,7 @@ func (batch *Batch) addendaFieldInclusionReturn(entry *EntryDetail) error {
 			return batch.Error("Addenda05", ErrBatchAddendaCategory, entry.Category)
 		}
 	}
-	if entry.Addenda98 != nil {
+	if entry.Addenda98 != nil || entry.Addenda98Refused != nil {
 		return batch.Error("Addenda98", ErrBatchAddendaCategory, entry.Category)
 	}
 	if entry.Addenda99 == nil && entry.Addenda99Dishonored == nil && entry.Addenda99Contested == nil {
@@ -1024,7 +1034,7 @@ func (batch *Batch) ValidAmountForCodes(entry *EntryDetail) error {
 	if batch.validateOpts != nil && batch.validateOpts.AllowInvalidAmounts {
 		return nil
 	}
-	if entry != nil && entry.Addenda98 != nil {
+	if entry != nil && (entry.Addenda98 != nil || entry.Addenda98Refused != nil) {
 		// NOC entries will have a zero'd amount value
 		if entry.Amount != 0 {
 			return ErrBatchAmountNonZero
diff --git a/batchCOR.go b/batchCOR.go
index 3e26915c..02730cff 100644
--- a/batchCOR.go
+++ b/batchCOR.go
@@ -108,7 +108,7 @@ func (batch *BatchCOR) Create() error {
 // isAddenda98 verifies that a Addenda98 exists for each EntryDetail and is Validated
 func (batch *BatchCOR) isAddenda98() error {
 	for _, entry := range batch.Entries {
-		if entry.Addenda98 == nil {
+		if entry.Addenda98 == nil && entry.Addenda98Refused == nil {
 			return batch.Error("Addenda98", ErrBatchCORAddenda)
 		}
 	}
diff --git a/entryDetail.go b/entryDetail.go
index 2aa8d04c..70f92722 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -77,8 +77,10 @@ type EntryDetail struct {
 	Addenda02 *Addenda02 `json:"addenda02,omitempty"`
 	// Addenda05 for use with StandardEntryClassCode: ACK, ATX, CCD, CIE, CTX, DNE, ENR, WEB, PPD, TRX.
 	Addenda05 []*Addenda05 `json:"addenda05,omitempty"`
-	// Addenda98 for user with NOC
+	// Addenda98 for user with Notification of Change
 	Addenda98 *Addenda98 `json:"addenda98,omitempty"`
+	// Addenda98 for user with Refused Notification of Change
+	Addenda98Refused *Addenda98Refused `json:"addenda98Refused,omitempty"`
 	// Addenda99 for use with Returns
 	Addenda99 *Addenda99 `json:"addenda99,omitempty"`
 	// Addenda99Contested for use with Contested Dishonored Returns
@@ -356,6 +358,9 @@ func (ed *EntryDetail) SetTraceNumber(ODFIIdentification string, seq int) {
 	if ed.Addenda98 != nil {
 		ed.Addenda98.TraceNumber = traceNumber
 	}
+	if ed.Addenda98Refused != nil {
+		ed.Addenda98Refused.TraceNumber = traceNumber
+	}
 	if ed.Addenda99 != nil {
 		ed.Addenda99.TraceNumber = traceNumber
 	}
@@ -634,6 +639,9 @@ func (ed *EntryDetail) addendaCount() (n int) {
 	if ed.Addenda98 != nil {
 		n += 1
 	}
+	if ed.Addenda98Refused != nil {
+		n += 1
+	}
 	if ed.Addenda99 != nil {
 		n += 1
 	}
diff --git a/fieldErrors.go b/fieldErrors.go
index b3842bcc..881b991a 100644
--- a/fieldErrors.go
+++ b/fieldErrors.go
@@ -72,6 +72,10 @@ var (
 
 	// ErrAddenda98ChangeCode is given when there's an invalid addenda change code
 	ErrAddenda98ChangeCode = errors.New("found is not a valid addenda Change Code")
+	// ErrAddenda98RefusedChangeCode is given when there's an invalid addenda refused change code
+	ErrAddenda98RefusedChangeCode = errors.New("found is not a valid addenda Refused Change Code")
+	// ErrAddenda98RefusedTraceSequenceNumber is given when there's an invalid addenda trace sequence number
+	ErrAddenda98RefusedTraceSequenceNumber = errors.New("found is not a valid addenda trace sequence number")
 	// ErrAddenda98CorrectedData is given when the corrected data does not corespond to the change code
 	ErrAddenda98CorrectedData = errors.New("must contain the corrected information corresponding to the Change Code")
 	// ErrAddenda99ReturnCode is given when there's an invalid return code
@@ -81,7 +85,7 @@ var (
 	// ErrAddenda99ContestedReturnCode is given when there's an invalid dishonored return code
 	ErrAddenda99ContestedReturnCode = errors.New("found is not a valid contested dishonored return code")
 	// ErrBatchCORAddenda is given when an entry in a COR batch does not have an addenda98
-	ErrBatchCORAddenda = errors.New("one Addenda98 record is required for each entry in SEC Type COR")
+	ErrBatchCORAddenda = errors.New("one Addenda98 or Addenda98Refused record is required for each entry in SEC Type COR")
 
 	// FileHeader errors
 
diff --git a/file.go b/file.go
index da84ef0f..1b949983 100644
--- a/file.go
+++ b/file.go
@@ -267,6 +267,9 @@ func setEntryRecordType(e *EntryDetail) {
 	if e.Addenda98 != nil {
 		e.Addenda98.TypeCode = "98"
 	}
+	if e.Addenda98Refused != nil {
+		e.Addenda98Refused.TypeCode = "98"
+	}
 	if e.Addenda99 != nil {
 		e.Addenda99.TypeCode = "99"
 	}
diff --git a/merge.go b/merge.go
index 23fe2c39..f0929e53 100644
--- a/merge.go
+++ b/merge.go
@@ -255,6 +255,9 @@ func lineCount(f *File) int {
 			if entries[j].Addenda98 != nil {
 				lines++
 			}
+			if entries[j].Addenda98Refused != nil {
+				lines++
+			}
 			if entries[j].Addenda99 != nil {
 				lines++
 			}
diff --git a/reader.go b/reader.go
index 317e8bbf..b5f15923 100644
--- a/reader.go
+++ b/reader.go
@@ -468,13 +468,27 @@ func (r *Reader) parseAddenda() error {
 				}
 				r.currentBatch.GetEntries()[entryIndex].AddAddenda05(addenda05)
 			case "98":
-				addenda98 := NewAddenda98()
-				addenda98.Parse(r.line)
-				if err := maybeValidate(addenda98, r.File.validateOpts); err != nil {
-					return r.parseError(err)
+				// The Addenda98 and Addenda98Refused records have their change code in the same spot,
+				// but refused records have a different set of values.
+				switch {
+				case IsRefusedChangeCode(r.line[3:6]):
+					addenda98Refused := NewAddenda98Refused()
+					addenda98Refused.Parse(r.line)
+					if err := maybeValidate(addenda98Refused, r.File.validateOpts); err != nil {
+						return r.parseError(err)
+					}
+					r.currentBatch.GetEntries()[entryIndex].Category = CategoryNOC
+					r.currentBatch.GetEntries()[entryIndex].Addenda98Refused = addenda98Refused
+
+				default:
+					addenda98 := NewAddenda98()
+					addenda98.Parse(r.line)
+					if err := maybeValidate(addenda98, r.File.validateOpts); err != nil {
+						return r.parseError(err)
+					}
+					r.currentBatch.GetEntries()[entryIndex].Category = CategoryNOC
+					r.currentBatch.GetEntries()[entryIndex].Addenda98 = addenda98
 				}
-				r.currentBatch.GetEntries()[entryIndex].Category = CategoryNOC
-				r.currentBatch.GetEntries()[entryIndex].Addenda98 = addenda98
 			case "99":
 				// Addenda99, Addenda99Dishonored, Addenda99Contested records both have their code
 				// in the same spot, so we need to determine which to parse by the value.
diff --git a/writer.go b/writer.go
index 8ee4eba6..09bf9c04 100644
--- a/writer.go
+++ b/writer.go
@@ -123,6 +123,12 @@ func (w *Writer) writeBatch(file *File, isADV bool) error {
 					}
 					w.lineNum++
 				}
+				if entry.Addenda98Refused != nil {
+					if _, err := w.w.WriteString(entry.Addenda98Refused.String() + w.LineEnding); err != nil {
+						return err
+					}
+					w.lineNum++
+				}
 				if entry.Addenda99 != nil {
 					if _, err := w.w.WriteString(entry.Addenda99.String() + w.LineEnding); err != nil {
 						return err


Test patch:
diff --git a/addenda98_refused_test.go b/addenda98_refused_test.go
new file mode 100644
index 00000000..520afe6b
--- /dev/null
+++ b/addenda98_refused_test.go
@@ -0,0 +1,100 @@
+// 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 ach
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func mockAddenda98Refused() *Addenda98Refused {
+	add := NewAddenda98Refused()
+	add.RefusedChangeCode = "C62"
+	add.OriginalTrace = "059999990000003"
+	add.OriginalDFI = "05999999"
+	add.CorrectedData = "68-6547"
+	add.ChangeCode = "C01"
+	add.TraceSequenceNumber = "0000002"
+	add.TraceNumber = "059999990000001"
+	return add
+}
+
+func TestAddenda98Refused_Fields(t *testing.T) {
+	add := mockAddenda98Refused()
+
+	// shorten some fields
+	add.OriginalTrace = "059993"
+	add.TraceNumber = "000123"
+
+	require.Equal(t, "C62", add.RefusedChangeCodeField().Code)
+	require.Equal(t, "000000000059993", add.OriginalTraceField())
+	require.Equal(t, "05999999", add.OriginalDFIField())
+	require.Equal(t, "68-6547                      ", add.CorrectedDataField())
+	require.Equal(t, "C01", add.ChangeCodeField().Code)
+	require.Equal(t, "0000002", add.TraceSequenceNumberField())
+	require.Equal(t, "000000000000123", add.TraceNumberField())
+}
+
+func TestAddenda98Refused_Read(t *testing.T) {
+	original := mockAddenda98Refused()
+
+	read := &Addenda98Refused{}
+	read.Parse(original.String())
+
+	require.Equal(t, "C62", read.RefusedChangeCodeField().Code)
+	require.Equal(t, "059999990000003", read.OriginalTraceField())
+	require.Equal(t, "05999999", read.OriginalDFIField())
+	require.Equal(t, "68-6547                      ", read.CorrectedDataField())
+	require.Equal(t, "C01", read.ChangeCodeField().Code)
+	require.Equal(t, "0000002", read.TraceSequenceNumberField())
+	require.Equal(t, "059999990000001", read.TraceNumberField())
+}
+
+func TestAddenda98Refused_File(t *testing.T) {
+	file := NewFile()
+	file.Header = mockFileHeader()
+
+	b := mockBatchCOR(t)
+	b.Entries[0].AddendaRecordIndicator = 1
+	b.Entries[0].Addenda98Refused = mockAddenda98Refused()
+	b.Entries[0].TraceNumber = "121042880000002"
+	require.NoError(t, b.Create())
+	file.AddBatch(b)
+	require.NoError(t, file.Create())
+
+	var buf bytes.Buffer
+	err := NewWriter(&buf).Write(file)
+	require.NoError(t, err)
+	require.Contains(t, buf.String(), "C62")
+
+	read, err := NewReader(&buf).Read()
+	require.NoError(t, err)
+
+	require.Len(t, read.Batches, 1)
+	entries := read.Batches[0].GetEntries()
+	require.Len(t, entries, 1)
+
+	ed := entries[0]
+	require.NotNil(t, ed.Addenda98)
+	require.Equal(t, "C01", ed.Addenda98.ChangeCode)
+	require.NotNil(t, ed.Addenda98Refused)
+	require.Equal(t, "C62", ed.Addenda98Refused.RefusedChangeCode)
+	require.Equal(t, "C01", ed.Addenda98Refused.ChangeCode)
+}