Repository: moov-io/ach
Pull request #1004 (2022-03-30 15:25:59)

feat: support parsing out Contested Dishonored Returns
A natural followup from https://github.com/moov-io/ach/pull/1002 

codecov-commenter: # [Codecov](https://codecov.io/gh/moov-io/ach/pull/1004?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=moov-io) Report
> Merging [#1004](https://codecov.io/gh/moov-io/ach/pull/1004?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=moov-io) (dd983bd) into [master](https://codecov.io/gh/moov-io/ach/commit/22244dddd9de280a6505b38a1f468caf1f92ca30?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=moov-io) (22244dd) will **decrease** coverage by `0.54%`.
> The diff coverage is `59.32%`.


```diff
@@            Coverage Diff             @@
##           master    #1004      +/-   ##
==========================================
- Coverage   91.36%   90.82%   -0.55%     
==========================================
  Files          67       68       +1     
  Lines        6315     6419     +104     
==========================================
+ Hits         5770     5830      +60     
- Misses        333      369      +36     
- Partials      212      220       +8     
```


Patch:
diff --git a/addenda99_dishonored_return.go b/addenda99_dishonored_return.go
index dde4608a..c3c27148 100644
--- a/addenda99_dishonored_return.go
+++ b/addenda99_dishonored_return.go
@@ -31,28 +31,28 @@ type Addenda99Dishonored struct {
 	TypeCode string `json:"typeCode"`
 
 	// DishonoredReturnReasonCode is the return code explaining the dishonorment
-	DishonoredReturnReasonCode string
+	DishonoredReturnReasonCode string `json:"dishonoredReturnReasonCode"`
 
 	// OriginalEntryTraceNumber is the trace number specifieid in the initial entry
-	OriginalEntryTraceNumber string
+	OriginalEntryTraceNumber string `json:"originalEntryTraceNumber"`
 
 	// OriginalReceivingDFIIdentification is the DFI Identification specifieid in the initial entry
-	OriginalReceivingDFIIdentification string
+	OriginalReceivingDFIIdentification string `json:"originalReceivingDFIIdentification"`
 
 	// ReturnTraceNumber is the TraceNumber used when issuing the return
-	ReturnTraceNumber string
+	ReturnTraceNumber string `json:"returnTraceNumber"`
 
 	// ReturnSettlementDate is the date of return issuing
-	ReturnSettlementDate string
+	ReturnSettlementDate string `json:"returnSettlementDate"`
 
 	// ReturnReasonCode is the initial return code
-	ReturnReasonCode string
+	ReturnReasonCode string `json:"returnReasonCode"`
 
 	// AddendaInformation is additional data
-	AddendaInformation string
+	AddendaInformation string `json:"addendaInformation"`
 
 	// TraceNumber is the trace number for dishonorment
-	TraceNumber string
+	TraceNumber string `json:"traceNumber"`
 
 	// validator is composed for data validation
 	validator
@@ -115,26 +115,14 @@ func (Addenda99Dishonored *Addenda99Dishonored) SetValidation(opts *ValidateOpts
 	Addenda99Dishonored.validateOpts = opts
 }
 
-func isODFIDishonoredReturn(code string) bool {
-	switch code {
-	case "R61", "R67", "R68", "R69", "R70": // Used by ODFI for Dishonored Returns
-		return true
-	}
-	return false
-}
-
-func isRDFIDishonoredReturn(code string) bool {
+func IsDishonoredReturnCode(code string) bool {
 	switch code {
-	case "R71", "R72", "R73", "R74", "R75", "R76": // Used by RDFI for Dishonored Returns
+	case "R61", "R67", "R68", "R69", "R70":
 		return true
 	}
 	return false
 }
 
-func IsDishonoredReturnCode(code string) bool {
-	return isODFIDishonoredReturn(code) || isRDFIDishonoredReturn(code)
-}
-
 // Validate verifies NACHA rules for Addenda99Dishonored
 func (Addenda99Dishonored *Addenda99Dishonored) Validate() error {
 	if Addenda99Dishonored.recordType != "7" {
diff --git a/batch.go b/batch.go
index f9dd7faa..729558c1 100644
--- a/batch.go
+++ b/batch.go
@@ -600,6 +600,11 @@ func (batch *Batch) isFieldInclusion() error {
 					return err
 				}
 			}
+			if entry.Addenda99Contested != nil {
+				if err := entry.Addenda99Contested.Validate(); err != nil {
+					return err
+				}
+			}
 
 		}
 		return batch.Control.Validate()
@@ -857,6 +862,11 @@ func (batch *Batch) isAddendaSequence() error {
 				return batch.Error("AddendaRecordIndicator", ErrBatchAddendaIndicator)
 			}
 		}
+		if entry.Addenda99Contested != nil {
+			if entry.AddendaRecordIndicator != 1 {
+				return batch.Error("AddendaRecordIndicator", ErrBatchAddendaIndicator)
+			}
+		}
 	}
 	return nil
 }
