diff --git a/pkg/response/match/matcher.go b/pkg/response/match/matcher.go index fd4161b1..cd3812c5 100644 --- a/pkg/response/match/matcher.go +++ b/pkg/response/match/matcher.go @@ -32,165 +32,157 @@ func (m Matcher) FindAction(bh *ach.BatchHeader, ed *ach.EntryDetail) (copyActio /* * See https://github.com/moov-io/ach-test-harness#config-schema for more details on how to configure. */ - for i := range m.Responses { + for idx, resp := range m.Responses { logger := m.Logger.With(log.Fields{ - "entry_trace_number": log.String(ed.TraceNumber), + "matcher.response_idx": log.Int(idx), + "entry_trace_number": log.String(ed.TraceNumber), }) if m.Debug { logger.Info().Log("starting EntryDetail matching") } - positive, negative := 0, 0 // Matchers are AND'd together - - positiveMatchers := []string{} - negativeMatchers := []string{} - - matcher := m.Responses[i].Match - action := m.Responses[i].Action - - if copyAction != nil && action.Copy != nil { + if copyAction != nil && resp.Action.Copy != nil { continue // skip, we already have a copy action } - if processAction != nil && action.Return != nil { + if processAction != nil && resp.Action.Return != nil { continue // skip, we already have a process action } - logger = logger.With(action) - logger = logger.With(matcher) + // Run .Match and .Not matchers + positiveMatches, negativeMatches := m.runMatchers(logger, resp.Match, bh, ed) - if m.Debug { - logger = logger.With(log.Fields{ - "matcher.response_idx": log.Int(i), - "matcher.account_number": log.String(matcher.AccountNumber), - "matcher.entry_type": log.String(string(matcher.EntryType)), - "matcher.individual_name": log.String(matcher.IndividualName), - "matcher.routing_number": log.String(matcher.RoutingNumber), - "matcher.trace_number": log.String(matcher.TraceNumber), - "ed.account_number": log.String(ed.DFIAccountNumber), - "ed.entry_type": log.String(fmt.Sprintf("%d", ed.TransactionCode)), - "ed.individual_name": log.String(ed.IndividualName), - "ed.routing_number": log.String(ed.RDFIIdentification + ed.CheckDigit), - "ed.trace_number": log.String(ed.TraceNumber), - "ed.amount": log.String(fmt.Sprintf("%d", ed.Amount)), - }) - } + // The Not matchers need to be inverted from the affirmative + notPositiveMatches, notNegativeMatches := m.runMatchers(logger, resp.Not, bh, ed) - // Trace Number - if matcher.TraceNumber != "" { - if TraceNumber(matcher, ed) { - positiveMatchers = append(positiveMatchers, "TraceNumber") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "TraceNumber") - negative++ - } - } + // Add the affirmative positive matches with the populated Not matches + positive := len(positiveMatches) + len(notNegativeMatches) - // Account Number - if matcher.AccountNumber != "" { - if AccountNumber(matcher, ed) { - positiveMatchers = append(positiveMatchers, "DFIAccountNumber") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "DFIAccountNumber") - negative++ - } - } + // Add the affirmative negative matches with the populated Not negative matches + negative := len(negativeMatches) + len(notPositiveMatches) - // Routing Number - if matcher.RoutingNumber != "" { - if RoutingNumber(matcher, ed) { - positiveMatchers = append(positiveMatchers, "RDFIIdentification") - positive++ + // Return the Action if we've still matched + if negative == 0 && positive > 0 { + // Action is valid, figure out where it belongs + if resp.Action.Copy != nil { + copyAction = &resp.Action } else { - negativeMatchers = append(negativeMatchers, "RDFIIdentification") - negative++ + processAction = &resp.Action + // A non-Copy (process) Action with no Delay supersedes everything else + if processAction.Delay == nil { + return nil, processAction + } } } + } - // Check if the Amount matches - if matcher.Amount != nil { - if Amount(matcher, ed) { - positiveMatchers = append(positiveMatchers, "Amount") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "Amount") - negative++ - } - } + return +} - // Check if this Entry is a debit - if matcher.EntryType != service.EntryTypeEmpty { - if matchedEntryType(matcher, ed) { - positiveMatchers = append(positiveMatchers, "TransactionCode") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "TransactionCode") - negative++ - } - } +func (m Matcher) runMatchers(logger log.Logger, matcher service.Match, bh *ach.BatchHeader, ed *ach.EntryDetail) (positiveMatchers, negativeMatchers []string) { + logger = logger.With(matcher) + + if m.Debug { + logger = logger.With(log.Fields{ + "matcher.account_number": log.String(matcher.AccountNumber), + "matcher.entry_type": log.String(string(matcher.EntryType)), + "matcher.individual_name": log.String(matcher.IndividualName), + "matcher.routing_number": log.String(matcher.RoutingNumber), + "matcher.trace_number": log.String(matcher.TraceNumber), + "ed.account_number": log.String(ed.DFIAccountNumber), + "ed.entry_type": log.String(fmt.Sprintf("%d", ed.TransactionCode)), + "ed.individual_name": log.String(ed.IndividualName), + "ed.routing_number": log.String(ed.RDFIIdentification + ed.CheckDigit), + "ed.trace_number": log.String(ed.TraceNumber), + "ed.amount": log.String(fmt.Sprintf("%d", ed.Amount)), + }) + } - if matcher.IndividualName != "" { - if matchedIndividualName(matcher, ed) { - positiveMatchers = append(positiveMatchers, "IndividualName") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "IndividualName") - negative++ - } + // Trace Number + if matcher.TraceNumber != "" { + if TraceNumber(matcher, ed) { + positiveMatchers = append(positiveMatchers, "TraceNumber") + } else { + negativeMatchers = append(negativeMatchers, "TraceNumber") } + } - // BatchHeader fields - if matcher.CompanyIdentification != "" { - if matchedCompanyIdentification(matcher, bh) { - positiveMatchers = append(positiveMatchers, "CompanyIdentification") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "CompanyIdentification") - negative++ - } - } - if matcher.CompanyEntryDescription != "" { - if matchedCompanyEntryDescription(matcher, bh) { - positiveMatchers = append(positiveMatchers, "CompanyEntryDescription") - positive++ - } else { - negativeMatchers = append(negativeMatchers, "CompanyEntryDescription") - negative++ - } + // Account Number + if matcher.AccountNumber != "" { + if AccountNumber(matcher, ed) { + positiveMatchers = append(positiveMatchers, "DFIAccountNumber") + } else { + negativeMatchers = append(negativeMatchers, "DFIAccountNumber") } + } - // format the list of negative and positive matchers for logging - var b strings.Builder + // Routing Number + if matcher.RoutingNumber != "" { + if RoutingNumber(matcher, ed) { + positiveMatchers = append(positiveMatchers, "RDFIIdentification") + } else { + negativeMatchers = append(negativeMatchers, "RDFIIdentification") + } + } - b.WriteString(fmt.Sprintf("FINAL matching score negative=%d", negative)) - if len(negativeMatchers) > 0 { - b.WriteString(fmt.Sprintf(" (%s)", strings.Join(negativeMatchers, ", "))) + // Check if the Amount matches + if matcher.Amount != nil { + if Amount(matcher, ed) { + positiveMatchers = append(positiveMatchers, "Amount") + } else { + negativeMatchers = append(negativeMatchers, "Amount") } + } - b.WriteString(fmt.Sprintf(" positive=%d", positive)) - if len(positiveMatchers) > 0 { - b.WriteString(fmt.Sprintf(" (%s)", strings.Join(positiveMatchers, ", "))) + // Check if this Entry is a debit + if matcher.EntryType != service.EntryTypeEmpty { + if matchedEntryType(matcher, ed) { + positiveMatchers = append(positiveMatchers, "TransactionCode") + } else { + negativeMatchers = append(negativeMatchers, "TransactionCode") } + } - if m.Debug { - logger.Log(b.String()) + if matcher.IndividualName != "" { + if matchedIndividualName(matcher, ed) { + positiveMatchers = append(positiveMatchers, "IndividualName") + } else { + negativeMatchers = append(negativeMatchers, "IndividualName") } + } - // Return the Action if we've still matched - if negative == 0 && positive > 0 { - // Action is valid, figure out where it belongs - if m.Responses[i].Action.Copy != nil { - copyAction = &m.Responses[i].Action - } else { - processAction = &m.Responses[i].Action - // A non-Copy (process) Action with no Delay supersedes everything else - if processAction.Delay == nil { - return nil, processAction - } - } + // BatchHeader fields + if matcher.CompanyIdentification != "" { + if matchedCompanyIdentification(matcher, bh) { + positiveMatchers = append(positiveMatchers, "CompanyIdentification") + } else { + negativeMatchers = append(negativeMatchers, "CompanyIdentification") + } + } + if matcher.CompanyEntryDescription != "" { + if matchedCompanyEntryDescription(matcher, bh) { + positiveMatchers = append(positiveMatchers, "CompanyEntryDescription") + } else { + negativeMatchers = append(negativeMatchers, "CompanyEntryDescription") } } + + // format the list of negative and positive matchers for logging + var b strings.Builder + + b.WriteString(fmt.Sprintf("FINAL matching score negative=%d", len(negativeMatchers))) + if len(negativeMatchers) > 0 { + b.WriteString(fmt.Sprintf(" (%s)", strings.Join(negativeMatchers, ", "))) + } + + b.WriteString(fmt.Sprintf(" positive=%d", len(positiveMatchers))) + if len(positiveMatchers) > 0 { + b.WriteString(fmt.Sprintf(" (%s)", strings.Join(positiveMatchers, ", "))) + } + + if m.Debug { + logger.Log(b.String()) + } + return } diff --git a/pkg/response/match/matcher_test.go b/pkg/response/match/matcher_test.go index ca9b9b8b..53cc4c60 100644 --- a/pkg/response/match/matcher_test.go +++ b/pkg/response/match/matcher_test.go @@ -392,4 +392,45 @@ func TestMultiMatch(t *testing.T) { require.NotNil(t, processAction) require.Equal(t, actionReturn, *processAction) }) + + t.Run("Match with NOT", func(t *testing.T) { + var matcher Matcher + matcher.Logger = log.NewTestLogger() + matcher.Responses = []service.Response{} + + // Read our test file + file, err := ach.ReadFile(filepath.Join("..", "..", "..", "testdata", "20230809-144155-102000021.ach")) + require.NoError(t, err) + require.NotNil(t, file) + require.True(t, len(file.Batches) > 0) + + bh := file.Batches[0].GetHeader() + entries := file.Batches[0].GetEntries() + + // Match no entries + copyAction, processAction := matcher.FindAction(bh, entries[0]) + require.Nil(t, copyAction) + require.Nil(t, processAction) + + // Match based on CompanyID but NOT when CompanyEntryDescription == "Payment" + matcher.Responses = append(matcher.Responses, service.Response{ + Match: service.Match{ + CompanyIdentification: "Classbook", + }, + Not: service.Match{ + CompanyEntryDescription: "Payment", + }, + Action: actionReturn, + }) + copyAction, processAction = matcher.FindAction(bh, entries[0]) + require.Nil(t, copyAction) + require.Nil(t, processAction) + + // Change the Not matcher so we return the entry + matcher.Responses[0].Not.CompanyEntryDescription = "Other" + copyAction, processAction = matcher.FindAction(bh, entries[0]) + require.Nil(t, copyAction) + require.NotNil(t, processAction) + require.Equal(t, actionReturn, *processAction) + }) } diff --git a/pkg/service/model_config.go b/pkg/service/model_config.go index 5a372e17..1b4a6bff 100644 --- a/pkg/service/model_config.go +++ b/pkg/service/model_config.go @@ -95,6 +95,7 @@ type Matching struct { type Response struct { Match Match + Not Match Action Action }