Repository: moov-io/ach
Pull request #1479 (2024-08-28 14:22:46)

fix: DNE records use prenote TransactionCodes
Also mask customer SSN in DNE Addenda05 records 

Fixes: https://github.com/moov-io/ach/issues/1171


Patch:
diff --git a/batchDNE.go b/batchDNE.go
index a1b18091..e1aee003 100644
--- a/batchDNE.go
+++ b/batchDNE.go
@@ -18,7 +18,9 @@
 package ach
 
 import (
+	"fmt"
 	"strings"
+	"time"
 )
 
 // BatchDNE is a batch file that handles SEC code Death Notification Entry (DNE)
@@ -60,7 +62,7 @@ func (batch *BatchDNE) Validate() error {
 		}
 
 		switch entry.TransactionCode {
-		case CheckingReturnNOCCredit, CheckingPrenoteCredit, SavingsReturnNOCCredit, SavingsPrenoteCredit:
+		case CheckingPrenoteCredit, SavingsPrenoteCredit:
 		default:
 			return batch.Error("TransactionCode", ErrBatchTransactionCode, entry.TransactionCode)
 		}
@@ -69,10 +71,10 @@ func (batch *BatchDNE) Validate() error {
 		if len(entry.Addenda05) != 1 {
 			return batch.Error("AddendaCount", NewErrBatchAddendaCount(len(entry.Addenda05), 1))
 		}
-		// // Verify the Amount is valid for SEC code and TransactionCode
-		// if err := batch.ValidAmountForCodes(entry); err != nil { // TODO(adam): https://github.com/moov-io/ach/issues/1171
-		// 	return err
-		// }
+		// Verify the Amount is valid for SEC code and TransactionCode
+		if err := batch.ValidAmountForCodes(entry); err != nil {
+			return err
+		}
 		// Verify the TransactionCode is valid for a ServiceClassCode
 		if err := batch.ValidTranCodeForServiceClassCode(entry); err != nil {
 			return err
@@ -98,36 +100,42 @@ func (batch *BatchDNE) Create() error {
 	return batch.Validate()
 }
 
-// details returns the Date of Death (YYMMDD), Customer SSN (9 digits), and Amount ($$$$.cc)
-// from the Addenda05 record. This method assumes the addenda05 PaymentRelatedInformation is valid.
-func (batch *BatchDNE) details() (string, string, string) {
-	if batch == nil || len(batch.Entries) == 0 {
-		return "", "", ""
-	}
+type DNEPaymentInformation struct {
+	DateOfDeath time.Time
+	CustomerSSN string
+
+	// Amount is a two-decimal float value formatted as a string
+	// Example: 123.45
+	Amount string
+}
 
-	addendas := batch.Entries[0].Addenda05
-	if len(addendas) != 1 {
-		return "", "", ""
+// ParseDNEPaymentInformation returns an DNEPaymentInformation for a given Addenda05 record. The information is parsed from the addenda's
+// PaymentRelatedInformation field.
+//
+// The returned information is not validated for correctness.
+func ParseDNEPaymentInformation(addenda05 *Addenda05) (*DNEPaymentInformation, error) {
+	if addenda05 == nil {
+		return nil, nil
 	}
 
-	line := addendas[0].PaymentRelatedInformation
-	return line[18:24], line[37:46], strings.TrimSuffix(line[54:], `\`)
-}
+	fields := strings.Split(strings.TrimSuffix(addenda05.PaymentRelatedInformation, `\`), "*")
+	if len(fields) != 6 {
+		return nil, fmt.Errorf("unexpected %d fields", len(fields))
+	}
 
-// DateOfDeath returns the YYMMDD string from Addenda05's PaymentRelatedInformation
-func (batch *BatchDNE) DateOfDeath() string {
-	date, _, _ := batch.details()
-	return date
-}
+	dateOfDeath, err := time.Parse("010206", fields[1])
+	if err != nil {
+		return nil, fmt.Errorf("parsing DateOfDeath: %w", err)
+	}
 
-// CustomerSSN returns the SSN string from Addenda05's PaymentRelatedInformation
-func (batch *BatchDNE) CustomerSSN() string {
-	_, ssn, _ := batch.details()
-	return ssn
+	return &DNEPaymentInformation{
+		DateOfDeath: dateOfDeath,
+		CustomerSSN: fields[3],
+		Amount:      fields[5],
+	}, nil
 }
 
-// Amount returns the amount to be dispursed to the named beneficiary from Addenda05's PaymentRelatedInformation.
-func (batch *BatchDNE) Amount() string {
-	_, _, amount := batch.details()
-	return amount
+func (info DNEPaymentInformation) String() string {
+	return fmt.Sprintf(`DATE OF DEATH*%s*CUSTOMER SSN*%s*AMOUNT*%s\`,
+		info.DateOfDeath.Format("010206"), info.CustomerSSN, info.Amount)
 }
diff --git a/cmd/achcli/describe/file.go b/cmd/achcli/describe/file.go
index fb12a803..f5c233c6 100644
--- a/cmd/achcli/describe/file.go
+++ b/cmd/achcli/describe/file.go
@@ -258,6 +258,16 @@ func dumpAddenda05(w *tabwriter.Writer, batch ach.Batcher, a *ach.Addenda05, opt
 				paymentInfo.DFIAccountNumber = maskNumber(paymentInfo.DFIAccountNumber)
 			}
 
+			paymentRelatedInfo = paymentInfo.String()
+		}
+
+	case *ach.BatchDNE:
+		paymentInfo, _ := ach.ParseDNEPaymentInformation(a)
+		if paymentInfo != nil {
+			if opts.MaskNames || opts.MaskAccountNumbers {
+				paymentInfo.CustomerSSN = maskNumber(paymentInfo.CustomerSSN)
+			}
+
 			paymentRelatedInfo = paymentInfo.String()
 		}
 	}


Test patch:
diff --git a/batchDNE_test.go b/batchDNE_test.go
index ee25c7e2..bab249d7 100644
--- a/batchDNE_test.go
+++ b/batchDNE_test.go
@@ -22,6 +22,8 @@ import (
 	"time"
 
 	"github.com/moov-io/base"
+
+	"github.com/stretchr/testify/require"
 )
 
 // mockBatchDNEHeader creates a DNE batch header
@@ -41,7 +43,7 @@ func mockBatchDNEHeader() *BatchHeader {
 // mockDNEEntryDetail creates a DNE entry detail
 func mockDNEEntryDetail() *EntryDetail {
 	entry := NewEntryDetail()
-	entry.TransactionCode = CheckingReturnNOCCredit
+	entry.TransactionCode = CheckingPrenoteCredit
 	entry.SetRDFI("031300012")
 	entry.DFIAccountNumber = "744-5678-99"
 	entry.Amount = 0
@@ -264,35 +266,38 @@ func TestBatchDNETransactionCode(t *testing.T) {
 	}
 }
 
-func TestBatchDNE__Details(t *testing.T) {
+func TestBatchDNE__ParseDNEPaymentInformation(t *testing.T) {
 	mockBatch := mockBatchDNE(t)
-	date, ssn, amount := mockBatch.details()
-	if date != "010218" {
-		t.Errorf("Got %s", date)
-	}
-	if ssn != "#########" {
-		t.Errorf("Got %s", ssn)
-	}
-	if amount != "$$$$.cc" {
-		t.Errorf("Got %s", amount)
-	}
 
-	// Check the helper methods too
-	if v := mockBatch.DateOfDeath(); v != date {
-		t.Errorf("got %s expected %s", v, date)
-	}
-	if v := mockBatch.CustomerSSN(); v != ssn {
-		t.Errorf("got %s expected %s", v, ssn)
-	}
-	if v := mockBatch.Amount(); v != amount {
-		t.Errorf("got %s expected %s", v, amount)
-	}
+	details, err := ParseDNEPaymentInformation(mockBatch.Entries[0].Addenda05[0])
+	require.NoError(t, err)
+
+	require.Equal(t, "010218", details.DateOfDeath.Format("010206"))
+	require.Equal(t, "#########", details.CustomerSSN)
+	require.Equal(t, "$$$$.cc", details.Amount)
 }
 
 func TestBatchDNE__nil(t *testing.T) {
-	var batch *BatchDNE = nil
-	date, ssn, amount := batch.details()
-	if date != "" || ssn != "" || amount != "" {
-		t.Errorf("got non-empty details from nil BatchDNE: date=%q ssn=%q amount=%q", date, ssn, amount)
+	details, err := ParseDNEPaymentInformation(nil)
+	require.Nil(t, details)
+	require.NoError(t, err)
+
+	t.Run("panic", func(t *testing.T) {
+		batch := mockBatchDNE(t)
+		batch.Entries[0].Addenda05[0].PaymentRelatedInformation = ""
+
+		details, err := ParseDNEPaymentInformation(batch.Entries[0].Addenda05[0])
+		require.ErrorContains(t, err, "unexpected 1 fields")
+		require.Nil(t, details)
+	})
+}
+
+func TestDNRPaymentInformation(t *testing.T) {
+	info := DNEPaymentInformation{
+		DateOfDeath: time.Date(2024, time.August, 28, 10, 30, 0, 0, time.UTC),
+		CustomerSSN: "333224444",
+		Amount:      "123.45",
 	}
+	expected := `DATE OF DEATH*082824*CUSTOMER SSN*333224444*AMOUNT*123.45\`
+	require.Equal(t, expected, info.String())
 }
diff --git a/examples/example_dneWrite_test.go b/examples/example_dneWrite_test.go
index 1b1fd2e2..221a43d0 100644
--- a/examples/example_dneWrite_test.go
+++ b/examples/example_dneWrite_test.go
@@ -39,7 +39,7 @@ func Example_dneWrite() {
 	bh.OriginatorStatusCode = 2
 
 	entry := ach.NewEntryDetail()
-	entry.TransactionCode = ach.CheckingReturnNOCCredit
+	entry.TransactionCode = ach.CheckingPrenoteCredit
 	entry.SetRDFI("031300012")
 	entry.DFIAccountNumber = "744-5678-99"
 	entry.Amount = 0
@@ -77,7 +77,7 @@ func Example_dneWrite() {
 	// Output:
 	// 101 031300012 2313801041908161055A094101Federal Reserve Bank   My Bank Name           12345678
 	// 5220Name on Account                     231380104 DNEDeath           190816   2231380100000001
-	// 621031300012744-5678-99      0000000000031300010000001Best. #1                1231380100000001
+	// 623031300012744-5678-99      0000000000031300010000001Best. #1                1231380100000001
 	// 705    DATE OF DEATH*010218*CUSTOMERSSN*#########*AMOUNT*$$$$.cc\                  00010000001
 	// 82200000020003130001000000000000000000000000231380104                          231380100000001
 	// 9000001000001000000020003130001000000000000000000000000
diff --git a/examples/testdata/dne-read.ach b/examples/testdata/dne-read.ach
index 0d4d4846..c114b098 100644
--- a/examples/testdata/dne-read.ach
+++ b/examples/testdata/dne-read.ach
@@ -1,10 +1,10 @@
 101 03130001202313801041908161055A094101Federal Reserve Bank   My Bank Name           12345678
 5220Name on Account                     231380104 DNEDeath           190816   2231380100000001
-621031300012744-5678-99      0000000000031300010000001Best. #1                1231380100000001
+623031300012744-5678-99      0000000000031300010000001Best. #1                1231380100000001
 705    DATE OF DEATH*010218*CUSTOMERSSN*#########*AMOUNT*$$$$.cc\                  00010000001
 82200000020003130001000000000000000000000000231380104                          231380100000001
 9000001000001000000020003130001000000000000000000000000                                       
 9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
 9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
 9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
-9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
\ No newline at end of file
+9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
diff --git a/test/ach-dne-read/dne-read.ach b/test/ach-dne-read/dne-read.ach
index 10150c53..04724d1c 100644
--- a/test/ach-dne-read/dne-read.ach
+++ b/test/ach-dne-read/dne-read.ach
@@ -1,6 +1,6 @@
 101 031300012 2313801041811020000A094101Federal Reserve Bank   My Bank Name                   
 5220Name on Account                     231380104 DNEDeath           181103   2231380100000001
-621031300012744-5678-99      0000000000031300010000001Best. #1                1231380100000001
+623031300012744-5678-99      0000000000031300010000001Best. #1                1231380100000001
 705    DATE OF DEATH*010218*CUSTOMERSSN*#########*AMOUNT*$$$$.cc\                  00010000001
 82200000020003130001000000000000000000000000231380104                          231380100000001
 9000001000001000000020003130001000000000000000000000000                                       
diff --git a/test/ach-dne-write/main.go b/test/ach-dne-write/main.go
index 92e0f0d8..09a853ab 100644
--- a/test/ach-dne-write/main.go
+++ b/test/ach-dne-write/main.go
@@ -53,7 +53,7 @@ func main() {
 	// can be multiple entries per batch
 	entry := ach.NewEntryDetail()
 	// Identifies the entry as a debit and credit entry AND to what type of account (Savings, DDA, Loan, GL)
-	entry.TransactionCode = ach.CheckingReturnNOCCredit
+	entry.TransactionCode = ach.CheckingPrenoteCredit
 	entry.SetRDFI("031300012")             // Receiver's bank transit routing number
 	entry.DFIAccountNumber = "744-5678-99" // Receiver's bank account number
 	entry.Amount = 0                       // Amount of transaction with no decimal. One dollar and eleven cents = 111