diff --git a/batch.go b/batch.go index 5f0b39f16..39ce1a162 100644 --- a/batch.go +++ b/batch.go @@ -5,6 +5,7 @@ package ach import ( + "encoding/json" "errors" "fmt" "strconv" @@ -25,6 +26,23 @@ type Batch struct { converters } +func (b *Batch) UnmarshalJSON(p []byte) error { + b.Header = NewBatchHeader() + b.Control = NewBatchControl() + b.ADVControl = NewADVBatchControl() + + type Alias Batch + aux := struct { + *Alias + }{ + (*Alias)(b), + } + if err := json.Unmarshal(p, &aux); err != nil { + return err + } + return nil +} + // NewBatch takes a BatchHeader and returns a matching SEC code batch type that is a batcher. Returns an error if the SEC code is not supported. func NewBatch(bh *BatchHeader) (Batcher, error) { switch bh.StandardEntryClassCode { @@ -190,20 +208,7 @@ func (batch *Batch) build() error { if !batch.IsADV() { for i, entry := range batch.Entries { - entryCount++ - - // Add in Addenda Count - if entry.Addenda02 != nil { - entryCount++ - } - entryCount = entryCount + len(entry.Addenda05) - if entry.Addenda98 != nil { - entryCount++ - } - - if entry.Addenda99 != nil { - entryCount++ - } + entryCount += 1 + entry.addendaCount() currentTraceNumberODFI, err := strconv.Atoi(entry.TraceNumberField()[:8]) if err != nil { @@ -217,7 +222,7 @@ func (batch *Batch) build() error { // Add a sequenced TraceNumber if one is not already set. Have to keep original trance number Return and NOC entries if currentTraceNumberODFI != batchHeaderODFI { - batch.Entries[i].SetTraceNumber(batch.Header.ODFIIdentification, seq) + entry.SetTraceNumber(batch.Header.ODFIIdentification, seq) } seq++ addendaSeq := 1 @@ -307,6 +312,10 @@ func (batch *Batch) GetEntries() []*EntryDetail { // AddEntry appends an EntryDetail to the Batch func (batch *Batch) AddEntry(entry *EntryDetail) { + if entry == nil { + return + } + batch.category = entry.Category batch.Entries = append(batch.Entries, entry) } @@ -395,19 +404,7 @@ func (batch *Batch) isBatchEntryCount() error { if !batch.IsADV() { for _, entry := range batch.Entries { - entryCount++ - - // Add in Addenda Count - if entry.Addenda02 != nil { - entryCount++ - } - entryCount = entryCount + len(entry.Addenda05) - if entry.Addenda98 != nil { - entryCount++ - } - if entry.Addenda99 != nil { - entryCount++ - } + entryCount += 1 + entry.addendaCount() } if entryCount != batch.Control.EntryAddendaCount { msg := fmt.Sprintf(msgBatchCalculatedControlEquality, entryCount, batch.Control.EntryAddendaCount) diff --git a/batcher.go b/batcher.go index 0a2175194..824ca36b8 100644 --- a/batcher.go +++ b/batcher.go @@ -50,7 +50,7 @@ func (e *BatchError) Error() string { var ( // generic messages msgBatchHeaderControlEquality = "header %v is not equal to control %v" - msgBatchCalculatedControlEquality = "calculated %v is out-of-balance with control %v" + msgBatchCalculatedControlEquality = "calculated %v is out-of-balance with batch control %v" msgBatchAscending = "%v is less than last %v. Must be in ascending order" // specific messages for error msgBatchCompanyEntryDescription = "Company entry description %v is not valid for batch type %v" diff --git a/entryDetail.go b/entryDetail.go index 9adc55281..6f3e36b36 100644 --- a/entryDetail.go +++ b/entryDetail.go @@ -497,3 +497,22 @@ func (ed *EntryDetail) CreditOrDebit() string { func (ed *EntryDetail) AddAddenda05(addenda05 *Addenda05) { ed.Addenda05 = append(ed.Addenda05, addenda05) } + +// addendaCount returns the count of Addenda records added onto this EntryDetail +func (ed *EntryDetail) addendaCount() (n int) { + if ed.Addenda02 != nil { + n += 1 + } + for i := range ed.Addenda05 { + if ed.Addenda05[i] != nil { + n += 1 + } + } + if ed.Addenda98 != nil { + n += 1 + } + if ed.Addenda99 != nil { + n += 1 + } + return n +} diff --git a/file.go b/file.go index eff37a53f..49d5c6963 100644 --- a/file.go +++ b/file.go @@ -28,7 +28,7 @@ const ( // Errors strings specific to parsing a Batch container var ( - msgFileCalculatedControlEquality = "calculated %v is out-of-balance with control %v" + msgFileCalculatedControlEquality = "calculated %v is out-of-balance with file control %v" // specific messages msgRecordLength = "must be 94 characters and found %d" msgFileBatchOutside = "outside of current batch" @@ -118,11 +118,12 @@ func FileFromJSON(bs []byte) (*File, error) { // Read FileHeader header := fileHeader{ - Header: NewFileHeader(), + Header: file.Header, } if err := json.NewDecoder(bytes.NewReader(bs)).Decode(&header); err != nil { return nil, fmt.Errorf("problem reading FileHeader: %v", err) } + file.Header = header.Header if !file.IsADV() { // Read FileControl @@ -148,14 +149,15 @@ func FileFromJSON(bs []byte) (*File, error) { if err := file.setBatchesFromJSON(bs); err != nil { return nil, err } - file.Header = header.Header + if !file.IsADV() { file.Control.BatchCount = len(file.Batches) } else { - file.ADVControl.BatchCount = len(file.Batches) } - + if err := file.Create(); err != nil { + return file, err + } return file, nil } @@ -177,7 +179,7 @@ func (f *File) setBatchesFromJSON(bs []byte) error { if err := json.Unmarshal(bs, &batches); err != nil { return err } - // Clear out any nil batchs + // Clear out any nil batchess for i := range f.Batches { if f.Batches[i] == nil { f.Batches = append(f.Batches[:i], f.Batches[i+1:]...) @@ -188,6 +190,9 @@ func (f *File) setBatchesFromJSON(bs []byte) error { if batches.Batches[i] == nil { continue } + if err := batches.Batches[i].build(); err != nil { + return fmt.Errorf("batch %s: %v", batches.Batches[i].Header.ID, err) + } f.Batches = append(f.Batches, batches.Batches[i]) } return nil @@ -214,6 +219,12 @@ func (f *File) Create() error { totalCreditAmount := 0 for i, batch := range f.Batches { + if v := f.Batches[i].GetHeader(); v == nil { + f.Batches[i].SetHeader(NewBatchHeader()) + } + if v := f.Batches[i].GetControl(); v == nil { + f.Batches[i].SetControl(NewBatchControl()) + } // create ascending batch numbers f.Batches[i].GetHeader().BatchNumber = batchSeq diff --git a/file_test.go b/file_test.go index a79889b45..251cd51fe 100644 --- a/file_test.go +++ b/file_test.go @@ -401,6 +401,14 @@ func TestFile__readFromJson(t *testing.T) { t.Fatal(err) } + // Ensure the file is valid + if err := file.Create(); err != nil { + t.Error(err) + } + if err := file.Validate(); err != nil { + t.Error(err) + } + if file.ID != "adam-01" { t.Errorf("file.ID: %s", file.ID) } @@ -422,7 +430,7 @@ func TestFile__readFromJson(t *testing.T) { } batch := file.Batches[0] batchControl := batch.GetControl() - if batchControl.EntryAddendaCount != 2 { + if batchControl.EntryAddendaCount != 1 { t.Errorf("EntryAddendaCount: %d", batchControl.EntryAddendaCount) } @@ -430,7 +438,7 @@ func TestFile__readFromJson(t *testing.T) { if file.Control.BatchCount != 1 { t.Errorf("BatchCount: %d", file.Control.BatchCount) } - if file.Control.EntryAddendaCount != 2 { + if file.Control.EntryAddendaCount != 1 { t.Errorf("File Control EntryAddendaCount: %d", file.Control.EntryAddendaCount) } if file.Control.TotalDebitEntryDollarAmountInFile != 0 || file.Control.TotalCreditEntryDollarAmountInFile != 100000 { @@ -448,3 +456,29 @@ func TestFile__readFromJson(t *testing.T) { t.Error(err) } } + +// TestFile__jsonFileNoControlBlobs will read an ach.File from its JSON form, but the JSON has no +// batchControl or fileControl sub-objects. +func TestFile__jsonFileNoControlBlobs(t *testing.T) { + path := filepath.Join("test", "testdata", "ppd-no-control-blobs-valid.json") + bs, err := ioutil.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + file, err := FileFromJSON(bs) + if err != nil { + t.Fatal(err) + } + + if err := file.Create(); err != nil { + t.Fatal(err) + } + if err := file.Validate(); err != nil { + t.Fatal(err) + } + + if file.ID != "adam-01" { + t.Errorf("file.ID: %s", file.ID) + } +} diff --git a/makefile b/makefile index 410e15056..1448aacfc 100644 --- a/makefile +++ b/makefile @@ -5,6 +5,7 @@ VERSION := $(shell grep -Eo '(v[0-9]+[\.][0-9]+[\.][0-9]+([-a-zA-Z0-9]*)?)' vers build: go fmt ./... @mkdir -p ./bin/ + go build github.com/moov-io/ach CGO_ENABLED=0 go build -o ./bin/server github.com/moov-io/ach/cmd/server generate: clean diff --git a/reader.go b/reader.go index 1673a8644..19f9861e7 100644 --- a/reader.go +++ b/reader.go @@ -151,7 +151,7 @@ func NewReader(r io.Reader) *Reader { // on the first character of each line. It also enforces ACH formatting rules and returns // the appropriate error if issues are found. // -// A parsed file may not be valid and callers should confirm with Validate(). Invalid files may +// A parsed file may not be valid and callers should confirm with Create() Validate(). Invalid files may // be rejected by other Financial Institutions or ACH tools. func (r *Reader) Read() (File, error) { r.lineNum = 0 diff --git a/test/testdata/ppd-no-control-blobs-valid.json b/test/testdata/ppd-no-control-blobs-valid.json new file mode 100644 index 000000000..499469ae9 --- /dev/null +++ b/test/testdata/ppd-no-control-blobs-valid.json @@ -0,0 +1,56 @@ +{ + "id": "adam-01", + "fileHeader": { + "id": "adam-01", + "immediateDestination": "231380104", + "immediateOrigin": "121042882", + "fileCreationDate": "2018-10-08T00:00:00Z", + "fileCreationTime": "0000-01-01T00:00:00Z", + "fileIDModifier": "A", + "immediateDestinationName": "Citadel", + "immediateOriginName": "Wells Fargo" + }, + "batches": [ + { + "batchHeader": { + "id": "adam-01", + "serviceClassCode": 200, + "companyName": "Wells Fargo", + "companyIdentification": "121042882", + "standardEntryClassCode": "PPD", + "companyEntryDescription": "Trans. Des", + "effectiveEntryDate": "2018-10-09T00:00:00Z", + "ODFIIdentification": "12104288", + "batchNumber": 1 + }, + "entryDetails": [ + { + "id": "adam-01", + "transactionCode": 22, + "RDFIIdentification": "23138010", + "checkDigit": "4", + "DFIAccountNumber": "81967038518 ", + "amount": 100000, + "identificationNumber": "#83738AB# ", + "individualName": "Steven Tander ", + "discretionaryData": " ", + "addendaRecordIndicator": 1, + "traceNumber": "121042880000001", + "category": "Forward", + "addenda05": [ + { + "entryDetailSequenceNumber": 1, + "sequenceNumber": 1, + "paymentRelatedInformation": "Bonus for working on #OSS!", + "typeCode": "", + "id": "gvluehibyuuqdoajiqfn" + } + ] + } + ] + } + ], + "IATBatches": null, + "NotificationOfChange": null, + "ReturnEntries": null +} diff --git a/test/testdata/ppd-valid.json b/test/testdata/ppd-valid.json index e5f4400ae..991fe7f0b 100644 --- a/test/testdata/ppd-valid.json +++ b/test/testdata/ppd-valid.json @@ -34,7 +34,7 @@ "identificationNumber": "#83738AB# ", "individualName": "Steven Tander ", "discretionaryData": " ", - "addendaRecordIndicator": 1, + "addendaRecordIndicator": 0, "traceNumber": "121042880000001", "category": "Forward" } @@ -42,7 +42,7 @@ "batchControl": { "id": "adam-01", "serviceClassCode": 200, - "entryAddendaƇount": 2, + "entryAddendaƇount": 0, "entryHash": 23138010, "totalDebit": 0, "totalCredit": 100000, @@ -57,7 +57,7 @@ "id": "adam-01", "batchCount": 1, "blockCount": 1, - "entryAddendaCount": 2, + "entryAddendaCount": 0, "entryHash": 23138010, "totalDebit": 0, "totalCredit": 100000