Repository: moov-io/ach
Pull request #826 (2021-01-08 19:48:56)

fix: allow POS batches to contain credits
This also loosens the service class code and permits POS batches to be
mixed, credit-only, or debit-only.

While the spec initially defines POS entries as debits, it when
elaborates:

Also an adjusting or other credit Entry related to such debit Entry,
transfer of funds, or obligation.

codecov-io: # [Codecov](https://codecov.io/gh/moov-io/ach/pull/826?src=pr&el=h1) Report
> Merging [#826](https://codecov.io/gh/moov-io/ach/pull/826?src=pr&el=desc) (d09c6ea) into [master](https://codecov.io/gh/moov-io/ach/commit/85a464f43f8b32f8e19cbe8767f764e3ba431959?el=desc) (85a464f) will **increase** coverage by `0.03%`.
> The diff coverage is `n/a`.


```diff
@@            Coverage Diff             @@
##           master     #826      +/-   ##
==========================================
+ Coverage   92.20%   92.24%   +0.03%     
==========================================
  Files          66       66              
  Lines        5109     5104       -5     
==========================================
- Hits         4711     4708       -3     
+ Misses        235      233       -2     
  Partials      163      163              
```


Patch:
diff --git a/batchCIE.go b/batchCIE.go
index a20e5728..273ad465 100644
--- a/batchCIE.go
+++ b/batchCIE.go
@@ -66,9 +66,9 @@ func (batch *BatchCIE) Validate() error {
 		if entry.CreditOrDebit() != "C" {
 			return batch.Error("TransactionCode", ErrBatchDebitOnly, entry.TransactionCode)
 		}
-		// CIE must have one Addenda05 record
-		if len(entry.Addenda05) != 1 {
-			return batch.Error("AddendaCount", NewErrBatchRequiredAddendaCount(len(entry.Addenda05), 1))
+		// CIE must have a maximum of one Addenda05 record
+		if len(entry.Addenda05) > 1 {
+			return batch.Error("AddendaCount", NewErrBatchAddendaCount(len(entry.Addenda05), 1))
 		}
 		// Verify the TransactionCode is valid for a ServiceClassCode
 		if err := batch.ValidTranCodeForServiceClassCode(entry); err != nil {
diff --git a/batchPOS.go b/batchPOS.go
index 15930664..e1279ba8 100644
--- a/batchPOS.go
+++ b/batchPOS.go
@@ -63,17 +63,7 @@ func (batch *BatchPOS) Validate() error {
 		return batch.Error("StandardEntryClassCode", ErrBatchSECType, POS)
 	}
 
-	// POS detail entries can only be a debit, ServiceClassCode must allow debits
-	switch batch.Header.ServiceClassCode {
-	case MixedDebitsAndCredits, CreditsOnly:
-		return batch.Error("ServiceClassCode", ErrBatchServiceClassCode, batch.Header.ServiceClassCode)
-	}
-
 	for _, entry := range batch.Entries {
-		// POS detail entries must be a debit
-		if entry.CreditOrDebit() != "D" {
-			return batch.Error("TransactionCode", ErrBatchDebitOnly, entry.TransactionCode)
-		}
 		if err := entry.isCardTransactionType(entry.DiscretionaryData); err != nil {
 			return batch.Error("CardTransactionType", ErrBatchInvalidCardTransactionType, entry.DiscretionaryData)
 		}
diff --git a/reader.go b/reader.go
index 23f5315d..870e7507 100644
--- a/reader.go
+++ b/reader.go
@@ -23,6 +23,7 @@ import (
 	"io"
 	"os"
 	"strings"
+	"unicode/utf8"
 
 	"github.com/moov-io/base"
 )
@@ -135,7 +136,7 @@ func (r *Reader) Read() (File, error) {
 			return r.File, r.errors
 		}
 
-		lineLength := len(line)
+		lineLength := utf8.RuneCountInString(line)
 
 		switch {
 		case r.lineNum == 1 && lineLength > RecordLength && lineLength%RecordLength == 0:


Test patch:
diff --git a/batchCIE_test.go b/batchCIE_test.go
index e450cd6e..00381f0a 100644
--- a/batchCIE_test.go
+++ b/batchCIE_test.go
@@ -248,7 +248,7 @@ func testBatchCIEAddendaCount(t testing.TB) {
 	mockBatch := mockBatchCIE()
 	mockBatch.GetEntries()[0].AddAddenda05(mockAddenda05())
 	err := mockBatch.Create()
-	if !base.Match(err, NewErrBatchRequiredAddendaCount(2, 1)) {
+	if !base.Match(err, NewErrBatchAddendaCount(2, 1)) {
 		t.Errorf("%T: %s", err, err)
 	}
 }
@@ -271,7 +271,7 @@ func testBatchCIEAddendaCountZero(t testing.TB) {
 	mockBatch := NewBatchCIE(mockBatchCIEHeader())
 	mockBatch.AddEntry(mockCIEEntryDetail())
 	err := mockBatch.Create()
-	if !base.Match(err, NewErrBatchRequiredAddendaCount(0, 1)) {
+	if !base.Match(err, nil) {
 		t.Errorf("%T: %s", err, err)
 	}
 }
@@ -296,7 +296,7 @@ func testBatchCIEInvalidAddendum(t testing.TB) {
 	mockBatch.GetEntries()[0].Addenda02 = mockAddenda02()
 	mockBatch.Entries[0].AddendaRecordIndicator = 1
 	err := mockBatch.Create()
-	if !base.Match(err, NewErrBatchRequiredAddendaCount(0, 1)) {
+	if !base.Match(err, ErrBatchAddendaCategory) {
 		t.Errorf("%T: %s", err, err)
 	}
 }
@@ -422,7 +422,7 @@ func TestBatchCIEAddenda(t *testing.T) {
 	// mock batch already has one addenda. Creating two addenda should error
 	mockBatch.GetEntries()[0].AddAddenda05(mockAddenda05())
 	err := mockBatch.Create()
-	if !base.Match(err, NewErrBatchRequiredAddendaCount(2, 1)) {
+	if !base.Match(err, NewErrBatchAddendaCount(2, 1)) {
 		t.Errorf("%T: %s", err, err)
 	}
 }
diff --git a/batchPOS_test.go b/batchPOS_test.go
index bd5bbdeb..750756a8 100644
--- a/batchPOS_test.go
+++ b/batchPOS_test.go
@@ -50,6 +50,21 @@ func mockPOSEntryDetail() *EntryDetail {
 	return entry
 }
 
+// mockPOSEntryDetailCredit creates a BatchPOS EntryDetail with a credit entry
+func mockPOSEntryDetailCredit() *EntryDetail {
+	entry := NewEntryDetail()
+	entry.TransactionCode = CheckingCredit
+	entry.SetRDFI("231380104")
+	entry.DFIAccountNumber = "744-5678-99"
+	entry.Amount = 25000
+	entry.IdentificationNumber = "45689033"
+	entry.IndividualName = "Wade Arnold"
+	entry.SetTraceNumber(mockBatchPOSHeader().ODFIIdentification, 2)
+	entry.DiscretionaryData = "01"
+	entry.Category = CategoryForward
+	return entry
+}
+
 // mockBatchPOS creates a BatchPOS
 func mockBatchPOS() *BatchPOS {
 	mockBatch := NewBatchPOS(mockBatchPOSHeader())
@@ -62,6 +77,28 @@ func mockBatchPOS() *BatchPOS {
 	return mockBatch
 }
 
+// mockBatchPOSMixedDebitsAndCredits creates a BatchPOS with mixed debits and credits
+func mockBatchPOSMixedDebitsAndCredits() *BatchPOS {
+	bh := mockBatchPOSHeader()
+	bh.ServiceClassCode = MixedDebitsAndCredits
+
+	mockBatch := NewBatchPOS(bh)
+	mockBatch.AddEntry(mockPOSEntryDetail())
+	mockBatch.GetEntries()[0].Addenda02 = mockAddenda02()
+	mockBatch.Entries[0].AddendaRecordIndicator = 1
+
+	mockBatch.AddEntry(mockPOSEntryDetailCredit())
+	mockBatch.GetEntries()[1].Addenda02 = mockAddenda02()
+	mockBatch.Entries[1].AddendaRecordIndicator = 1
+
+	mockBatch.GetControl().ServiceClassCode = MixedDebitsAndCredits
+
+	if err := mockBatch.Create(); err != nil {
+		panic(err)
+	}
+	return mockBatch
+}
+
 // testBatchPOSHeader creates a BatchPOS BatchHeader
 func testBatchPOSHeader(t testing.TB) {
 	batch, _ := NewBatch(mockBatchPOSHeader())
@@ -220,29 +257,6 @@ func BenchmarkBatchPOSAutomatedAccountingAdvices(b *testing.B) {
 	}
 }
 
-// testBatchPOSTransactionCode validates BatchPOS TransactionCode is not a credit
-func testBatchPOSTransactionCode(t testing.TB) {
-	mockBatch := mockBatchPOS()
-	mockBatch.GetEntries()[0].TransactionCode = CheckingCredit
-	err := mockBatch.Create()
-	if !base.Match(err, ErrBatchDebitOnly) {
-		t.Errorf("%T: %s", err, err)
-	}
-}
-
-// TestBatchPOSTransactionCode tests validating BatchPOS TransactionCode is not a credit
-func TestBatchPOSTransactionCode(t *testing.T) {
-	testBatchPOSTransactionCode(t)
-}
-
-// BenchmarkBatchPOSTransactionCode benchmarks validating BatchPOS TransactionCode is not a credit
-func BenchmarkBatchPOSTransactionCode(b *testing.B) {
-	b.ReportAllocs()
-	for i := 0; i < b.N; i++ {
-		testBatchPOSTransactionCode(b)
-	}
-}
-
 // testBatchPOSAddendaCount validates BatchPOS Addendum count of 2
 func testBatchPOSAddendaCount(t testing.TB) {
 	mockBatch := mockBatchPOS()
@@ -474,3 +488,32 @@ func TestBatchPOSTerminalState(t *testing.T) {
 		t.Errorf("%T: %s", err, err)
 	}
 }
+
+// testBatchPOSMixedDebitsAndCredits validates BatchPOS with mixed debits and credits
+func testBatchPOSMixedDebitsAndCredits(t testing.TB) {
+	mockBatch := mockBatchPOSMixedDebitsAndCredits()
+	err := mockBatch.Create()
+
+	if !base.Match(err, nil) {
+		t.Errorf("%T: %s", err, err)
+	}
+
+	err = mockBatch.Validate()
+
+	if !base.Match(err, nil) {
+		t.Errorf("%T: %s", err, err)
+	}
+}
+
+// TestBatchPOSMixedDebitsAndCredits tests validating BatchPOS with mixed debits and credits
+func TestBatchPOSMixedDebitsAndCredits(t *testing.T) {
+	testBatchPOSMixedDebitsAndCredits(t)
+}
+
+// BenchmarkBatchPOSMixedDebitsAndCredits benchmarks validating BatchPOS with mixed debits and credits
+func BenchmarkBatchPOSMixedDebitsAndCredits(b *testing.B) {
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		testBatchPOSMixedDebitsAndCredits(b)
+	}
+}