From c93d8ea95f46f26d17f7e90f05ea7fa87510be36 Mon Sep 17 00:00:00 2001 From: Brandon Johnson Date: Wed, 26 Jun 2024 11:59:36 -0600 Subject: [PATCH] feat: batches endpoint for searching files and returning batches (#235) --- cmd/ach-test-harness/main.go | 6 + pkg/batches/api_batch.go | 61 ++++++++++ pkg/batches/api_batch_test.go | 141 ++++++++++++++++++++++++ pkg/batches/repository_batch.go | 106 ++++++++++++++++++ pkg/batches/repository_batch_test.go | 63 +++++++++++ pkg/batches/service_batch.go | 65 +++++++++++ pkg/batches/testdata/.should-be-ingored | 1 + pkg/batches/testdata/outbound/1.ach | 10 ++ pkg/batches/testdata/returned/2.ach | 10 ++ 9 files changed, 463 insertions(+) create mode 100644 pkg/batches/api_batch.go create mode 100644 pkg/batches/api_batch_test.go create mode 100644 pkg/batches/repository_batch.go create mode 100644 pkg/batches/repository_batch_test.go create mode 100644 pkg/batches/service_batch.go create mode 100644 pkg/batches/testdata/.should-be-ingored create mode 100644 pkg/batches/testdata/outbound/1.ach create mode 100644 pkg/batches/testdata/returned/2.ach diff --git a/cmd/ach-test-harness/main.go b/cmd/ach-test-harness/main.go index 0a8b0dbe..4f2e452e 100644 --- a/cmd/ach-test-harness/main.go +++ b/cmd/ach-test-harness/main.go @@ -6,6 +6,7 @@ import ( "os" achtestharness "github.com/moov-io/ach-test-harness" + "github.com/moov-io/ach-test-harness/pkg/batches" "github.com/moov-io/ach-test-harness/pkg/entries" "github.com/moov-io/ach-test-harness/pkg/response" "github.com/moov-io/ach-test-harness/pkg/service" @@ -37,6 +38,11 @@ func main() { entryController := entries.NewEntryController(env.Logger, entryService) entryController.AppendRoutes(env.Router) + batchRepository := batches.NewFTPRepository(env.Logger, env.Config.Servers.FTP) + batchService := batches.NewBatchService(batchRepository) + batchController := batches.NewBatchController(env.Logger, batchService) + batchController.AppendRoutes(env.Router) + fileWriter := response.NewFileWriter(env.Logger, env.Config.Servers, env.FTPServer) fileTransformer := response.NewFileTransformer(env.Logger, env.Config, env.Config.Responses, fileWriter) response.Register(env.Logger, env.Config.ValidateOpts, env.FTPServer, fileTransformer) diff --git a/pkg/batches/api_batch.go b/pkg/batches/api_batch.go new file mode 100644 index 00000000..8f59d855 --- /dev/null +++ b/pkg/batches/api_batch.go @@ -0,0 +1,61 @@ +package batches + +import ( + "encoding/json" + "net/http" + + "github.com/moov-io/base/log" + + "github.com/gorilla/mux" +) + +func NewBatchController(logger log.Logger, service BatchService) *batchController { + return &batchController{ + logger: logger, + service: service, + } +} + +type batchController struct { + logger log.Logger + service BatchService +} + +func (c *batchController) AppendRoutes(router *mux.Router) *mux.Router { + router. + Name("Batch.search"). + Methods("GET"). + Path("/batches"). + HandlerFunc(c.Search()) + + return router +} + +func (c *batchController) Search() func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + batches, err := c.service.Search(readSearchOptions(r)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(batches) + } +} + +func readSearchOptions(r *http.Request) SearchOptions { + query := r.URL.Query() + opts := SearchOptions{ + AccountNumber: query.Get("accountNumber"), + RoutingNumber: query.Get("routingNumber"), + TraceNumber: query.Get("traceNumber"), + CreatedAfter: query.Get("createdAfter"), + Path: query.Get("path"), + } + return opts +} diff --git a/pkg/batches/api_batch_test.go b/pkg/batches/api_batch_test.go new file mode 100644 index 00000000..60be77e3 --- /dev/null +++ b/pkg/batches/api_batch_test.go @@ -0,0 +1,141 @@ +package batches + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/moov-io/ach-test-harness/pkg/service" + "github.com/moov-io/base/log" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" +) + +func TestBatchController(t *testing.T) { + router := mux.NewRouter() + logger := log.NewDefaultLogger() + + t.Run("/batches returns list of batches", func(t *testing.T) { + repo := NewFTPRepository(logger, &service.FTPConfig{ + RootPath: "./testdata", + }) + newBatchService := NewBatchService(repo) + controller := NewBatchController(logger, newBatchService) + controller.AppendRoutes(router) + + req, _ := http.NewRequest("GET", "/batches", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + wantJSON := []byte(` + [ + { + "batchHeader": { + "id": "", + "serviceClassCode": 225, + "companyName": "Name on Account", + "companyIdentification": "231380104", + "standardEntryClassCode": "CCD", + "companyEntryDescription": "Vndr Pay", + "effectiveEntryDate": "190816", + "settlementDate": " ", + "originatorStatusCode": 1, + "ODFIIdentification": "03130001", + "batchNumber": 1 + }, + "entryDetails": [ + { + "id": "", + "transactionCode": 27, + "RDFIIdentification": "23138010", + "checkDigit": "4", + "DFIAccountNumber": "744-5678-99", + "amount": 500000, + "identificationNumber": "location1234567", + "individualName": "Best Co. #123456789012", + "discretionaryData": "S ", + "traceNumber": "031300010000001", + "category": "Forward" + }, + { + "id": "", + "transactionCode": 27, + "RDFIIdentification": "23138010", + "checkDigit": "4", + "DFIAccountNumber": "744-5678-99", + "amount": 125, + "identificationNumber": "Fee123456789012", + "individualName": "Best Co. #123456789012", + "discretionaryData": "S ", + "traceNumber": "031300010000002", + "category": "Forward" + } + ], + "batchControl": { + "id": "", + "serviceClassCode": 225, + "entryAddendaCount": 2, + "entryHash": 46276020, + "totalDebit": 500125, + "totalCredit": 0, + "companyIdentification": "231380104", + "ODFIIdentification": "03130001", + "batchNumber": 1 + }, + "offset": null + }, + { + "batchHeader": { + "id": "", + "serviceClassCode": 220, + "companyName": "Name on Account", + "companyIdentification": "231380104", + "standardEntryClassCode": "PPD", + "companyEntryDescription": "REG.SALARY", + "effectiveEntryDate": "190816", + "settlementDate": " ", + "originatorStatusCode": 1, + "ODFIIdentification": "12104288", + "batchNumber": 1 + }, + "entryDetails": [ + { + "id": "", + "transactionCode": 22, + "RDFIIdentification": "23138010", + "checkDigit": "4", + "DFIAccountNumber": "987654321", + "amount": 100000000, + "identificationNumber": " ", + "individualName": "Credit Account 1 ", + "discretionaryData": " ", + "traceNumber": "121042880000002", + "category": "Forward" + } + ], + "batchControl": { + "id": "", + "serviceClassCode": 220, + "entryAddendaCount": 1, + "entryHash": 23138010, + "totalDebit": 0, + "totalCredit": 100000000, + "companyIdentification": "231380104", + "ODFIIdentification": "12104288", + "batchNumber": 1 + }, + "offset": null + } + ] + `) + + gotJSON := rr.Body.Bytes() + + fmt.Printf("\n\n%s\n\n", string(gotJSON)) + + require.Truef(t, jsonpatch.Equal(wantJSON, gotJSON), "received JSON does not match expected json") + }) +} diff --git a/pkg/batches/repository_batch.go b/pkg/batches/repository_batch.go new file mode 100644 index 00000000..62176ea9 --- /dev/null +++ b/pkg/batches/repository_batch.go @@ -0,0 +1,106 @@ +package batches + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/moov-io/ach" + "github.com/moov-io/ach-test-harness/pkg/response/match" + "github.com/moov-io/ach-test-harness/pkg/service" + "github.com/moov-io/base/log" +) + +type BatchRepository interface { + Search(opts SearchOptions) ([]ach.Batcher, error) +} + +type batchRepository struct { + logger log.Logger + rootPath string +} + +func NewFTPRepository(logger log.Logger, cfg *service.FTPConfig) *batchRepository { + return &batchRepository{ + logger: logger, + rootPath: cfg.RootPath, + } +} + +func (r *batchRepository) Search(opts SearchOptions) ([]ach.Batcher, error) { + out := make([]ach.Batcher, 0) + + //nolint:gosimple + var search fs.WalkDirFunc + search = func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + if err != nil { + r.logger.Logf("error walking dir %s. %v", d.Name(), err) + return nil + } + + r.logger.Logf("reading %s", path) + // read only *.ach files + if strings.ToLower(filepath.Ext(path)) != ".ach" { + return nil + } + + batches, err := filterBatches(path, opts) + if err != nil { + return err + } + out = append(out, batches...) + return nil + } + + var walkingPath = r.rootPath + if opts.Path != "" { + walkingPath = filepath.Join(r.rootPath, opts.Path) + } + + r.logger.Logf("Waling directory %s", walkingPath) + if err := filepath.WalkDir(walkingPath, search); err != nil { + return nil, fmt.Errorf("failed reading directory content %s: %v", walkingPath, err) + } + + return out, nil +} + +func filterBatches(path string, opts SearchOptions) ([]ach.Batcher, error) { + file, _ := ach.ReadFile(path) + if file == nil { + return nil, nil + } + + tooOld, err := opts.fileTooOld(file) + if tooOld || err != nil { + return nil, err + } + + mm := service.Match{ + AccountNumber: opts.AccountNumber, + RoutingNumber: opts.RoutingNumber, + TraceNumber: opts.TraceNumber, + } + + var out []ach.Batcher + for i := range file.Batches { + entries := file.Batches[i].GetEntries() + if mm.Empty() { + out = append(out, file.Batches[i]) + continue + } + for j := range entries { + if match.TraceNumber(mm, entries[j]) || match.AccountNumber(mm, entries[j]) || + match.RoutingNumber(mm, entries[j]) { + // accumulate batch + out = append(out, file.Batches[i]) + continue + } + } + } + return out, nil +} diff --git a/pkg/batches/repository_batch_test.go b/pkg/batches/repository_batch_test.go new file mode 100644 index 00000000..6e054b17 --- /dev/null +++ b/pkg/batches/repository_batch_test.go @@ -0,0 +1,63 @@ +package batches + +import ( + "testing" + + "github.com/moov-io/ach-test-harness/pkg/service" + "github.com/moov-io/base/log" + "github.com/stretchr/testify/require" +) + +func TestRepository(t *testing.T) { + logger := log.NewDefaultLogger() + + repo := NewFTPRepository(logger, &service.FTPConfig{ + RootPath: "./testdata", + Paths: service.Paths{ + Files: "/outbound/", + Return: "/returned/", + }, + }) + + // return all + batches, err := repo.Search(SearchOptions{}) + require.NoError(t, err) + require.Len(t, batches, 2) + + // search by account number + batches, err = repo.Search(SearchOptions{ + AccountNumber: "987654321", + }) + + require.NoError(t, err) + require.Len(t, batches, 1) + + // search by timestamp in our files: + // returned/2.ach was created on 1908161055 and has 1 entry + // outbound/1.ach was created on 1908161059 and has 2 batches + batches, err = repo.Search(SearchOptions{ + CreatedAfter: "2019-08-16T10:56:00+00:00", + }) + + // expect to get batches from outbound/1.ach + require.NoError(t, err) + require.Len(t, batches, 1) + + // search by subdirectory in our files: + // outbound/1.ach was created on 1908161059 and has 2 batches + batches, err = repo.Search(SearchOptions{ + Path: "outbound", + }) + + // expect to get batches from outbound/1.ach + require.NoError(t, err) + require.Len(t, batches, 1) +} + +func TestRepository__filterBatches(t *testing.T) { + var opts SearchOptions + + batches, err := filterBatches("/tmp/noexist/foobar", opts) + require.NoError(t, err) + require.Len(t, batches, 0) +} diff --git a/pkg/batches/service_batch.go b/pkg/batches/service_batch.go new file mode 100644 index 00000000..55c9664d --- /dev/null +++ b/pkg/batches/service_batch.go @@ -0,0 +1,65 @@ +package batches + +import ( + "fmt" + "time" + + "github.com/moov-io/ach" + "github.com/moov-io/base" +) + +type BatchService interface { + Search(ops SearchOptions) ([]ach.Batcher, error) +} + +type batchService struct { + repository BatchRepository +} + +func NewBatchService(repository BatchRepository) *batchService { + return &batchService{ + repository: repository, + } +} + +type SearchOptions struct { + AccountNumber string + Amount int + RoutingNumber string + TraceNumber string + CreatedAfter string + Path string +} + +func (s *batchService) Search(opts SearchOptions) ([]ach.Batcher, error) { + return s.repository.Search(opts) +} + +func (opts SearchOptions) fileTooOld(file *ach.File) (bool, error) { + if opts.CreatedAfter == "" { + return false, nil + } + + tt, err := parseTimestamp(opts.CreatedAfter) + if err != nil { + return false, err + } + + fileCreated, err := time.Parse("0601021504", file.Header.FileCreationDate+file.Header.FileCreationTime) + if err != nil { + return false, err + } + + return fileCreated.Before(tt), nil +} + +func parseTimestamp(when string) (time.Time, error) { + formats := []string{base.ISO8601Format, "2006-01-02", time.RFC3339} + for i := range formats { + tt, err := time.Parse(formats[i], when) + if !tt.IsZero() && err == nil { + return tt, nil + } + } + return time.Time{}, fmt.Errorf("unable to parse '%s'", when) +} diff --git a/pkg/batches/testdata/.should-be-ingored b/pkg/batches/testdata/.should-be-ingored new file mode 100644 index 00000000..6ef9062c --- /dev/null +++ b/pkg/batches/testdata/.should-be-ingored @@ -0,0 +1 @@ +for test diff --git a/pkg/batches/testdata/outbound/1.ach b/pkg/batches/testdata/outbound/1.ach new file mode 100644 index 00000000..8fc74780 --- /dev/null +++ b/pkg/batches/testdata/outbound/1.ach @@ -0,0 +1,10 @@ +101 03130001202313801041908161059A094101Federal Reserve Bank My Bank Name 12345678 +5225Name on Account 231380104 CCDVndr Pay 190816 1031300010000001 +627231380104744-5678-99 0000500000location1234567Best Co. #123456789012S 0031300010000001 +627231380104744-5678-99 0000000125Fee123456789012Best Co. #123456789012S 0031300010000002 +82250000020046276020000000500125000000000000231380104 031300010000001 +9000001000001000000020046276020000000500125000000000000 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 diff --git a/pkg/batches/testdata/returned/2.ach b/pkg/batches/testdata/returned/2.ach new file mode 100644 index 00000000..ccf8637a --- /dev/null +++ b/pkg/batches/testdata/returned/2.ach @@ -0,0 +1,10 @@ +101 03130001202313801041908161055A094101Federal Reserve Bank My Bank Name 12345678 +5220Name on Account 231380104 PPDREG.SALARY 190816 1121042880000001 +622231380104987654321 0100000000 Credit Account 1 0121042880000002 +82200000010023138010000000000000000100000000231380104 121042880000001 +9000001000001000000010023138010000000000000000100000000 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999