Repository: moov-io/ach
Pull request #217 (2018-06-19 15:02:43)

#207 Add support for SHR
#207 Add support for SHR


Patch:
diff --git a/README.md b/README.md
index 353b4f40..26025e9d 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Package 'moov-io/ach' implements a file reader and writer for parsing [ACH](http
 ACH is under active development but already in production for multiple companies. Please star the project if you are interested in its progress.
 
 * Library currently supports the reading and writing 
-	* ARC (Accounts Receivable Entry
+	* ARC (Accounts Receivable Entry)
 	* BOC (Back Office Conversion)
 	* CCD (Corporate credit or debit)
 	* COR (Automated Notification of Change(NOC))
@@ -24,6 +24,7 @@ ACH is under active development but already in production for multiple companies
 	* POS (Point of Sale)
 	* PPD (Prearranged payment and deposits)
 	* RCK (Represented Check Entries)
+	* SHR (Shared Network Entry)
 	* TEL (Telephone-Initiated Entry)
 	* WEB (Internet-initiated Entries)
 	* Return Entries
diff --git a/addenda98.go b/addenda98.go
index 6577e8ff..017d8292 100644
--- a/addenda98.go
+++ b/addenda98.go
@@ -136,7 +136,7 @@ func (addenda98 *Addenda98) OriginalTraceField() string {
 
 // OriginalDFIField returns a zero padded OriginalDFI string
 func (addenda98 *Addenda98) OriginalDFIField() string {
-	return addenda98.stringRTNField(addenda98.OriginalDFI, 8)
+	return addenda98.stringField(addenda98.OriginalDFI, 8)
 }
 
 //CorrectedDataField returns a space padded CorrectedData string
diff --git a/addenda99.go b/addenda99.go
index 7c908311..676cfbbf 100644
--- a/addenda99.go
+++ b/addenda99.go
@@ -149,7 +149,7 @@ func (Addenda99 *Addenda99) DateOfDeathField() string {
 
 // OriginalDFIField returns a zero padded OriginalDFI string
 func (Addenda99 *Addenda99) OriginalDFIField() string {
-	return Addenda99.stringRTNField(Addenda99.OriginalDFI, 8)
+	return Addenda99.stringField(Addenda99.OriginalDFI, 8)
 }
 
 //AddendaInformationField returns a space padded AddendaInformation string
diff --git a/batch.go b/batch.go
index 70915491..439305ae 100644
--- a/batch.go
+++ b/batch.go
@@ -43,6 +43,8 @@ func NewBatch(bh *BatchHeader) (Batcher, error) {
 		return NewBatchPPD(bh), nil
 	case "RCK":
 		return NewBatchRCK(bh), nil
+	case "SHR":
+		return NewBatchSHR(bh), nil
 	case "TEL":
 		return NewBatchTEL(bh), nil
 	case "WEB":
diff --git a/batchControl.go b/batchControl.go
index 16e0ead0..077e2366 100644
--- a/batchControl.go
+++ b/batchControl.go
@@ -36,7 +36,7 @@ type BatchControl struct {
 	TotalDebitEntryDollarAmount int `json:"totalDebit"`
 	// TotalCreditEntryDollarAmount Contains accumulated Entry credit totals within the batch.
 	TotalCreditEntryDollarAmount int `json:"totalCredit"`
-	// CompanyIdentification is an alphameric code used to identify an Originator
+	// CompanyIdentification is an alphanumeric code used to identify an Originator
 	// The Company Identification Field must be included on all
 	// prenotification records and on each entry initiated pursuant to such
 	// prenotification. The Company ID may begin with the ANSI one-digit
@@ -174,7 +174,7 @@ func (bc *BatchControl) EntryHashField() string {
 	return bc.numericField(bc.EntryHash, 10)
 }
 
-//TotalDebitEntryDollarAmountField get a zero padded Debity Entry Amount
+//TotalDebitEntryDollarAmountField get a zero padded Debit Entry Amount
 func (bc *BatchControl) TotalDebitEntryDollarAmountField() string {
 	return bc.numericField(bc.TotalDebitEntryDollarAmount, 12)
 }
@@ -184,7 +184,7 @@ func (bc *BatchControl) TotalCreditEntryDollarAmountField() string {
 	return bc.numericField(bc.TotalCreditEntryDollarAmount, 12)
 }
 
-// CompanyIdentificationField get the CompanyIdentification righ padded
+// CompanyIdentificationField get the CompanyIdentification right padded
 func (bc *BatchControl) CompanyIdentificationField() string {
 	return bc.alphaField(bc.CompanyIdentification, 10)
 }
@@ -196,7 +196,7 @@ func (bc *BatchControl) MessageAuthenticationCodeField() string {
 
 // ODFIIdentificationField get the odfi number zero padded
 func (bc *BatchControl) ODFIIdentificationField() string {
-	return bc.stringRTNField(bc.ODFIIdentification, 8)
+	return bc.stringField(bc.ODFIIdentification, 8)
 }
 
 // BatchNumberField gets a string of the batch number zero padded
diff --git a/batchHeader.go b/batchHeader.go
index 58d48330..31fbd194 100644
--- a/batchHeader.go
+++ b/batchHeader.go
@@ -266,7 +266,7 @@ func (bh *BatchHeader) EffectiveEntryDateField() string {
 
 // ODFIIdentificationField get the odfi number zero padded
 func (bh *BatchHeader) ODFIIdentificationField() string {
-	return bh.stringRTNField(bh.ODFIIdentification, 8)
+	return bh.stringField(bh.ODFIIdentification, 8)
 }
 
 // BatchNumberField get the batch number zero padded
diff --git a/batchSHR.go b/batchSHR.go
new file mode 100644
index 00000000..51b4adbd
--- /dev/null
+++ b/batchSHR.go
@@ -0,0 +1,111 @@
+// Copyright 2018 The ACH Authors
+// Use of this source code is governed by an Apache License
+// license that can be found in the LICENSE file.
+
+package ach
+
+import (
+	"fmt"
+)
+
+// BatchSHR holds the BatchHeader and BatchControl and all EntryDetail for SHR Entries.
+//
+// Shared Network Entry (SHR) is a debit Entry initiated at an “electronic terminal,”
+// as that term is defined in Regulation E, to a Consumer Account of the Receiver to pay
+// an obligation incurred in a point-of-sale transaction, or to effect a point-of-sale
+// terminal cash withdrawal. Also an adjusting or other credit Entry related to such debit
+// Entry, transfer of funds, or obligation. SHR Entries are initiated in a shared network
+// where the ODFI and RDFI have an agreement in addition to these Rules to process such
+// Entries.
+type BatchSHR struct {
+	batch
+}
+
+var msgBatchSHRAddenda = "found and 1 Addenda02 is required for SEC code SHR"
+var msgBatchSHRAddendaType = "%T found where Addenda02 is required for SEC code SHR"
+
+// NewBatchSHR returns a *BatchSHR
+func NewBatchSHR(bh *BatchHeader) *BatchSHR {
+	batch := new(BatchSHR)
+	batch.SetControl(NewBatchControl())
+	batch.SetHeader(bh)
+	return batch
+}
+
+// Validate checks valid NACHA batch rules. Assumes properly parsed records.
+func (batch *BatchSHR) Validate() error {
+	// basic verification of the batch before we validate specific rules.
+	if err := batch.verify(); err != nil {
+		return err
+	}
+	// Add configuration based validation for this type.
+
+	// Add type specific validation.
+
+	if batch.Header.StandardEntryClassCode != "SHR" {
+		msg := fmt.Sprintf(msgBatchSECType, batch.Header.StandardEntryClassCode, "SHR")
+		return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: "StandardEntryClassCode", Msg: msg}
+	}
+
+	// SHR detail entries can only be a debit, ServiceClassCode must allow debits
+	switch batch.Header.ServiceClassCode {
+	case 200, 220, 280:
+		msg := fmt.Sprintf(msgBatchServiceClassCode, batch.Header.ServiceClassCode, "SHR")
+		return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: "ServiceClassCode", Msg: msg}
+	}
+
+	for _, entry := range batch.Entries {
+		// SHR detail entries must be a debit
+		if entry.CreditOrDebit() != "D" {
+			msg := fmt.Sprintf(msgBatchTransactionCodeCredit, entry.TransactionCode)
+			return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: "TransactionCode", Msg: msg}
+		}
+		if err := entry.isCardTransactionType(entry.DiscretionaryData); err != nil {
+			msg := fmt.Sprintf(msgBatchCardTransactionType, entry.DiscretionaryData)
+			return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: "CardTransactionType", Msg: msg}
+		}
+
+		// CardExpirationDate BatchSHR ACH File format is MMYY.  Validate MM is 01-12.
+		if err := entry.isMonth(entry.parseStringField(entry.SHRCardExpirationDateField()[0:2])); err != nil {
+			return &FieldError{FieldName: "CardExpirationDate", Value: entry.parseStringField(entry.SHRCardExpirationDateField()[0:2]), Msg: msgValidMonth}
+		}
+
+		if err := entry.isYear(entry.parseStringField(entry.SHRCardExpirationDateField()[2:4])); err != nil {
+			return &FieldError{FieldName: "CardExpirationDate", Value: entry.parseStringField(entry.SHRCardExpirationDateField()[2:4]), Msg: msgValidYear}
+		}
+
+		// Addenda validations - SHR Addenda must be Addenda02
+
+		// Addendum must be equal to 1
+		if len(entry.Addendum) != 1 {
+			return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: "Addendum", Msg: msgBatchSHRAddenda}
+		}
+
+		// Addenda type assertion must be Addenda02
+		addenda02, ok := entry.Addendum[0].(*Addenda02)
+		if !ok {
+			msg := fmt.Sprintf(msgBatchSHRAddendaType, entry.Addendum[0])
+			return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: "Addendum", Msg: msg}
+		}
+
+		// Addenda02 must be Validated
+		if err := addenda02.Validate(); err != nil {
+			// convert the field error in to a batch error for a consistent api
+			if e, ok := err.(*FieldError); ok {
+				return &BatchError{BatchNumber: batch.Header.BatchNumber, FieldName: e.FieldName, Msg: e.Msg}
+			}
+		}
+	}
+	return nil
+}
+
+// Create takes Batch Header and Entries and builds a valid batch
+func (batch *BatchSHR) Create() error {
+	// generates sequence numbers and batch control
+	if err := batch.build(); err != nil {
+		return err
+	}
+	// Additional steps specific to batch type
+	// ...
+	return batch.Validate()
+}
diff --git a/converters.go b/converters.go
index dfd451fc..1000fd1c 100644
--- a/converters.go
+++ b/converters.go
@@ -66,8 +66,8 @@ func (c *converters) numericField(n int, max uint) string {
 	return s
 }
 
-// stringRTNField slices to max length and zero filled
-func (c *converters) stringRTNField(s string, max uint) string {
+// stringField slices to max length and zero filled
+func (c *converters) stringField(s string, max uint) string {
 	ln := uint(len(s))
 	if ln > max {
 		return s[:max]
diff --git a/entryDetail.go b/entryDetail.go
index 6d82726c..8ff8a4ed 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -245,7 +245,7 @@ func (ed *EntryDetail) AddAddenda(addenda Addendumer) []Addendumer {
 
 // SetRDFI takes the 9 digit RDFI account number and separates it for RDFIIdentification and CheckDigit
 func (ed *EntryDetail) SetRDFI(rdfi string) *EntryDetail {
-	s := ed.stringRTNField(rdfi, 9)
+	s := ed.stringField(rdfi, 9)
 	ed.RDFIIdentification = ed.parseStringField(s[:8])
 	ed.CheckDigit = ed.parseStringField(s[8:9])
 	return ed
@@ -253,13 +253,13 @@ func (ed *EntryDetail) SetRDFI(rdfi string) *EntryDetail {
 
 // SetTraceNumber takes first 8 digits of ODFI and concatenates a sequence number onto the TraceNumber
 func (ed *EntryDetail) SetTraceNumber(ODFIIdentification string, seq int) {
-	trace := ed.stringRTNField(ODFIIdentification, 8) + ed.numericField(seq, 7)
+	trace := ed.stringField(ODFIIdentification, 8) + ed.numericField(seq, 7)
 	ed.TraceNumber = ed.parseNumField(trace)
 }
 
 // RDFIIdentificationField get the rdfiIdentification with zero padding
 func (ed *EntryDetail) RDFIIdentificationField() string {
-	return ed.stringRTNField(ed.RDFIIdentification, 8)
+	return ed.stringField(ed.RDFIIdentification, 8)
 }
 
 // DFIAccountNumberField gets the DFIAccountNumber with space padding
@@ -328,6 +328,42 @@ func (ed *EntryDetail) POPTerminalStateField() string {
 	return ed.parseStringField(ed.IdentificationNumber[13:15])
 }
 
+// SetSHRCardExpirationDate format MMYY is used in SHR, characters 1-4 of underlying
+// IdentificationNumber
+func (ed *EntryDetail) SetSHRCardExpirationDate(s string) {
+	ed.IdentificationNumber = ed.alphaField(s, 4)
+}
+
+// SetSHRDocumentReferenceNumber format int is used in SHR, characters 5-15 of underlying
+// IdentificationNumber
+func (ed *EntryDetail) SetSHRDocumentReferenceNumber(s string) {
+	ed.IdentificationNumber = ed.IdentificationNumber + ed.stringField(s, 11)
+}
+
+// SetSHRIndividualCardAccountNumber format int is used in SHR, underlying
+// IndividualName
+func (ed *EntryDetail) SetSHRIndividualCardAccountNumber(s string) {
+	ed.IndividualName = ed.stringField(s, 22)
+}
+
+// SHRCardExpirationDateField format MMYY is used in SHR, characters 1-4 of underlying
+// IdentificationNumber
+func (ed *EntryDetail) SHRCardExpirationDateField() string {
+	return ed.parseStringField(ed.IdentificationNumber[0:4])
+}
+
+// SHRDocumentReferenceNumberField format int is used in SHR, characters 5-15 of underlying
+// IdentificationNumber
+func (ed *EntryDetail) SHRDocumentReferenceNumberField() string {
+	return ed.stringField(ed.IdentificationNumber[4:15], 11)
+}
+
+// SHRIndividualCardAccountNumberField format int is used in SHR, underlying
+// IndividualName
+func (ed *EntryDetail) SHRIndividualCardAccountNumberField() string {
+	return ed.stringField(ed.IndividualName, 22)
+}
+
 // IndividualNameField returns a space padded string of IndividualName
 func (ed *EntryDetail) IndividualNameField() string {
 	return ed.alphaField(ed.IndividualName, 22)
diff --git a/fileHeader.go b/fileHeader.go
index cf01b2fd..f5fec7d8 100644
--- a/fileHeader.go
+++ b/fileHeader.go
@@ -116,7 +116,7 @@ func (fh *FileHeader) Parse(record string) {
 	// (14-23) A 10-digit number assigned to you by the ODFI once they approve you to originate ACH files through them
 	fh.ImmediateOrigin = fh.parseStringField(record[13:23])
 	// 24-29 Today's date in YYMMDD format
-	// must be after todays date.
+	// must be after today's date.
 	fh.FileCreationDate = fh.parseSimpleDate(record[23:29])
 	// 30-33 The current time in HHMM format
 	fh.FileCreationTime = fh.parseSimpleTime(record[29:33])
@@ -240,12 +240,12 @@ func (fh *FileHeader) fieldInclusion() error {
 
 // ImmediateDestinationField gets the immediate destination number with zero padding
 func (fh *FileHeader) ImmediateDestinationField() string {
-	return " " + fh.stringRTNField(fh.ImmediateDestination, 9)
+	return " " + fh.stringField(fh.ImmediateDestination, 9)
 }
 
 // ImmediateOriginField gets the immediate origin number with 0 padding
 func (fh *FileHeader) ImmediateOriginField() string {
-	return " " + fh.stringRTNField(fh.ImmediateOrigin, 9)
+	return " " + fh.stringField(fh.ImmediateOrigin, 9)
 }
 
 // FileCreationDateField gets the file creation date in YYMMDD format
diff --git a/validators.go b/validators.go
index ca13e000..a88a9f7e 100644
--- a/validators.go
+++ b/validators.go
@@ -30,6 +30,7 @@ var (
 	msgCardTransactionType = "is an invalid Card Transaction Type"
 	msgValidMonth          = "is an invalid month"
 	msgValidDay            = "is an invalid day"
+	msgValidYear           = "is an invalid year"
 )
 
 // validator is common validation and formatting of golang types to ach type strings
@@ -75,6 +76,21 @@ func (v *validator) isCardTransactionType(code string) error {
 	return errors.New(msgCardTransactionType)
 }
 
+// isYear validates a 2 digit year 18-50 (2018 - 2050)
+// ToDo:  Add/remove more years as card expiration dates need/don't need them
+func (v *validator) isYear(s string) error {
+	switch s {
+	case
+		"18", "19",
+		"20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
+		"30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
+		"40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
+		"50":
+		return nil
+	}
+	return errors.New(msgValidYear)
+}
+
 // isMonth validates a 2 digit month 01-12
 func (v *validator) isMonth(s string) error {
 	switch s {


Test patch:
diff --git a/batchPOS_test.go b/batchPOS_test.go
index 575472fe..69f377b0 100644
--- a/batchPOS_test.go
+++ b/batchPOS_test.go
@@ -26,7 +26,8 @@ func mockPOSEntryDetail() *EntryDetail {
 	entry.DFIAccountNumber = "744-5678-99"
 	entry.Amount = 25000
 	entry.IdentificationNumber = "45689033"
-	entry.SetReceivingCompany("ABC Company")
+	entry.IndividualName = "Wade Arnold"
+	//entry.SetReceivingCompany("ABC Company")
 	entry.SetTraceNumber(mockBatchPOSHeader().ODFIIdentification, 123)
 	entry.DiscretionaryData = "01"
 	entry.Category = CategoryForward
diff --git a/batchSHR_test.go b/batchSHR_test.go
new file mode 100644
index 00000000..52ea69dd
--- /dev/null
+++ b/batchSHR_test.go
@@ -0,0 +1,570 @@
+// Copyright 2018 The ACH Authors
+// Use of this source code is governed by an Apache License
+// license that can be found in the LICENSE file.
+
+package ach
+
+import (
+	"testing"
+)
+
+// mockBatchSHRHeader creates a BatchSHR BatchHeader
+func mockBatchSHRHeader() *BatchHeader {
+	bh := NewBatchHeader()
+	bh.ServiceClassCode = 225
+	bh.StandardEntryClassCode = "SHR"
+	bh.CompanyName = "Payee Name"
+	bh.CompanyIdentification = "121042882"
+	bh.CompanyEntryDescription = "ACH SHR"
+	bh.ODFIIdentification = "12104288"
+	return bh
+}
+
+// mockSHREntryDetail creates a BatchSHR EntryDetail
+func mockSHREntryDetail() *EntryDetail {
+	entry := NewEntryDetail()
+	entry.TransactionCode = 27
+	entry.SetRDFI("231380104")
+	entry.DFIAccountNumber = "744-5678-99"
+	entry.Amount = 25000
+	entry.SetSHRCardExpirationDate("0722")
+	entry.SetSHRDocumentReferenceNumber("12345678910")
+	entry.SetSHRIndividualCardAccountNumber("1234567891123456789")
+	entry.SetTraceNumber(mockBatchSHRHeader().ODFIIdentification, 123)
+	entry.DiscretionaryData = "01"
+	entry.Category = CategoryForward
+	return entry
+}
+
+// mockBatchSHR creates a BatchSHR
+func mockBatchSHR() *BatchSHR {
+	mockBatch := NewBatchSHR(mockBatchSHRHeader())
+	mockBatch.AddEntry(mockSHREntryDetail())
+	mockBatch.GetEntries()[0].AddAddenda(mockAddenda02())
+	if err := mockBatch.Create(); err != nil {
+		panic(err)
+	}
+	return mockBatch
+}
+
+// testBatchSHRHeader creates a BatchSHR BatchHeader
+func testBatchSHRHeader(t testing.TB) {
+	batch, _ := NewBatch(mockBatchSHRHeader())
+	err, ok := batch.(*BatchSHR)
+	if !ok {
+		t.Errorf("Expecting BatchSHR got %T", err)
+	}
+}
+
+// TestBatchSHRHeader tests validating BatchSHR BatchHeader
+func TestBatchSHRHeader(t *testing.T) {
+	testBatchSHRHeader(t)
+}
+
+// BenchmarkBatchSHRHeader benchmarks validating BatchSHR BatchHeader
+func BenchmarkBatchSHRHeader(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRHeader(b)
+	}
+}
+
+// testBatchSHRCreate validates BatchSHR create
+func testBatchSHRCreate(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		t.Errorf("%T: %s", err, err)
+	}
+}
+
+// TestBatchSHRCreate tests validating BatchSHR create
+func TestBatchSHRCreate(t *testing.T) {
+	testBatchSHRCreate(t)
+}
+
+// BenchmarkBatchSHRCreate benchmarks validating BatchSHR create
+func BenchmarkBatchSHRCreate(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRCreate(b)
+	}
+}
+
+// testBatchSHRStandardEntryClassCode validates BatchSHR create for an invalid StandardEntryClassCode
+func testBatchSHRStandardEntryClassCode(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.Header.StandardEntryClassCode = "WEB"
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "StandardEntryClassCode" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRStandardEntryClassCode tests validating BatchSHR create for an invalid StandardEntryClassCode
+func TestBatchSHRStandardEntryClassCode(t *testing.T) {
+	testBatchSHRStandardEntryClassCode(t)
+}
+
+// BenchmarkBatchSHRStandardEntryClassCode benchmarks validating BatchSHR create for an invalid StandardEntryClassCode
+func BenchmarkBatchSHRStandardEntryClassCode(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRStandardEntryClassCode(b)
+	}
+}
+
+// testBatchSHRServiceClassCodeEquality validates service class code equality
+func testBatchSHRServiceClassCodeEquality(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetControl().ServiceClassCode = 200
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "ServiceClassCode" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRServiceClassCodeEquality tests validating service class code equality
+func TestBatchSHRServiceClassCodeEquality(t *testing.T) {
+	testBatchSHRServiceClassCodeEquality(t)
+}
+
+// BenchmarkBatchSHRServiceClassCodeEquality benchmarks validating service class code equality
+func BenchmarkBatchSHRServiceClassCodeEquality(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRServiceClassCodeEquality(b)
+	}
+}
+
+// testBatchSHRServiceClass200 validates BatchSHR create for an invalid ServiceClassCode 200
+func testBatchSHRServiceClass200(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.Header.ServiceClassCode = 200
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "ServiceClassCode" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRServiceClass200 tests validating BatchSHR create for an invalid ServiceClassCode 200
+func TestBatchSHRServiceClass200(t *testing.T) {
+	testBatchSHRServiceClass200(t)
+}
+
+// BenchmarkBatchSHRServiceClass200 benchmarks validating BatchSHR create for an invalid ServiceClassCode 200
+func BenchmarkBatchSHRServiceClass200(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRServiceClass200(b)
+	}
+}
+
+// testBatchSHRServiceClass220 validates BatchSHR create for an invalid ServiceClassCode 220
+func testBatchSHRServiceClass220(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.Header.ServiceClassCode = 220
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "ServiceClassCode" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRServiceClass220 tests validating BatchSHR create for an invalid ServiceClassCode 220
+func TestBatchSHRServiceClass220(t *testing.T) {
+	testBatchSHRServiceClass220(t)
+}
+
+// BenchmarkBatchSHRServiceClass220 benchmarks validating BatchSHR create for an invalid ServiceClassCode 220
+func BenchmarkBatchSHRServiceClass220(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRServiceClass220(b)
+	}
+}
+
+// testBatchSHRServiceClass280 validates BatchSHR create for an invalid ServiceClassCode 280
+func testBatchSHRServiceClass280(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.Header.ServiceClassCode = 280
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "ServiceClassCode" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRServiceClass280 tests validating BatchSHR create for an invalid ServiceClassCode 280
+func TestBatchSHRServiceClass280(t *testing.T) {
+	testBatchSHRServiceClass280(t)
+}
+
+// BenchmarkBatchSHRServiceClass280 benchmarks validating BatchSHR create for an invalid ServiceClassCode 280
+func BenchmarkBatchSHRServiceClass280(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRServiceClass280(b)
+	}
+}
+
+// testBatchSHRTransactionCode validates BatchSHR TransactionCode is not a credit
+func testBatchSHRTransactionCode(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetEntries()[0].TransactionCode = 22
+	if err := mockBatch.Create(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "TransactionCode" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRTransactionCode tests validating BatchSHR TransactionCode is not a credit
+func TestBatchSHRTransactionCode(t *testing.T) {
+	testBatchSHRTransactionCode(t)
+}
+
+// BenchmarkBatchSHRTransactionCode benchmarks validating BatchSHR TransactionCode is not a credit
+func BenchmarkBatchSHRTransactionCode(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRTransactionCode(b)
+	}
+}
+
+// testBatchSHRAddendaCount validates BatchSHR Addendum count of 2
+func testBatchSHRAddendaCount(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetEntries()[0].AddAddenda(mockAddenda02())
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "Addendum" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRAddendaCount tests validating BatchSHR Addendum count of 2
+func TestBatchSHRAddendaCount(t *testing.T) {
+	testBatchSHRAddendaCount(t)
+}
+
+// BenchmarkBatchSHRAddendaCount benchmarks validating BatchSHR Addendum count of 2
+func BenchmarkBatchSHRAddendaCount(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRAddendaCount(b)
+	}
+}
+
+// testBatchSHRAddendaCountZero validates Addendum count of 0
+func testBatchSHRAddendaCountZero(t testing.TB) {
+	mockBatch := NewBatchSHR(mockBatchSHRHeader())
+	mockBatch.AddEntry(mockSHREntryDetail())
+	if err := mockBatch.Create(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "Addendum" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRAddendaCountZero tests validating Addendum count of 0
+func TestBatchSHRAddendaCountZero(t *testing.T) {
+	testBatchSHRAddendaCountZero(t)
+}
+
+// BenchmarkBatchSHRAddendaCountZero benchmarks validating Addendum count of 0
+func BenchmarkBatchSHRAddendaCountZero(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRAddendaCountZero(b)
+	}
+}
+
+// testBatchSHRInvalidAddendum validates Addendum must be Addenda02
+func testBatchSHRInvalidAddendum(t testing.TB) {
+	mockBatch := NewBatchSHR(mockBatchSHRHeader())
+	mockBatch.AddEntry(mockSHREntryDetail())
+	mockBatch.GetEntries()[0].AddAddenda(mockAddenda05())
+	if err := mockBatch.Create(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "Addendum" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRInvalidAddendum tests validating Addendum must be Addenda02
+func TestBatchSHRInvalidAddendum(t *testing.T) {
+	testBatchSHRInvalidAddendum(t)
+}
+
+// BenchmarkBatchSHRInvalidAddendum benchmarks validating Addendum must be Addenda02
+func BenchmarkBatchSHRInvalidAddendum(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRInvalidAddendum(b)
+	}
+}
+
+// testBatchSHRInvalidAddenda validates Addendum must be Addenda02
+func testBatchSHRInvalidAddenda(t testing.TB) {
+	mockBatch := NewBatchSHR(mockBatchSHRHeader())
+	mockBatch.AddEntry(mockSHREntryDetail())
+	addenda02 := mockAddenda02()
+	addenda02.recordType = "63"
+	mockBatch.GetEntries()[0].AddAddenda(addenda02)
+	if err := mockBatch.Create(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "recordType" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRInvalidAddenda tests validating Addendum must be Addenda02
+func TestBatchSHRInvalidAddenda(t *testing.T) {
+	testBatchSHRInvalidAddenda(t)
+}
+
+// BenchmarkBatchSHRInvalidAddenda benchmarks validating Addendum must be Addenda02
+func BenchmarkBatchSHRInvalidAddenda(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRInvalidAddenda(b)
+	}
+}
+
+// ToDo: Using a FieldError may need to add a BatchError and use *BatchError
+
+// testBatchSHRInvalidBuild validates an invalid batch build
+func testBatchSHRInvalidBuild(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetHeader().recordType = "3"
+	if err := mockBatch.Create(); err != nil {
+		if e, ok := err.(*FieldError); ok {
+			if e.FieldName != "recordType" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRInvalidBuild tests validating an invalid batch build
+func TestBatchSHRInvalidBuild(t *testing.T) {
+	testBatchSHRInvalidBuild(t)
+}
+
+// BenchmarkBatchSHRInvalidBuild benchmarks validating an invalid batch build
+func BenchmarkBatchSHRInvalidBuild(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRInvalidBuild(b)
+	}
+}
+
+// testBatchSHRCardTransactionType validates BatchSHR create for an invalid CardTransactionType
+func testBatchSHRCardTransactionType(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetEntries()[0].DiscretionaryData = "555"
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "CardTransactionType" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestBatchSHRCardTransactionType tests validating BatchSHR create for an invalid CardTransactionType
+func TestBatchSHRCardTransactionType(t *testing.T) {
+	testBatchSHRCardTransactionType(t)
+}
+
+// BenchmarkBatchSHRCardTransactionType benchmarks validating BatchSHR create for an invalid CardTransactionType
+func BenchmarkBatchSHRCardTransactionType(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRCardTransactionType(b)
+	}
+}
+
+// testBatchSHRCardExpirationDateField validates SHRCardExpirationDate
+// characters 0-4 of underlying IdentificationNumber
+func testBatchSHRCardExpirationDateField(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	ts := mockBatch.Entries[0].SHRCardExpirationDateField()
+	if ts != "0718" {
+		t.Error("Card Expiration Date is invalid")
+	}
+}
+
+// TestBatchSHRCardExpirationDateField tests validatingSHRCardExpirationDate
+// characters 0-4 of underlying IdentificationNumber
+func TestBatchSHRCardExpirationDateField(t *testing.T) {
+	testBatchSHRCardExpirationDateField(t)
+}
+
+// BenchmarkBatchSHRCardExpirationDateField benchmarks validating SHRCardExpirationDate
+// characters 0-4 of underlying IdentificationNumber
+func BenchmarkBatchSHRCardExpirationDateField(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRCardExpirationDateField(b)
+	}
+}
+
+// testBatchSHRDocumentReferenceNumberField validates SHRDocumentReferenceNumberField
+// characters 5-15 of underlying IdentificationNumber
+func testBatchSHRDocumentReferenceNumberField(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	ts := mockBatch.Entries[0].SHRDocumentReferenceNumberField()
+	if ts != "12345678910" {
+		t.Error("Document Reference Number is invalid")
+	}
+}
+
+// TestBatchSHRDocumentReferenceNumberField tests validating SHRDocumentReferenceNumberField
+// characters 5-15 of underlying IdentificationNumber
+func TestBatchSHRDocumentReferenceNumberField(t *testing.T) {
+	testBatchSHRDocumentReferenceNumberField(t)
+}
+
+// BenchmarkBatchSHRDocumentReferenceNumberField benchmarks validating SHRDocumentReferenceNumberField
+// characters 5-15 of underlying IdentificationNumber
+func BenchmarkSHRDocumentReferenceNumberField(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRDocumentReferenceNumberField(b)
+	}
+}
+
+// testBatchSHRIndividualCardAccountNumberField validates SHRIndividualCardAccountNumberField
+// underlying IndividualName
+func testBatchSHRIndividualCardAccountNumberField(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	ts := mockBatch.Entries[0].SHRIndividualCardAccountNumberField()
+	if ts != "0001234567891123456789" {
+		t.Error("Individual Card Account Number is invalid")
+	}
+}
+
+// TestBatchSHRIndividualCardAccountNumberField tests validating SHRIndividualCardAccountNumberField
+// underlying IndividualName
+func TestBatchSHRIndividualCardAccountNumberField(t *testing.T) {
+	testBatchSHRIndividualCardAccountNumberField(t)
+}
+
+// BenchmarkBatchSHRIndividualCardAccountNumberField benchmarks validating SHRIndividualCardAccountNumberField
+// underlying IndividualName
+func BenchmarkBatchSHRDocumentReferenceNumberField(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchSHRIndividualCardAccountNumberField(b)
+	}
+}
+
+// testSHRCardExpirationDateMonth validates the month is valid for CardExpirationDate
+func testSHRCardExpirationDateMonth(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetEntries()[0].SetSHRCardExpirationDate("1306")
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*FieldError); ok {
+			if e.FieldName != "CardExpirationDate" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestSHRCardExpirationDateMonth tests validating the month is valid for CardExpirationDate
+func TestSHRSHRCardExpirationDateMonth(t *testing.T) {
+	testSHRCardExpirationDateMonth(t)
+}
+
+// BenchmarkSHRCardExpirationDateMonth test validating the month is valid for CardExpirationDate
+func BenchmarkSHRCardExpirationDateMonth(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testSHRCardExpirationDateMonth(b)
+	}
+}
+
+// testSHRCardExpirationDateYear validates the year is valid for CardExpirationDate
+func testSHRCardExpirationDateYear(t testing.TB) {
+	mockBatch := mockBatchSHR()
+	mockBatch.GetEntries()[0].SetSHRCardExpirationDate("0612")
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*FieldError); ok {
+			if e.FieldName != "CardExpirationDate" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestSHRCardExpirationDateYear tests validating the year is valid for CardExpirationDate
+func TestSHRSHRCardExpirationDateYear(t *testing.T) {
+	testSHRCardExpirationDateYear(t)
+}
+
+// BenchmarkSHRCardExpirationDateYear test validating the year is valid for CardExpirationDate
+func BenchmarkSHRCardExpirationDateYear(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testSHRCardExpirationDateYear(b)
+	}
+}
diff --git a/converters_test.go b/converters_test.go
index 2627d580..3e3dc5e8 100644
--- a/converters_test.go
+++ b/converters_test.go
@@ -141,7 +141,7 @@ func BenchmarkParseStringField(b *testing.B) {
 // testRTNFieldShort ensures zero padding and right justified
 func testRTNFieldShort(t testing.TB) {
 	c := converters{}
-	result := c.stringRTNField("123456", 8)
+	result := c.stringField("123456", 8)
 	if result != "00123456" {
 		t.Errorf("Zero padding 8 character string : '%v'", result)
 	}
@@ -163,7 +163,7 @@ func BenchmarkRTNFieldShort(b *testing.B) {
 // testRTNFieldLong ensures sliced to max length
 func testRTNFieldLong(t testing.TB) {
 	c := converters{}
-	result := c.stringRTNField("1234567899", 8)
+	result := c.stringField("1234567899", 8)
 	if result != "12345678" {
 		t.Errorf("first 8 character string: '%v'", result)
 	}
@@ -185,7 +185,7 @@ func BenchmarkRTNFieldLong(b *testing.B) {
 // testRTNFieldExact ensures exact match
 func testRTNFieldExact(t testing.TB) {
 	c := converters{}
-	result := c.stringRTNField("123456789", 9)
+	result := c.stringField("123456789", 9)
 	if result != "123456789" {
 		t.Errorf("first 9 character string: '%v'", result)
 	}
diff --git a/writer_test.go b/writer_test.go
index d41beb22..fd08657e 100644
--- a/writer_test.go
+++ b/writer_test.go
@@ -49,10 +49,57 @@ func TestPPDWrite(t *testing.T) {
 	testPPDWrite(t)
 }
 
-// BenchmarkPPDWrite benchmarks writing a PPD ACH file
+// BenchmarkPPDWrite benchmarks validating writing a PPD ACH file
 func BenchmarkPPDWrite(b *testing.B) {
 	b.ReportAllocs()
 	for i := 0; i < b.N; i++ {
 		testPPDWrite(b)
 	}
 }
+
+// testFileWriteErr validates error for file write
+func testFileWriteErr(t testing.TB) {
+	file := NewFile().SetHeader(mockFileHeader())
+	entry := mockEntryDetail()
+	entry.AddAddenda(mockAddenda05())
+	batch := NewBatchPPD(mockBatchPPDHeader())
+	batch.SetHeader(mockBatchHeader())
+	batch.AddEntry(entry)
+	batch.Create()
+	file.AddBatch(batch)
+
+	if err := file.Create(); err != nil {
+		t.Errorf("%T: %s", err, err)
+	}
+	if err := file.Validate(); err != nil {
+		t.Errorf("%T: %s", err, err)
+	}
+
+	file.Batches[0].GetControl().EntryAddendaCount = 10
+
+	b := &bytes.Buffer{}
+	f := NewWriter(b)
+
+	if err := f.WriteAll([]*File{file}); err != nil {
+		if e, ok := err.(*FileError); ok {
+			if e.FieldName != "EntryAddendaCount" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+// TestFileWriteErr tests validating error for file write
+func TestFileWriteErr(t *testing.T) {
+	testFileWriteErr(t)
+}
+
+// BenchmarkFileWriteErr benchmarks error for file write
+func BenchmarkFileWriteErr(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testFileWriteErr(b)
+	}
+}