diff --git a/internal/achx/trace.go b/internal/achx/trace.go index 90d6ebce..a438e278 100644 --- a/internal/achx/trace.go +++ b/internal/achx/trace.go @@ -5,34 +5,43 @@ package achx import ( + "crypto/rand" "fmt" - "math/rand" - "time" + "math/big" + "strconv" "unicode/utf8" -) -var ( - traceNumberSource = rand.NewSource(time.Now().Unix()) + "github.com/moov-io/ach" + "github.com/pkg/errors" ) // TraceNumber returns a trace number from a given routing number // and uses a hidden random generator. These values are not expected // to be cryptographically secure. -func TraceNumber(routingNumber string) string { - v := fmt.Sprintf("%s%d", ABA8(routingNumber), traceNumberSource.Int63()) +// +// We decided not to increment trace numbers from a shared counter because +// it will create a lot of "NNNNN00001" trace numbers that are duplicated +// and hard to uniquely identify a Transfer. +func TraceNumber(routingNumber string) (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(1e15)) + if err != nil { + return "", errors.Wrap(err, "ERROR creating trace number") + } + v := fmt.Sprintf("%s%s", ABA8(routingNumber), n.String()) if utf8.RuneCountInString(v) > 15 { - return v[:15] + return v[:15], nil } - return v + return v, nil } // ABA8 returns the first 8 digits of an ABA routing number. // If the input is invalid then an empty string is returned. func ABA8(rtn string) string { - if n := utf8.RuneCountInString(rtn); n == 10 { + n := utf8.RuneCountInString(rtn) + if n == 10 { return rtn[1:9] // ACH server will prefix with space, 0, or 1 } - if n := utf8.RuneCountInString(rtn); n != 8 && n != 9 { + if n != 8 && n != 9 { return "" } return rtn[:8] @@ -41,10 +50,14 @@ func ABA8(rtn string) string { // ABACheckDigit returns the last digit of an ABA routing number. // If the input is invalid then an empty string is returned. func ABACheckDigit(rtn string) string { - if n := utf8.RuneCountInString(rtn); n == 10 { + n := utf8.RuneCountInString(rtn) + if n == 10 { return rtn[9:] // ACH server will prefix with space, 0, or 1 } - if n := utf8.RuneCountInString(rtn); n != 8 && n != 9 { + if n == 8 { + return strconv.Itoa(ach.CalculateCheckDigit(rtn)) + } + if n != 8 && n != 9 { return "" } return rtn[8:9] diff --git a/internal/achx/trace_test.go b/internal/achx/trace_test.go index 854de4e9..3e31457c 100644 --- a/internal/achx/trace_test.go +++ b/internal/achx/trace_test.go @@ -6,28 +6,34 @@ package achx import ( "testing" + + "github.com/stretchr/testify/require" ) func TestTrace__ABA(t *testing.T) { - routingNumber := "231380104" - if v := ABA8(routingNumber); v != "23138010" { - t.Errorf("got %s", v) - } - if v := ABACheckDigit(routingNumber); v != "4" { - t.Errorf("got %s", v) - } + // 9 digits + require.Equal(t, "23138010", ABA8("231380104")) + require.Equal(t, "4", ABACheckDigit("231380104")) // 10 digit from ACH server - if v := ABA8("0123456789"); v != "12345678" { - t.Errorf("got %s", v) - } - if v := ABACheckDigit("0123456789"); v != "9" { - t.Errorf("got %s", v) - } + require.Equal(t, "12345678", ABA8("0123456789")) + require.Equal(t, "9", ABACheckDigit("0123456789")) + + // 8 digits + require.Equal(t, "12345678", ABA8("12345678")) + require.Equal(t, "0", ABACheckDigit("12345678")) + + // short + require.Equal(t, "", ABA8("1234")) + require.Equal(t, "", ABACheckDigit("1234")) + require.Equal(t, "", ABA8("")) + require.Equal(t, "", ABACheckDigit("")) } func TestTraceNumber(t *testing.T) { - if v := TraceNumber("121042882"); v == "" { - t.Error("empty trace number") + for i := 0; i < 10000; i++ { + trace, err := TraceNumber("121042882") + require.NoError(t, err) + require.NotEmpty(t, trace) } } diff --git a/pkg/response/entry_transformer.go b/pkg/response/entry_transformer.go index 4938abd5..acea84a7 100644 --- a/pkg/response/entry_transformer.go +++ b/pkg/response/entry_transformer.go @@ -56,9 +56,14 @@ func (t *CorrectionTransformer) MorphEntry(fh ach.FileHeader, ed *ach.EntryDetai out.IndividualName = ed.IndividualName out.DiscretionaryData = ed.DiscretionaryData out.AddendaRecordIndicator = 1 - out.TraceNumber = achx.TraceNumber(fh.ImmediateDestination) out.Category = ach.CategoryNOC + if trace, err := achx.TraceNumber(ed.RDFIIdentificationField()); err != nil { + return out, fmt.Errorf("generating trace number: %w", err) + } else { + out.TraceNumber = trace + } + // Create the NOC addenda addenda98 := ach.NewAddenda98() addenda98.ChangeCode = action.Correction.Code @@ -115,9 +120,14 @@ func (t *ReturnTransformer) MorphEntry(fh ach.FileHeader, ed *ach.EntryDetail, a out.IndividualName = ed.IndividualName out.DiscretionaryData = ed.DiscretionaryData out.AddendaRecordIndicator = 1 - out.TraceNumber = achx.TraceNumber(fh.ImmediateDestination) out.Category = ach.CategoryReturn + if trace, err := achx.TraceNumber(ed.RDFIIdentificationField()); err != nil { + return out, fmt.Errorf("generating trace number: %w", err) + } else { + out.TraceNumber = trace + } + // Create the Return addenda addenda99 := ach.NewAddenda99() addenda99.ReturnCode = action.Return.Code