diff --git a/addenda98.go b/addenda98.go index 8c8e82828..387e51fbc 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 000000000..d30df1697 --- /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/addenda98_refused_test.go b/addenda98_refused_test.go new file mode 100644 index 000000000..520afe6b2 --- /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) +} diff --git a/batch.go b/batch.go index 7ee096e4a..603469687 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 3e26915c7..02730cff0 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 2aa8d04ce..70f927222 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 b3842bcc7..881b991a2 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 da84ef0f0..1b949983d 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 23fe2c399..f0929e533 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 317e8bbf2..b5f159234 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 8ee4eba69..09bf9c040 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