From e89f3e9962f102a8a428fa38c772675648e7fd9b Mon Sep 17 00:00:00 2001 From: Jason Bornstein <131717043+jasonbornsteinMOOV@users.noreply.github.com> Date: Mon, 31 Jul 2023 15:10:14 -0400 Subject: [PATCH] add support for delayed responses --- README.md | 24 ++--------- pkg/response/batch_mirror.go | 2 +- pkg/response/file_transformer.go | 9 +++-- pkg/response/file_transformer_test.go | 19 ++++++--- pkg/response/file_writer.go | 35 ++++++++++------ pkg/response/match/matcher.go | 45 ++++----------------- pkg/response/match/matcher_test.go | 57 +++++++-------------------- pkg/service/model_config.go | 11 ++---- 8 files changed, 71 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index fe67be19..039b9938 100644 --- a/README.md +++ b/README.md @@ -76,16 +76,9 @@ ACHTestHarness: accountNumber: "12345678" traceNumber: "121042880000001" action: + delay: "12h" return: code: "R03" - - - match: - amount: - value: 12357 # $123.57 - future: - delay: "12h" - return: - code: "R10" ``` The full config for Responses is below: @@ -104,22 +97,13 @@ match: traceNumber: # Exact match of TraceNumber entryType: # Checks TransactionCode. Accepted values: credit, debit or prenote. action: + # How long into the future should we wait before making the correction/return available? + delay: + # Copy the EntryDetail to another directory copy: path: # Filepath on the FTP server - # Send the EntryDetail back with the following ACH change code - correction: - code: - data: - - # Send the EntryDetail back with the following ACH return code - return: - code: -future: - # How long into the future should we wait before making the correction/return available? - delay: - # Send the EntryDetail back with the following ACH change code correction: code: diff --git a/pkg/response/batch_mirror.go b/pkg/response/batch_mirror.go index ad897ee3..a91e25eb 100644 --- a/pkg/response/batch_mirror.go +++ b/pkg/response/batch_mirror.go @@ -57,7 +57,7 @@ func (bm *batchMirror) saveFiles() error { if filename, err := bm.filename(); err != nil { return fmt.Errorf("unable to get filename: %v", err) } else { - bm.writer.Write(filepath.Join(path, filename), &buf) + bm.writer.Write(filepath.Join(path, filename), &buf, nil) } } return nil diff --git a/pkg/response/file_transformer.go b/pkg/response/file_transformer.go index a8e6275a..64933503 100644 --- a/pkg/response/file_transformer.go +++ b/pkg/response/file_transformer.go @@ -56,6 +56,7 @@ func (ft *FileTransfomer) Transform(file *ach.File) error { return fmt.Errorf("file transform: header validate: %v", err) } + var delay *time.Duration // TODO JB: delay should be at the entry-level instead of the file-level for i := range file.Batches { mirror := newBatchMirror(ft.Writer, file.Batches[i]) batch, err := ach.NewBatch(file.Batches[i].GetHeader()) @@ -65,7 +66,7 @@ func (ft *FileTransfomer) Transform(file *ach.File) error { entries := file.Batches[i].GetEntries() for j := range entries { // Check if there's a matching Action and perform it - this might be a future-dated action - action, _ := ft.Matcher.FindAction(entries[j]) + action := ft.Matcher.FindAction(entries[j]) if action != nil { entry, err := ft.Entry.MorphEntry(file.Header, entries[j], *action) if err != nil { @@ -93,7 +94,9 @@ func (ft *FileTransfomer) Transform(file *ach.File) error { } } - // TODO JB: do something with the `future` object + if action.Delay != nil { + delay = action.Delay + } } } // Save off the entries as requested @@ -115,7 +118,7 @@ func (ft *FileTransfomer) Transform(file *ach.File) error { } if err := out.Validate(); err == nil { filepath := filepath.Join(ft.returnPath, generateFilename(out)) // TODO(adam): need to determine return path - if err := ft.Writer.WriteFile(filepath, out); err != nil { + if err := ft.Writer.WriteFile(filepath, out, delay); err != nil { return fmt.Errorf("transform write %s: %v", filepath, err) } } else { diff --git a/pkg/response/file_transformer_test.go b/pkg/response/file_transformer_test.go index c9156b74..6c66e846 100644 --- a/pkg/response/file_transformer_test.go +++ b/pkg/response/file_transformer_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/moov-io/ach" "github.com/moov-io/ach-test-harness/pkg/service" @@ -20,7 +21,7 @@ func TestFileTransformer__CorrectedPrenote(t *testing.T) { EntryType: service.EntryTypePrenote, AccountNumber: "810044964044", }, - Action: &service.Action{ + Action: service.Action{ Correction: &service.Correction{ Code: "C01", Data: "445566778", @@ -57,7 +58,7 @@ func TestFileTransformer__ReturnedPrenote(t *testing.T) { EntryType: service.EntryTypePrenote, AccountNumber: "810044964044", }, - Action: &service.Action{ + Action: service.Action{ Return: &service.Return{ Code: "R03", }, @@ -88,13 +89,16 @@ func TestFileTransformer__ReturnedPrenote(t *testing.T) { } func TestFileTransformer__FutureCorrectedPrenote(t *testing.T) { + var delay, err = time.ParseDuration("12h") + require.NoError(t, err) + resp := service.Response{ Match: service.Match{ EntryType: service.EntryTypePrenote, AccountNumber: "810044964044", }, - Future: &service.Future{ - Delay: "12h", + Action: service.Action{ + Delay: &delay, Correction: &service.Correction{ Code: "C01", Data: "445566778", @@ -126,13 +130,16 @@ func TestFileTransformer__FutureCorrectedPrenote(t *testing.T) { } func TestFileTransformer__FutureReturnedPrenote(t *testing.T) { + var delay, err = time.ParseDuration("12h") + require.NoError(t, err) + resp := service.Response{ Match: service.Match{ EntryType: service.EntryTypePrenote, AccountNumber: "810044964044", }, - Future: &service.Future{ - Delay: "12h", + Action: service.Action{ + Delay: &delay, Return: &service.Return{ Code: "R03", }, diff --git a/pkg/response/file_writer.go b/pkg/response/file_writer.go index 0e3a73d9..1920ea3d 100644 --- a/pkg/response/file_writer.go +++ b/pkg/response/file_writer.go @@ -4,7 +4,9 @@ import ( "bytes" "fmt" "io" + "os" "path/filepath" + "time" "github.com/moov-io/ach" "github.com/moov-io/ach-test-harness/pkg/service" @@ -14,37 +16,39 @@ import ( ) type FileWriter interface { - Write(filepath string, r io.Reader) error - WriteFile(filename string, file *ach.File) error + Write(filepath string, r io.Reader, delay *time.Duration) error + WriteFile(filename string, file *ach.File, delay *time.Duration) error } func NewFileWriter(logger log.Logger, cfg service.ServerConfig, ftpServer *ftp.Server) FileWriter { if cfg.FTP != nil { return &FTPFileWriter{ - cfg: cfg.FTP.Paths, - logger: logger, - server: ftpServer, + cfg: cfg.FTP.Paths, + rootPath: cfg.FTP.RootPath, + logger: logger, + server: ftpServer, } } return nil } type FTPFileWriter struct { - cfg service.Paths - logger log.Logger - server *ftp.Server + cfg service.Paths + rootPath string + logger log.Logger + server *ftp.Server } -func (w *FTPFileWriter) WriteFile(filepath string, file *ach.File) error { +func (w *FTPFileWriter) WriteFile(filepath string, file *ach.File, delay *time.Duration) error { var buf bytes.Buffer if err := ach.NewWriter(&buf).Write(file); err != nil { return fmt.Errorf("write %s: %v", filepath, err) } w.logger.Info().Log(fmt.Sprintf("writing %s (%d bytes)", filepath, buf.Len())) - return w.Write(filepath, &buf) + return w.Write(filepath, &buf, delay) } -func (w *FTPFileWriter) Write(path string, r io.Reader) error { +func (w *FTPFileWriter) Write(path string, r io.Reader, delay *time.Duration) error { driver, err := w.server.Factory.NewDriver() if err != nil { return fmt.Errorf("get driver to write %s: %v", path, err) @@ -54,11 +58,16 @@ func (w *FTPFileWriter) Write(path string, r io.Reader) error { return fmt.Errorf("mkdir: %s: %v", path, err) } - // TODO JB: see if there's a way to future-date the file's timestamp - if _, err := driver.PutFile(path, r, false); err != nil { return fmt.Errorf("STOR %s: %v", path, err) } + + if delay != nil { + if err := os.Chtimes(filepath.Join(w.rootPath, path), time.Now(), time.Now().Add(*delay)); err != nil { + return fmt.Errorf("chtimes: %s: %v", path, err) + } + } + return nil } diff --git a/pkg/response/match/matcher.go b/pkg/response/match/matcher.go index 50d34ec2..7643a548 100644 --- a/pkg/response/match/matcher.go +++ b/pkg/response/match/matcher.go @@ -27,7 +27,7 @@ func New(logger log.Logger, cfg service.Matching, responses []service.Response) } } -func (m Matcher) FindAction(ed *ach.EntryDetail) (*service.Action, *service.Future) { +func (m Matcher) FindAction(ed *ach.EntryDetail) *service.Action { logger := m.Logger.With(log.Fields{ "entry_trace_number": log.String(ed.TraceNumber), }) @@ -37,12 +37,6 @@ func (m Matcher) FindAction(ed *ach.EntryDetail) (*service.Action, *service.Futu positive, negative := 0, 0 // Matchers are AND'd together matcher := m.Responses[i].Match action := m.Responses[i].Action - future := m.Responses[i].Future - - if future == nil && action == nil { - logger.Log("future and action are both nil") - continue - } var copyPath string var delayTime string @@ -52,21 +46,21 @@ func (m Matcher) FindAction(ed *ach.EntryDetail) (*service.Action, *service.Futu var amount int // Safely retrieve several values that are needed for the debug log below - if action != nil && action.Copy != nil { + if action.Copy != nil { copyPath = action.Copy.Path logger = logger.With(log.Fields{ "copy_path": log.String(copyPath), }) } - if future != nil { - delayTime = future.Delay + if action.Delay != nil { + delayTime = action.Delay.String() logger = logger.With(log.Fields{ "delay": log.String(delayTime), }) } - if action != nil && action.Correction != nil { + if action.Correction != nil { correctionCode = action.Correction.Code correctionData = action.Correction.Data logger = logger.With(log.Fields{ @@ -75,29 +69,13 @@ func (m Matcher) FindAction(ed *ach.EntryDetail) (*service.Action, *service.Futu }) } - if future != nil && future.Correction != nil { - correctionCode = future.Correction.Code - correctionData = future.Correction.Data - logger = logger.With(log.Fields{ - "correction_code": log.String(correctionCode), - "correction_data": log.String(correctionData), - }) - } - - if action != nil && action.Return != nil { + if action.Return != nil { returnCode = action.Return.Code logger = logger.With(log.Fields{ "return_code": log.String(returnCode), }) } - if future != nil && future.Return != nil { - returnCode = future.Return.Code - logger = logger.With(log.Fields{ - "return_code": log.String(returnCode), - }) - } - if matcher.Amount != nil { amount = matcher.Amount.Value logger = logger.With(log.Fields{ @@ -209,17 +187,10 @@ func (m Matcher) FindAction(ed *ach.EntryDetail) (*service.Action, *service.Futu logger.Logf("FINAL matching score negative=%d positive=%d", negative, positive) if negative == 0 && positive > 0 { - if m.Responses[i].Future != nil { - // do this so that the entries can be morphed without duplicating code - action = &service.Action{ - Correction: future.Correction, - Return: future.Return, - } - } - return action, future + return &m.Responses[i].Action } } - return nil, nil + return nil } func TraceNumber(m service.Match, ed *ach.EntryDetail) bool { diff --git a/pkg/response/match/matcher_test.go b/pkg/response/match/matcher_test.go index 30ef92ce..17783c45 100644 --- a/pkg/response/match/matcher_test.go +++ b/pkg/response/match/matcher_test.go @@ -3,6 +3,7 @@ package match import ( "path/filepath" "testing" + "time" "github.com/moov-io/ach" "github.com/moov-io/ach-test-harness/pkg/service" @@ -187,7 +188,7 @@ func TestMultiMatch(t *testing.T) { }, EntryType: service.EntryTypeDebit, }, - Action: &service.Action{ + Action: service.Action{ Return: &service.Return{ Code: "R01", }, @@ -197,7 +198,7 @@ func TestMultiMatch(t *testing.T) { Match: service.Match{ IndividualName: "Incorrect Name", }, - Action: &service.Action{ + Action: service.Action{ Correction: &service.Correction{ Code: "C04", Data: "Correct Name", @@ -214,19 +215,21 @@ func TestMultiMatch(t *testing.T) { require.True(t, len(file.Batches) > 0) entries := file.Batches[0].GetEntries() - action, future := matcher.FindAction(entries[0]) + action := matcher.FindAction(entries[0]) require.Nil(t, action) - require.Nil(t, future) // Find our Action - action, future = matcher.FindAction(entries[1]) + action = matcher.FindAction(entries[1]) require.NotNil(t, action) require.NotNil(t, action.Correction) require.Equal(t, action.Correction.Code, "C04") - require.Nil(t, future) + require.Nil(t, action.Delay) } func TestMatchFuture(t *testing.T) { + var delay, err = time.ParseDuration("12h") + require.NoError(t, err) + matcher := Matcher{ Logger: log.NewTestLogger(), Responses: []service.Response{ @@ -234,8 +237,8 @@ func TestMatchFuture(t *testing.T) { Match: service.Match{ IndividualName: "Incorrect Name", }, - Future: &service.Future{ - Delay: "12h", + Action: service.Action{ + Delay: &delay, Correction: &service.Correction{ Code: "C04", Data: "Correct Name", @@ -252,45 +255,13 @@ func TestMatchFuture(t *testing.T) { require.True(t, len(file.Batches) > 0) entries := file.Batches[0].GetEntries() - action, future := matcher.FindAction(entries[0]) + action := matcher.FindAction(entries[0]) require.Nil(t, action) - require.Nil(t, future) // Find our Action - action, future = matcher.FindAction(entries[1]) + action = matcher.FindAction(entries[1]) require.NotNil(t, action) require.NotNil(t, action.Correction) require.Equal(t, action.Correction.Code, "C04") - require.NotNil(t, future) - require.Equal(t, future.Delay, "12h") -} - -func TestMatchNilActionNilFuture(t *testing.T) { - matcher := Matcher{ - Logger: log.NewTestLogger(), - Responses: []service.Response{ - { - Match: service.Match{ - Amount: &service.Amount{ - Min: 500000, // $5,000.00 - Max: 1000000, // $10,000.00 - }, - EntryType: service.EntryTypeDebit, - }, - Action: nil, - Future: nil, - }, - }, - } - - // Read our test file - file, err := ach.ReadFile(filepath.Join("..", "..", "..", "testdata", "20210308-1806-071000301.ach")) - require.NoError(t, err) - require.NotNil(t, file) - require.True(t, len(file.Batches) > 0) - entries := file.Batches[0].GetEntries() - - action, future := matcher.FindAction(entries[0]) - require.Nil(t, action) - require.Nil(t, future) + require.Equal(t, action.Delay.String(), "12h0m0s") } diff --git a/pkg/service/model_config.go b/pkg/service/model_config.go index e731ce06..10f864b1 100644 --- a/pkg/service/model_config.go +++ b/pkg/service/model_config.go @@ -4,6 +4,7 @@ package service import ( "fmt" + "time" "github.com/moov-io/ach" ) @@ -75,8 +76,7 @@ type Matching struct { type Response struct { Match Match - Action *Action - Future *Future + Action Action } type Match struct { @@ -117,6 +117,7 @@ const ( ) type Action struct { + Delay *time.Duration // e.g. "12h" or "10s" Copy *Copy Correction *Correction Return *Return @@ -144,9 +145,3 @@ func (r Return) Validate() error { } return fmt.Errorf("unexpected return code %s", r.Code) } - -type Future struct { - Delay string // ex: "12h" - Correction *Correction - Return *Return -}