Repository: moov-io/ach (go)

Issue:
Telephone-Initiated Entries (TEL)
Telephone-Initiated Entries (TEL) are consumer debit transactions. The NACHA Operating Rules permit TEL entries when the Originator obtains the Receiver’s authorization for the debit entry orally via the telephone. An entry based upon a Receiver’s oral authorization must utilize the TEL (Telephone-Initiated Entry) Standard Entry Class (SEC) Code.


Pull request:
Add support for SEC type TEL w/ BatchTEL
Closes #155 for support for batches of SEC type TEL 

Patch:
diff --git a/batch.go b/batch.go
index f486203d..fa59d57b 100644
--- a/batch.go
+++ b/batch.go
@@ -3,6 +3,7 @@ package ach
 import (
 	"fmt"
 	"strconv"
+	"strings"
 )
 
 // Batch holds the Batch Header and Batch Control and all Entry Records for PPD Entries
@@ -28,6 +29,8 @@ func NewBatch(bp BatchParam) (Batcher, error) {
 		return NewBatchCCD(bp), nil
 	case "COR":
 		return NewBatchCOR(bp), nil
+	case "TEL":
+		return NewBatchTEL(bp), nil
 	default:
 	}
 	msg := fmt.Sprintf(msgFileNoneSEC, bp.StandardEntryClass)
@@ -383,3 +386,17 @@ func (batch *batch) isCategory() error {
 	}
 	return nil
 }
+
+// isPaymentTypeCode checks that the Entry detail records have either:
+// "R" For a recurring WEB Entry
+// "S" For a Single-Entry WEB Entry
+func (batch *batch) isPaymentTypeCode() error {
+	for _, entry := range batch.entries {
+		if !strings.Contains(strings.ToUpper(entry.PaymentTypeField()), "S") && !strings.Contains(strings.ToUpper(entry.PaymentTypeField()), "R") {
+			// TODO dead code because PaymentTypeField always returns S regardless of Discretionary Data value
+			msg := fmt.Sprintf(msgBatchWebPaymentType, entry.PaymentTypeField())
+			return &BatchError{BatchNumber: batch.header.BatchNumber, FieldName: "PaymentType", Msg: msg}
+		}
+	}
+	return nil
+}
diff --git a/batchCCD.go b/batchCCD.go
index 26a4f2e9..0e86a755 100644
--- a/batchCCD.go
+++ b/batchCCD.go
@@ -4,7 +4,7 @@ import (
 	"fmt"
 )
 