@@ -898,7 +908,7 @@ func (batch *Batch) isCategory() error {
 // Notification of Change:
 // COR and Addenda98
 // Return:
-// Addenda99, Addenda99Dishonored
+// Addenda99, Addenda99Dishonored, Addenda99Contested
 //
 func (batch *Batch) addendaFieldInclusion(entry *EntryDetail) error {
 	switch entry.Category {
@@ -982,7 +992,7 @@ func (batch *Batch) addendaFieldInclusionReturn(entry *EntryDetail) error {
 	if entry.Addenda98 != nil {
 		return batch.Error("Addenda98", ErrBatchAddendaCategory, entry.Category)
 	}
-	if entry.Addenda99 == nil && entry.Addenda99Dishonored == nil {
+	if entry.Addenda99 == nil && entry.Addenda99Dishonored == nil && entry.Addenda99Contested == nil {
 		return batch.Error("Addenda99", ErrFieldInclusion)
 	}
 	return nil
diff --git a/cmd/achcli/describe/file.go b/cmd/achcli/describe/file.go
index 63dd59f9..d4ddf00d 100644
--- a/cmd/achcli/describe/file.go
+++ b/cmd/achcli/describe/file.go
@@ -78,6 +78,7 @@ func File(ww io.Writer, file *ach.File, opts *Opts) {
 			dumpAddenda98(w, e.Addenda98)
 			dumpAddenda99(w, e.Addenda99)
 			dumpAddenda99Dishonored(w, e.Addenda99Dishonored)
+			dumpAddenda99Contested(w, e.Addenda99Contested)
 		}
 
 		bc := file.Batches[i].GetControl()
@@ -116,6 +117,22 @@ func dumpAddenda99Dishonored(w *tabwriter.Writer, a *ach.Addenda99Dishonored) {
 		a.ReturnSettlementDate, a.ReturnReasonCode, a.AddendaInformation, a.TraceNumber)
 }
 
+func dumpAddenda99Contested(w *tabwriter.Writer, a *ach.Addenda99Contested) {
+	if a == nil {
+		return
+	}
+
+	fmt.Fprintln(w, "\n      Contested Dishonored Addenda99")
+	fmt.Fprintln(w, "      ContestedReturnCode\tOrig. TraceNumber\tOrig Date Returned\tOrig. RDFIIdentification\tOrig. SettlementDate\tReturnTraceNumber")
+	fmt.Fprintf(w, "      %s\t%s\t%s\t%s\t%s\t%s\n",
+		a.ContestedReturnCode, a.OriginalEntryTraceNumber, a.DateOriginalEntryReturned, a.OriginalReceivingDFIIdentification,
+		a.OriginalSettlementDate, a.ReturnTraceNumber)
+
+	fmt.Fprintln(w, "      ReturnSettlementDate\tReturnReasonCode\tDishonoredTraceNumber\tDishonoredSettlementDate\tDishonoredReasonCode\tTraceNumber")
+	fmt.Fprintf(w, "      %s\t%s\t%s\t%s\t%s\t%s\n",
+		a.ReturnSettlementDate, a.ReturnReasonCode, a.DishonoredReturnTraceNumber, a.DishonoredReturnSettlementDate, a.DishonoredReturnReasonCode, a.TraceNumber)
+}
+
 func dumpAddenda05(w *tabwriter.Writer, a *ach.Addenda05) {
 	if a == nil {
 		return
diff --git a/entryDetail.go b/entryDetail.go
index 68de349c..7a389d9a 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -83,6 +83,8 @@ type EntryDetail struct {
 	Addenda98 *Addenda98 `json:"addenda98,omitempty"`
 	// Addenda99 for use with Returns
 	Addenda99 *Addenda99 `json:"addenda99,omitempty"`
+	// Addenda99Contested for use with Contested Dishonored Returns
+	Addenda99Contested *Addenda99Contested `json:"addenda99Contested,omitempty"`
 	// Addenda99Dishonored for use with Dishonored Returns
 	Addenda99Dishonored *Addenda99Dishonored `json:"addenda99Dishonored,omitempty"`
 	// Category defines if the entry is a Forward, Return, or NOC
@@ -608,6 +610,9 @@ func (ed *EntryDetail) addendaCount() (n int) {
 	if ed.Addenda99Dishonored != nil {
 		n += 1
 	}
+	if ed.Addenda99Contested != nil {
+		n += 1
+	}
 	return n
 }
 
diff --git a/fieldErrors.go b/fieldErrors.go
index 36ea87cb..b3842bcc 100644
--- a/fieldErrors.go
+++ b/fieldErrors.go
@@ -78,6 +78,8 @@ var (
 	ErrAddenda99ReturnCode = errors.New("found is not a valid return code")
 	// ErrAddenda99DishonoredReturnCode is given when there's an invalid dishonored return code
 	ErrAddenda99DishonoredReturnCode = errors.New("found is not a valid dishonored return code")
+	// 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")
 
diff --git a/file.go b/file.go
index 90cea478..a69aac75 100644
--- a/file.go
+++ b/file.go
@@ -260,6 +260,10 @@ func setEntryRecordType(e *EntryDetail) {
 		e.Addenda99Dishonored.recordType = "7"
 		e.Addenda99Dishonored.TypeCode = "99"
 	}
+	if e.Addenda99Contested != nil {
+		e.Addenda99Contested.recordType = "7"
+		e.Addenda99Contested.TypeCode = "99"
+	}
 }
 
 func setADVEntryRecordType(e *ADVEntryDetail) {
diff --git a/merge.go b/merge.go
index d797a445..d39f96c7 100644
--- a/merge.go
+++ b/merge.go
@@ -211,6 +211,9 @@ func lineCount(f *File) (int, error) {
 			if entries[j].Addenda99Dishonored != nil {
 				lines++
 			}
+			if entries[j].Addenda99Contested != nil {
+				lines++
+			}
 		}
 	}
 	for i := range f.IATBatches {
diff --git a/reader.go b/reader.go
index 3ad9cd5c..518aeef2 100644
--- a/reader.go
+++ b/reader.go
@@ -457,9 +457,10 @@ func (r *Reader) parseAddenda() error {
 				r.currentBatch.GetEntries()[entryIndex].Category = CategoryNOC
 				r.currentBatch.GetEntries()[entryIndex].Addenda98 = addenda98
 			case "99":
-				// Addenda99 and Addenda99Dishonored records both have their code in the same spot,
-				// so we need to determine which to parse by the value.
-				if IsDishonoredReturnCode(r.line[3:6]) {
+				// Addenda99, Addenda99Dishonored, Addenda99Contested records both have their code
+				// in the same spot, so we need to determine which to parse by the value.
+				switch {
+				case IsDishonoredReturnCode(r.line[3:6]):
 					addenda99Dishonored := NewAddenda99Dishonored()
 					addenda99Dishonored.Parse(r.line)
 					addenda99Dishonored.SetValidation(r.File.validateOpts)
@@ -467,7 +468,17 @@ func (r *Reader) parseAddenda() error {
 						return r.parseError(err)
 					}
 					r.currentBatch.GetEntries()[entryIndex].Addenda99Dishonored = addenda99Dishonored
-				} else {
+
+				case IsContestedReturnCode(r.line[3:6]):
+					addenda99Contested := NewAddenda99Contested()
+					addenda99Contested.Parse(r.line)
+					addenda99Contested.SetValidation(r.File.validateOpts)
+					if err := addenda99Contested.Validate(); err != nil {
+						return r.parseError(err)
+					}
+					r.currentBatch.GetEntries()[entryIndex].Addenda99Contested = addenda99Contested
+
+				default:
 					addenda99 := NewAddenda99()
 					addenda99.Parse(r.line)
 					addenda99.SetValidation(r.File.validateOpts)
diff --git a/writer.go b/writer.go
index 58530aa6..29369e34 100644
--- a/writer.go
+++ b/writer.go
@@ -133,6 +133,12 @@ func (w *Writer) writeBatch(file *File) error {
 					}
 					w.lineNum++
 				}
+				if entry.Addenda99Contested != nil {
+					if _, err := w.w.WriteString(entry.Addenda99Contested.String() + w.LineEnding); err != nil {
+						return err
+					}
+					w.lineNum++
+				}
 			}
 		} else {
 			for _, entry := range batch.GetADVEntries() {


Test patch:
diff --git a/addenda99_contested_return.go b/addenda99_contested_return.go
new file mode 100644
index 00000000..be0dbcfd
--- /dev/null
+++ b/addenda99_contested_return.go
@@ -0,0 +1,217 @@
+// 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 Addenda99Contested struct {
+	// ID is a client defined string used as a reference to this record.
+	ID string `json:"id"`
+	// RecordType defines the type of record in the block. entryAddendaPos 7
+	recordType string
+
+	// TypeCode Addenda types code '99'
+	TypeCode string `json:"typeCode"`
+
+	// ContestedReturnCode is the return code explaining the contested dishonorment
+	ContestedReturnCode string `json:"contestedReturnCode"`
+
+	// OriginalEntryTraceNumber is the trace number specifieid in the initial entry
+	OriginalEntryTraceNumber string `json:"originalEntryTraceNumber"`
+
+	// DateOriginalEntryReturned is the original entry's date
+	DateOriginalEntryReturned string `json:"dateOriginalEntryReturned"`
+
+	// OriginalReceivingDFIIdentification is the DFI Identification specifieid in the initial entry
+	OriginalReceivingDFIIdentification string `json:"originalReceivingDFIIdentification"`
+
+	// OriginalSettlementDate is the initial date of settlement
+	OriginalSettlementDate string `json:"originalSettlementDate"`
+
+	// ReturnTraceNumber is the original returns trace number
+	ReturnTraceNumber string `json:"returnTraceNumber"`
+
+	// ReturnSettlementDate is the original return's settlement date
+	ReturnSettlementDate string `json:"returnSettlementDate"`
+
+	// ReturnReasonCode is the original return's code
+	ReturnReasonCode string `json:"returnReasonCode"`
+
+	// DishonoredReturnTraceNumber is the dishonorment's trace number
+	DishonoredReturnTraceNumber string `json:"dishonoredReturnTraceNumber"`
+
+	// DishonoredReturnSettlementDate is the dishonorment's settlement date
+	DishonoredReturnSettlementDate string `json:"dishonoredReturnSettlementDate"`
+
+	// DishonoredReturnReasonCode is the dishonorment's return code
+	DishonoredReturnReasonCode string `json:"dishonoredReturnReasonCode"`
+
+	// TraceNumber is the trace number for contesting
+	TraceNumber string `json:"traceNumber"`
+
+	// validator is composed for data validation
+	validator
+	// converters is composed for ACH to GoLang Converters
+	converters
+
+	validateOpts *ValidateOpts
+}
+
+// NewAddenda99Contested returns a new Addenda99Contested with default values for none exported fields
+func NewAddenda99Contested() *Addenda99Contested {
+	Addenda99Contested := &Addenda99Contested{
+		recordType: "7",
+		TypeCode:   "99",
+	}
+	return Addenda99Contested
+}
+
+func (Addenda99Contested *Addenda99Contested) Parse(record string) {
+	if utf8.RuneCountInString(record) != 94 {
+		return
+	}
+
+	Addenda99Contested.recordType = "7"
+	Addenda99Contested.TypeCode = record[1:3]
+	Addenda99Contested.ContestedReturnCode = record[3:6]
+	Addenda99Contested.OriginalEntryTraceNumber = record[6:21]
+	Addenda99Contested.DateOriginalEntryReturned = record[21:27]
+	Addenda99Contested.OriginalReceivingDFIIdentification = record[27:35]
+	Addenda99Contested.OriginalSettlementDate = record[35:38]
+	Addenda99Contested.ReturnTraceNumber = record[38:53]
+	Addenda99Contested.ReturnSettlementDate = record[53:56]
+	Addenda99Contested.ReturnReasonCode = record[56:58]
+	Addenda99Contested.DishonoredReturnTraceNumber = record[59:73]
+	Addenda99Contested.DishonoredReturnSettlementDate = record[74:76]
+	Addenda99Contested.DishonoredReturnReasonCode = record[76:78]
+	Addenda99Contested.TraceNumber = record[79:94]
+}
+
+func (Addenda99Contested *Addenda99Contested) String() string {
+	var buf strings.Builder
+	buf.Grow(94)
+
+	buf.WriteString(Addenda99Contested.recordType)
+	buf.WriteString(Addenda99Contested.TypeCode)
+	buf.WriteString(Addenda99Contested.ContestedReturnCodeField())
+	buf.WriteString(Addenda99Contested.OriginalEntryTraceNumberField())
+	buf.WriteString(Addenda99Contested.DateOriginalEntryReturnedField())
+	buf.WriteString(Addenda99Contested.OriginalReceivingDFIIdentificationField())
+	buf.WriteString(Addenda99Contested.OriginalSettlementDateField())
+	buf.WriteString(Addenda99Contested.ReturnTraceNumberField())
+	buf.WriteString(Addenda99Contested.ReturnSettlementDateField())
+	buf.WriteString(Addenda99Contested.ReturnReasonCodeField())
+	buf.WriteString(Addenda99Contested.DishonoredReturnTraceNumberField())
+	buf.WriteString(Addenda99Contested.DishonoredReturnSettlementDateField())
+	buf.WriteString(Addenda99Contested.DishonoredReturnReasonCodeField())
+	buf.WriteString(" ")
+	buf.WriteString(Addenda99Contested.TraceNumberField())
+
+	return buf.String()
+}
+
+// SetValidation stores ValidateOpts on the Batch which are to be used to override
+// the default NACHA validation rules.
+func (Addenda99Contested *Addenda99Contested) SetValidation(opts *ValidateOpts) {
+	if Addenda99Contested == nil {
+		return
+	}
+	Addenda99Contested.validateOpts = opts
+}
+
+// Validate verifies NACHA rules for Addenda99Contested
+func (Addenda99Contested *Addenda99Contested) Validate() error {
+	if Addenda99Contested.recordType != "7" {
+		return fieldError("recordType", NewErrRecordType(7), Addenda99Contested.recordType)
+	}
+	if Addenda99Contested.TypeCode == "" {
+		return fieldError("TypeCode", ErrConstructor, Addenda99Contested.TypeCode)
+	}
+	if Addenda99Contested.TypeCode != "99" {
+		return fieldError("TypeCode", ErrAddendaTypeCode, Addenda99Contested.TypeCode)
+	}
+
+	// Verify the ContestedReturnReasonCode matches expected values
+	if Addenda99Contested.validateOpts == nil || !Addenda99Contested.validateOpts.CustomReturnCodes {
+		// We can validate the Contested ReturnCode
+		if !IsContestedReturnCode(Addenda99Contested.ContestedReturnCode) {
+			return fieldError("ContestedReturnCode", ErrAddenda99ContestedReturnCode, Addenda99Contested.ContestedReturnCode)
+		}
+	}
+
+	return nil
+}
+
+func IsContestedReturnCode(code string) bool {
+	switch code {
+	case "R71", "R72", "R73", "R74", "R75", "R76":
+		return true
+	}
+	return false
+}
+
+func (Addenda99Contested *Addenda99Contested) ContestedReturnCodeField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.ContestedReturnCode, 3)
+}
+
+func (Addenda99Contested *Addenda99Contested) OriginalEntryTraceNumberField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.OriginalEntryTraceNumber, 15)
+}
+
+func (Addenda99Contested *Addenda99Contested) DateOriginalEntryReturnedField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.DateOriginalEntryReturned, 6)
+}
+
+func (Addenda99Contested *Addenda99Contested) OriginalReceivingDFIIdentificationField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.OriginalReceivingDFIIdentification, 8)
+}
+
+func (Addenda99Contested *Addenda99Contested) OriginalSettlementDateField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.OriginalSettlementDate, 3)
+}
+
+func (Addenda99Contested *Addenda99Contested) ReturnTraceNumberField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.ReturnTraceNumber, 15)
+}
+
+func (Addenda99Contested *Addenda99Contested) ReturnSettlementDateField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.ReturnSettlementDate, 3)
+}
+
+func (Addenda99Contested *Addenda99Contested) ReturnReasonCodeField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.ReturnReasonCode, 2)
+}
+
+func (Addenda99Contested *Addenda99Contested) DishonoredReturnTraceNumberField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.DishonoredReturnTraceNumber, 15)
+}
+
+func (Addenda99Contested *Addenda99Contested) DishonoredReturnSettlementDateField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.DishonoredReturnSettlementDate, 3)
+}
+
+func (Addenda99Contested *Addenda99Contested) DishonoredReturnReasonCodeField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.DishonoredReturnReasonCode, 2)
+}
+
+func (Addenda99Contested *Addenda99Contested) TraceNumberField() string {
+	return Addenda99Contested.stringField(Addenda99Contested.TraceNumber, 15)
+}
diff --git a/addenda99_contested_return_test.go b/addenda99_contested_return_test.go
new file mode 100644
index 00000000..a7902455
--- /dev/null
+++ b/addenda99_contested_return_test.go
@@ -0,0 +1,110 @@
+// 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"
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func mockAddenda99Contested() *Addenda99Contested {
+	addenda99 := NewAddenda99Contested()
+	addenda99.ContestedReturnCode = "R71"
+	addenda99.OriginalEntryTraceNumber = "059999990000301"
+	addenda99.DateOriginalEntryReturned = "167"
+	addenda99.OriginalReceivingDFIIdentification = "12391871"
+	addenda99.OriginalSettlementDate = "164"
+	addenda99.ReturnTraceNumber = "779999990000301"
+	addenda99.ReturnSettlementDate = "165"
+	addenda99.ReturnReasonCode = "01"
+	addenda99.DishonoredReturnTraceNumber = "889999990000301"
+	addenda99.DishonoredReturnSettlementDate = "166"
+	addenda99.DishonoredReturnReasonCode = "67"
+	addenda99.TraceNumber = "123918710000001"
+	return addenda99
+}
+
+func TestAddenda99Contested__Fields(t *testing.T) {
+	addenda99 := mockAddenda99Contested()
+
+	// shorten some fields
+	addenda99.OriginalEntryTraceNumber = "0599999900301"
+	addenda99.ReturnTraceNumber = "1239187101"
+	addenda99.TraceNumber = "1239187100001"
+
+	require.Equal(t, "R71", addenda99.ContestedReturnCodeField())
+	require.Equal(t, "000599999900301", addenda99.OriginalEntryTraceNumberField())
+	require.Equal(t, "000167", addenda99.DateOriginalEntryReturnedField())
+	require.Equal(t, "12391871", addenda99.OriginalReceivingDFIIdentificationField())
+	require.Equal(t, "164", addenda99.OriginalSettlementDateField())
+	require.Equal(t, "000001239187101", addenda99.ReturnTraceNumberField())
+	require.Equal(t, "165", addenda99.ReturnSettlementDateField())
+	require.Equal(t, "01", addenda99.ReturnReasonCodeField())
+	require.Equal(t, "889999990000301", addenda99.DishonoredReturnTraceNumberField())
+	require.Equal(t, "166", addenda99.DishonoredReturnSettlementDateField())
+	require.Equal(t, "67", addenda99.DishonoredReturnReasonCodeField())
+	require.Equal(t, "001239187100001", addenda99.TraceNumberField())
+}
+
+func TestAddenda99Contested(t *testing.T) {
+	file := NewFile()
+	file.SetHeader(mockFileHeader())
+	file.Control = mockFileControl()
+
+	batch, err := NewBatch(mockBatchHeader())
+	require.NoError(t, err)
+
+	ed := mockEntryDetail()
+	ed.AddendaRecordIndicator = 1
+	ed.Category = CategoryDishonoredReturnContested
+
+	addenda99 := mockAddenda99()
+	ed.Addenda99 = addenda99
+	ed.Addenda99Dishonored = mockAddenda99Dishonored()
+	ed.Addenda99Contested = mockAddenda99Contested()
+	require.Equal(t, 3, ed.addendaCount())
+
+	batch.AddEntry(ed)
+	require.NoError(t, batch.Create())
+	require.Equal(t, 4, batch.GetControl().EntryAddendaCount)
+
+	file.AddBatch(batch)
+	require.NoError(t, file.Create())
+
+	require.Equal(t, 1, len(file.Batches))
+	require.Equal(t, 1, len(file.Batches[0].GetEntries()))
+	require.Equal(t, 4, file.Batches[0].GetControl().EntryAddendaCount)
+	require.Equal(t, 4, file.Control.EntryAddendaCount)
+
+	require.NoError(t, file.Validate())
+
+	var buf bytes.Buffer
+	if err := NewWriter(&buf).Write(file); err != nil {
+		t.Fatal(err)
+	}
+
+	path := filepath.Join("examples", "testdata", "contested-return.ach")
+	err = ioutil.WriteFile(path, buf.Bytes(), 0600)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/examples/testdata/contested-return.ach b/examples/testdata/contested-return.ach
new file mode 100644
index 00000000..1f0dbd56
--- /dev/null
+++ b/examples/testdata/contested-return.ach
@@ -0,0 +1,10 @@
+101 231380104 1210428822203311019A094101Federal Reserve Bank   My Bank Name                   
+5220ACME Corporation                    121042882 PPDPAYROLL         000000   1121042880000001
+622121042882123456789        0100000000ABC##jvkdjfuiwnWade Arnold             1121042880000001
+799R07099912340000015      09101298Authorization Revoked                       000000000000000
+799R68059999990000301      12391871   12391871000000117901Untimely Return      059999990000001
+799R71059999990000301000167123918711647799999900003011650188999999000030116667 123918710000001
+82200000040012104288000000000000000100000000121042882                          121042880000001
+9000001000001000000040012104288000000000000000100000000                                       
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
diff --git a/examples/testdata/dishonored-return.ach b/examples/testdata/dishonored-return.ach
index 821b293c..44d99231 100644
--- a/examples/testdata/dishonored-return.ach
+++ b/examples/testdata/dishonored-return.ach
@@ -1,4 +1,4 @@
-101 231380104 1210428822203301802A094101Federal Reserve Bank   My Bank Name                   
+101 231380104 1210428822203310948A094101Federal Reserve Bank   My Bank Name                   
 5225Payee Name                          231380104 POSACH POS         000000   1231380100000001
 627121042882744-5678-99      000002500045689033       Wade Arnold           011231380100000001
 799R68059999990000301      12391871   12391871000000117901Untimely Return      059999990000001