-// BatchCCD creates a batch file that handles SEC payment type CCD amd CCD+.
+// BatchCCD is a batch file that handles SEC payment type CCD amd CCD+.
 // Corporate credit or debit. Identifies an Entry initiated by an Organization to transfer funds to or from an account of that Organization or another Organization.
 // For commercial accounts only.
 type BatchCCD struct {
diff --git a/batchTEL.go b/batchTEL.go
new file mode 100644
index 00000000..6442f38b
--- /dev/null
+++ b/batchTEL.go
@@ -0,0 +1,72 @@
+package ach
+
+import "fmt"
+
+// BatchTEL is a batch that handles SEC payment type Telephone-Initiated Entries (TEL)
+// Telephone-Initiated Entries (TEL) are consumer debit transactions. The NACHA Operating Rules permit TEL entries when the Originator obtains the Receiver’s authorization for the debit entry orally via the telephone.
+// An entry based upon a Receiver’s oral authorization must utilize the TEL (Telephone-Initiated Entry) Standard Entry Class (SEC) Code.
+type BatchTEL struct {
+	batch
+}
+
+// NewBatchTEL returns a *BatchTEL
+func NewBatchTEL(params ...BatchParam) *BatchTEL {
+	batch := new(BatchTEL)
+	batch.SetControl(NewBatchControl())
+
+	if len(params) > 0 {
+		bh := NewBatchHeader(params[0])
+		bh.StandardEntryClassCode = "TEL"
+		batch.SetHeader(bh)
+		return batch
+	}
+	bh := NewBatchHeader()
+	bh.StandardEntryClassCode = "TEL"
+	batch.SetHeader(bh)
+	return batch
+}
+
+// Validate ensures the batch meets NACHA rules specific to the SEC type TEL
+func (batch *BatchTEL) 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.
+	// TEL can not have an addenda
+	if err := batch.isAddendaCount(0); err != nil {
+		return err
+	}
+
+	// Add type specific validation.
+	if batch.header.StandardEntryClassCode != "TEL" {
+		msg := fmt.Sprintf(msgBatchSECType, batch.header.StandardEntryClassCode, "TEL")
+		return &BatchError{BatchNumber: batch.header.BatchNumber, FieldName: "StandardEntryClassCode", Msg: msg}
+	}
+	// can not have credits in TEL batches
+	for _, entry := range batch.entries {
+		if entry.CreditOrDebit() != "D" {
+			msg := fmt.Sprintf(msgBatchTransactionCodeCredit, entry.IndividualName)
+			return &BatchError{BatchNumber: batch.header.BatchNumber, FieldName: "TransactionCode", Msg: msg}
+		}
+	}
+
+	if err := batch.isPaymentTypeCode(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Create builds the batch sequence numbers and batch control. Additional creation
+func (batch *BatchTEL) Create() error {
+	// generates sequence numbers and batch control
+	if err := batch.build(); err != nil {
+		return err
+	}
+
+	if err := batch.Validate(); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/batchWeb.go b/batchWeb.go
index 89a70aab..571fec95 100644
--- a/batchWeb.go
+++ b/batchWeb.go
@@ -2,7 +2,6 @@ package ach
 
 import (
 	"fmt"
-	"strings"
 )
 
 // BatchWEB creates a batch file that handles SEC payment type WEB.
@@ -73,16 +72,3 @@ func (batch *BatchWEB) Create() error {
 	}
 	return nil
 }
-
-// isPaymentTypeCode checks that the Entry detail records have either:
-// "R" For a recurring WEB Entry
-// "S" For a Single-Entry WEB Entry
-func (batch *BatchWEB) isPaymentTypeCode() error {
-	for _, entry := range batch.entries {
-		if !strings.Contains(strings.ToUpper(entry.PaymentTypeField()), "S") && !strings.Contains(strings.ToUpper(entry.PaymentTypeField()), "R") {
-			msg := fmt.Sprintf(msgBatchWebPaymentType, entry.PaymentTypeField())
-			return &BatchError{BatchNumber: batch.header.BatchNumber, FieldName: "PaymentType", Msg: msg}
-		}
-	}
-	return nil
-}
diff --git a/entryDetail.go b/entryDetail.go
index cd288b91..edff73b4 100644
--- a/entryDetail.go
+++ b/entryDetail.go
@@ -331,3 +331,16 @@ func (ed *EntryDetail) SetPaymentType(t string) {
 func (ed *EntryDetail) TraceNumberField() string {
 	return ed.numericField(ed.TraceNumber, 15)
 }
+
+// CreditOrDebit returns a "C" for credit or "D" for debit based on the entry TransactionCode
+func (ed *EntryDetail) CreditOrDebit() string {
+	tc := strconv.Itoa(ed.TransactionCode)
+	// take the second number in the Transaction code
+	switch tc[1:2] {
+	case "1", "2", "3":
+		return "C"
+	case "6", "7", "8":
+		return "D"
+	}
+	return ""
+}
diff --git a/validators.go b/validators.go
index fd146e21..d7f71287 100644
--- a/validators.go
+++ b/validators.go
@@ -44,7 +44,7 @@ var (
 	msgValidCheckDigit  = "does not match calculated check digit %d"
 )
 
-// iServiceClass returns true if a valid service class code
+// iServiceClass returns true if a valid service class code of a batch is found
 func (v *validator) isServiceClass(code int) error {
 	switch code {
 	case
@@ -61,7 +61,7 @@ func (v *validator) isServiceClass(code int) error {
 	return errors.New(msgServiceClass)
 }
 
-// isSECCode returns true if a SEC Code is found
+// isSECCode returns true if a SEC Code of a Batch is found
 func (v *validator) isSECCode(code string) error {
 	switch code {
 	case
@@ -72,7 +72,9 @@ func (v *validator) isSECCode(code string) error {
 	return errors.New(msgSECCode)
 }
 
-// isTypeCode returns true if a valid type code is found
+// isTypeCode returns true if a valid type code of an Addendum is found
+//
+// The Addenda Type Code defines the specific interpretation and format for the addenda information contained in the Entry.
 func (v *validator) isTypeCode(code string) error {
 	switch code {
 	case
@@ -93,7 +95,24 @@ func (v *validator) isTypeCode(code string) error {
 	return errors.New(msgAddendaTypeCode)
 }
 
-// isTransactionCode ensures TransactionCode code is valid
+// isTransactionCode ensures TransactionCode of an Entry is valid
+//
+// The Tran Code is a two-digit code in positions 2 - 3 of the Entry Detail Record (6 Record) within an ACH File.
+// The first digit of the Tran Code indicates the account type to which the entry will post, where the number:
+//	"2"designates a Checking Account.
+//	"3"designates a Savings Account.
+// 	"4"designates a General Ledger Account.
+// 	"5"designates Loan Account.
+//The second digit of the Tran Code identifies the entry as:
+//	an original forward entry, where the number:
+//		"2"designates a credit. or
+//		"7"designates a debit.
+//	a return or NOC, where the number:
+//		"1"designates the return/NOC of a credit, or
+//		"6"designates a return/NOC of a debit.
+//	a pre-note or non-monetary informational transaction, where the number:
+//		"3"designates a credit, or
+//		"8"designates a debit.
 func (v *validator) isTransactionCode(code int) error {
 	switch code {
 	// TransactionCode if the receivers account is:
@@ -205,7 +224,7 @@ func (v *validator) isTransactionCode(code int) error {
 	return errors.New(msgTransactionCode)
 }
 
-// isOriginatorStatusCode ensures status code is valid
+// isOriginatorStatusCode ensures status code of a batch is valid
 func (v *validator) isOriginatorStatusCode(code int) error {
 	switch code {
 	case
@@ -237,20 +256,7 @@ func (v *validator) isAlphanumeric(s string) error {
 	return nil
 }
 
-// isOriginatorStatusCode ensures status code is valid
-/*
-func (v *validator) isCheckDigit(routingNumber string, checkDigit int) error {
-	calculated := v.CalculateCheckDigit(routingNumber)
-	if calculated != checkDigit {
-		msg := fmt.Sprintf(msgValidCheckDigit, calculated)
-		return &FieldError{FieldName: "RoutingNumber", Value: routingNumber, Msg: msg}
-		//return msgValidCheckDigit
-	}
-	return nil
-}
-*/
-
-// CalculateCheckDigit returns a check digit for a rounting number
+// CalculateCheckDigit returns a check digit for a routing number
 // Multiply each digit in the Routing number by a weighting factor. The weighting factors for each digit are:
 // Position: 1 2 3 4 5 6 7 8
 // Weights : 3 7 1 3 7 1 3 7


Test patch:
diff --git a/batchTEL_test.go b/batchTEL_test.go
new file mode 100644
index 00000000..5f4edc24
--- /dev/null
+++ b/batchTEL_test.go
@@ -0,0 +1,123 @@
+package ach
+
+import (
+	"testing"
+)
+
+func mockBatchTELHeader() *BatchHeader {
+	bh := NewBatchHeader()
+	bh.ServiceClassCode = 225
+	bh.StandardEntryClassCode = "TEL"
+	bh.CompanyName = "Your Company, inc"
+	bh.CompanyIdentification = "123456789"
+	bh.CompanyEntryDescription = "Vndr Pay"
+	bh.ODFIIdentification = 6200001
+	return bh
+}
+
+func mockTELEntryDetail() *EntryDetail {
+	entry := NewEntryDetail()
+	entry.TransactionCode = 27
+	entry.SetRDFI(9101298)
+	entry.DFIAccountNumber = "744-5678-99"
+	entry.Amount = 5000000
+	entry.IdentificationNumber = "Phone 333-2222"
+	entry.IndividualName = "Wade Arnold"
+	entry.setTraceNumber(6200001, 123)
+	entry.SetPaymentType("S")
+	return entry
+}
+
+func mockBatchTEL() *BatchTEL {
+	mockBatch := NewBatchTEL()
+	mockBatch.SetHeader(mockBatchTELHeader())
+	mockBatch.AddEntry(mockTELEntryDetail())
+	if err := mockBatch.Create(); err != nil {
+		panic(err)
+	}
+	return mockBatch
+}
+
+func TestBatchTELParam(t *testing.T) {
+	batch, _ := NewBatch(mockBatchTELHeader().BatchParam())
+	err, ok := batch.(*BatchTEL)
+	if !ok {
+		t.Errorf("Expecting BatchTEL got %T", err)
+	}
+}
+
+func TestBatchTELCreate(t *testing.T) {
+	mockBatch := mockBatchTEL()
+	// Batch Header information is required to Create a batch.
+	mockBatch.GetHeader().ServiceClassCode = 0
+	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)
+		}
+	}
+}
+
+func TestBatchTELAddendaCount(t *testing.T) {
+	mockBatch := mockBatchTEL()
+	// TEL can not have an addendum
+	mockBatch.GetEntries()[0].AddAddenda(mockAddenda())
+	mockBatch.Create()
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			if e.FieldName != "AddendaCount" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
+
+func TestBatchTELSEC(t *testing.T) {
+	mockBatch := mockBatchTEL()
+	mockBatch.header.StandardEntryClassCode = "RCK"
+	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)
+		}
+	}
+}
+
+func TestBatchTELDebit(t *testing.T) {
+	mockBatch := mockBatchTEL()
+	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)
+		}
+	}
+}
+
+// verify that the entry detail payment type / discretionary data is either single or reoccurring for the
+func TestBatchTELPaymentType(t *testing.T) {
+	mockBatch := mockBatchTEL()
+	mockBatch.GetEntries()[0].DiscretionaryData = "AA"
+	if err := mockBatch.Validate(); err != nil {
+		if e, ok := err.(*BatchError); ok {
+			println(e.Error())
+			if e.FieldName != "PaymentType" {
+				t.Errorf("%T: %s", err, err)
+			}
+		} else {
+			t.Errorf("%T: %s", err, err)
+		}
+	}
+}
diff --git a/entryDetail_internal_test.go b/entryDetail_internal_test.go
index e542f6cb..e4afd1c1 100644
--- a/entryDetail_internal_test.go
+++ b/entryDetail_internal_test.go
@@ -313,3 +313,15 @@ func TestEDAddAddendaAddendaReturnTwice(t *testing.T) {
 		t.Error("AddendaReturn added and isReturn is false")
 	}
 }
+
+func TestEDCreditOrDebit(t *testing.T) {
+	// TODO add more credit and debit transaction code's to this test
+	entry := mockEntryDetail()
+	if entry.CreditOrDebit() != "C" {
+		t.Errorf("TransactionCode %v expected a Credit(C) got %v", entry.TransactionCode, entry.CreditOrDebit())
+	}
+	entry.TransactionCode = 27
+	if entry.CreditOrDebit() != "D" {
+		t.Errorf("TransactionCode %v expected a Debit(D) got %v", entry.TransactionCode, entry.CreditOrDebit())
+	}
+}