github.com/moov-io/imagecashletter@v0.10.1/file.go (about)

     1  // Copyright 2020 The Moov Authors
     2  // Use of this source code is governed by an Apache License
     3  // license that can be found in the LICENSE file.
     4  
     5  package imagecashletter
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"strconv"
    13  )
    14  
    15  // https://en.wikipedia.org/wiki/Substitute_check
    16  //
    17  // http://www.frbservices.org
    18  //
    19  // The Federal Reserve Banks uses the Accredited Standards Committee X9’s Specifications (X9.100-187–2016) for
    20  // Electronic Exchange of Check and Image Data in providing its suite of Check 21 services.
    21  //
    22  // Record Types
    23  const (
    24  	fileHeaderPos           = "01"
    25  	cashLetterHeaderPos     = "10"
    26  	bundleHeaderPos         = "20"
    27  	checkDetailPos          = "25"
    28  	checkDetailAddendumAPos = "26"
    29  	checkDetailAddendumBPos = "27"
    30  	checkDetailAddendumCPos = "28"
    31  	returnDetailPos         = "31"
    32  	returnAddendumAPos      = "32"
    33  	returnAddendumBPos      = "33"
    34  	returnAddendumCPos      = "34"
    35  	returnAddendumDPos      = "35"
    36  	imageViewDetailPos      = "50"
    37  	imageViewDataPos        = "52"
    38  	imageViewAnalysisPos    = "54"
    39  	creditPos               = "61"
    40  	creditItemPos           = "62"
    41  	bundleControlPos        = "70"
    42  	routingNumberSummaryPos = "85"
    43  	cashLetterControlPos    = "90"
    44  	fileControlPos          = "99"
    45  	// no longer supported by the standard
    46  	// accountTotalsDetailPos  = "40"
    47  	// nonHitTotalsDetailPos   = "41"
    48  	// boxSummaryPos           = "75"
    49  )
    50  
    51  // Record Types in EBCDIC
    52  const (
    53  	fileHeaderEbcPos           = "\xF0\xF1"
    54  	cashLetterHeaderEbcPos     = "\xF1\xF0"
    55  	bundleHeaderEbcPos         = "\xF2\xF0"
    56  	checkDetailEbcPos          = "\xF2\xF5"
    57  	checkDetailAddendumAEbcPos = "\xF2\xF6"
    58  	checkDetailAddendumBEbcPos = "\xF2\xF7"
    59  	checkDetailAddendumCEbcPos = "\xF2\xF8"
    60  	returnDetailEbcPos         = "\xF3\xF1"
    61  	returnAddendumAPEbcos      = "\xF3\xF2"
    62  	returnAddendumBEbcPos      = "\xF3\xF3"
    63  	returnAddendumCEbcPos      = "\xF3\xF4"
    64  	returnAddendumDEbcPos      = "\xF3\xF5"
    65  	imageViewDetailEbcPos      = "\xF5\xF0"
    66  	imageViewDataEbcPos        = "\xF5\xF2"
    67  	imageViewAnalysisEbcPos    = "\xF5\xF4"
    68  	creditEbcPos               = "\xF6\xF1"
    69  	creditItemEbcPos           = "\xF6\xF2"
    70  	bundleControlEbcPos        = "\xF7\xF0"
    71  	routingNumberSummaryEbcPos = "\xF8\xF5"
    72  	cashLetterControlEbcPos    = "\xF9\xF0"
    73  	fileControlEbcPos          = "\xF9\xF9"
    74  )
    75  
    76  // Errors strings specific to parsing a Batch container
    77  var (
    78  	msgRecordLength             = "Must be at least 80 characters and found %d"
    79  	msgFileCashLetterInside     = "Inside of current cash letter"
    80  	msgFileCashLetterControl    = "Cash letter control without a current cash letter"
    81  	msgFileRoutingNumberSummary = "Routing Number Summary without a current cash letter"
    82  	msgFileBundleOutside        = "Outside of current bundle"
    83  	msgFileBundleInside         = "Inside of current bundle"
    84  	msgFileBundleControl        = "Bundle control without a current bundle"
    85  	msgFileControl              = "None or more than one file control exists"
    86  	msgFileHeader               = "None or more than one file headers exists"
    87  	msgUnknownRecordType        = "%s is an unknown record type"
    88  	msgFileCashLetterID         = "%s is not unique"
    89  	msgRecordType               = "received expecting %d"
    90  	msgFileCreditItem           = "Credit item outside of cash letter"
    91  	msgFileCredit               = "Credit outside of cash letter"
    92  )
    93  
    94  // FileError is an error describing issues validating a file
    95  type FileError struct {
    96  	FieldName string
    97  	Value     string
    98  	Msg       string
    99  }
   100  
   101  func (e *FileError) Error() string {
   102  	return fmt.Sprintf("%s %s", e.FieldName, e.Msg)
   103  }
   104  
   105  type FileRecord interface {
   106  	setRecordType()
   107  	String() string
   108  }
   109  
   110  // File is an imagecashletter file
   111  type File struct {
   112  	// ID is a client defined string used as a reference to this record
   113  	ID string `json:"id"`
   114  	// FileHeader is an imagecashletter FileHeader
   115  	Header FileHeader `json:"fileHeader"`
   116  	// CashLetters are imagecashletter Cash Letters
   117  	CashLetters []CashLetter `json:"cashLetters,omitempty"`
   118  	// Bundles are imagecashletter Bundles
   119  	Bundles []Bundle `json:"bundle,omitempty"`
   120  	// FileControl is an imagecashletter FileControl
   121  	Control FileControl `json:"fileControl"`
   122  }
   123  
   124  // NewFile constructs a file template with a FileHeader and FileControl.
   125  func NewFile() *File {
   126  	return &File{
   127  		Header:  NewFileHeader(),
   128  		Control: NewFileControl(),
   129  	}
   130  }
   131  
   132  type fileHeader struct {
   133  	Header FileHeader `json:"fileHeader"`
   134  }
   135  
   136  type fileControl struct {
   137  	Control FileControl `json:"fileControl"`
   138  }
   139  
   140  // FileFromJSON attempts to return a *File object assuming the input is valid JSON.
   141  //
   142  // Callers should always check for a nil-error before using the returned file.
   143  //
   144  // The File returned may not be valid and callers should confirm with Validate().
   145  // Invalid files may be rejected by other Financial Institutions or ICL tools.
   146  func FileFromJSON(bs []byte) (*File, error) {
   147  	if len(bs) == 0 {
   148  		return nil, errors.New("no JSON data provided")
   149  	}
   150  
   151  	// read any root level fields
   152  	var f File
   153  	file := NewFile()
   154  	if err := json.NewDecoder(bytes.NewReader(bs)).Decode(&f); err != nil {
   155  		return nil, fmt.Errorf("problem reading file: %v", err)
   156  	}
   157  	file.ID = f.ID
   158  	file.CashLetters = f.CashLetters
   159  	file.Bundles = f.Bundles
   160  
   161  	// read the FileHeader
   162  	header := fileHeader{
   163  		Header: file.Header,
   164  	}
   165  	if err := json.NewDecoder(bytes.NewReader(bs)).Decode(&header); err != nil {
   166  		return nil, fmt.Errorf("problem reading FileHeader: %v", err)
   167  	}
   168  	file.Header = header.Header
   169  
   170  	// read file control
   171  	control := fileControl{
   172  		Control: NewFileControl(),
   173  	}
   174  	if err := json.NewDecoder(bytes.NewReader(bs)).Decode(&control); err != nil {
   175  		return nil, fmt.Errorf("problem reading FileControl: %v", err)
   176  	}
   177  	file.Control = control.Control
   178  
   179  	file.setRecordTypes()
   180  
   181  	if err := file.Create(); err != nil {
   182  		return file, err
   183  	}
   184  	if err := file.Validate(); err != nil {
   185  		return file, err
   186  	}
   187  	return file, nil
   188  }
   189  
   190  // Create creates a valid imagecashletter File
   191  func (f *File) Create() error {
   192  	if f == nil {
   193  		return ErrNilFile
   194  	}
   195  	// Requires a valid FileHeader to build FileControl
   196  	if err := f.Header.Validate(); err != nil {
   197  		return err
   198  	}
   199  
   200  	if len(f.CashLetters) <= 0 {
   201  		return &FileError{FieldName: "CashLetters", Value: strconv.Itoa(len(f.CashLetters)), Msg: "must have []*CashLetters to be built"}
   202  	}
   203  
   204  	// File Control Counts
   205  	fileCashLetterCount := len(f.CashLetters)
   206  	// add 2 for FileHeader/control and reset if build was called twice do to error
   207  	fileTotalRecordCount := 2
   208  	fileTotalItemCount := 0
   209  	fileTotalAmount := 0
   210  	creditIndicator := 0
   211  
   212  	// CashLetters
   213  	for _, cl := range f.CashLetters {
   214  		// Validate CashLetter
   215  		if err := cl.Validate(); err != nil {
   216  			return err
   217  		}
   218  		// add 2 for each cashletter header/control
   219  		fileTotalRecordCount = fileTotalRecordCount + 2
   220  
   221  		if len(cl.GetCreditItems()) > 0 {
   222  			fileTotalRecordCount = fileTotalRecordCount + len(cl.GetCreditItems())
   223  			creditIndicator = 1
   224  		}
   225  
   226  		// Bundles
   227  		for _, b := range cl.Bundles {
   228  			// Validate Bundle
   229  			if err := b.Validate(); err != nil {
   230  				return err
   231  			}
   232  
   233  			// add 2 for each bundle header/control
   234  			fileTotalRecordCount = fileTotalRecordCount + 2
   235  
   236  			// Check Items
   237  			for _, cd := range b.Checks {
   238  				fileTotalItemCount = fileTotalItemCount + 1
   239  
   240  				fileTotalRecordCount = fileTotalRecordCount + 1
   241  				fileTotalRecordCount = fileTotalRecordCount + len(cd.CheckDetailAddendumA) + len(cd.CheckDetailAddendumB) + len(cd.CheckDetailAddendumC)
   242  				fileTotalRecordCount = fileTotalRecordCount + len(cd.ImageViewDetail) + len(cd.ImageViewData) + len(cd.ImageViewAnalysis)
   243  
   244  				fileTotalAmount = fileTotalAmount + cd.ItemAmount
   245  			}
   246  			// Returns Items
   247  			for _, rd := range b.Returns {
   248  				fileTotalItemCount = fileTotalItemCount + 1
   249  
   250  				fileTotalRecordCount = fileTotalRecordCount + 1
   251  				fileTotalRecordCount = fileTotalRecordCount + len(rd.ReturnDetailAddendumA) + len(rd.ReturnDetailAddendumB) + len(rd.ReturnDetailAddendumC) + len(rd.ReturnDetailAddendumD)
   252  				fileTotalRecordCount = fileTotalRecordCount + len(rd.ImageViewDetail) + len(rd.ImageViewData) + len(rd.ImageViewAnalysis)
   253  
   254  				fileTotalAmount = fileTotalAmount + rd.ItemAmount
   255  			}
   256  		}
   257  	}
   258  
   259  	// create FileControl from calculated values
   260  	fc := NewFileControl()
   261  	fc.CashLetterCount = fileCashLetterCount
   262  	fc.TotalRecordCount = fileTotalRecordCount
   263  	fc.TotalItemCount = fileTotalItemCount
   264  	fc.FileTotalAmount = fileTotalAmount
   265  	fc.ImmediateOriginContactName = f.Control.ImmediateOriginContactName
   266  	fc.ImmediateOriginContactPhoneNumber = f.Control.ImmediateOriginContactPhoneNumber
   267  	fc.CreditTotalIndicator = creditIndicator
   268  	f.Control = fc
   269  	return nil
   270  }
   271  
   272  // Validate validates an ICL File
   273  func (f *File) Validate() error {
   274  	if f == nil {
   275  		return ErrNilFile
   276  	}
   277  	if err := f.CashLetterIDUnique(); err != nil {
   278  		return err
   279  	}
   280  	return nil
   281  }
   282  
   283  // SetHeader allows for header to be built.
   284  func (f *File) SetHeader(h FileHeader) *File {
   285  	f.Header = h
   286  	return f
   287  }
   288  
   289  // AddCashLetter appends a CashLetter to the imagecashletter.File
   290  func (f *File) AddCashLetter(cashLetter CashLetter) []CashLetter {
   291  	f.CashLetters = append(f.CashLetters, cashLetter)
   292  	return f.CashLetters
   293  }
   294  
   295  // CashLetterIDUnique verifies multiple CashLetters in a file have a unique CashLetterID
   296  func (f *File) CashLetterIDUnique() error {
   297  	if f == nil || len(f.CashLetters) == 0 {
   298  		return ErrNilFile
   299  	}
   300  	cashLetterID := ""
   301  	for _, cl := range f.CashLetters {
   302  		if cl.CashLetterHeader == nil {
   303  			continue
   304  		}
   305  		if cashLetterID == cl.CashLetterHeader.CashLetterID {
   306  			msg := fmt.Sprintf(msgFileCashLetterID, cashLetterID)
   307  			return &FileError{FieldName: "CashLetterID", Value: cl.CashLetterHeader.CashLetterID, Msg: msg}
   308  		}
   309  		cashLetterID = cl.CashLetterHeader.CashLetterID
   310  	}
   311  	return nil
   312  }
   313  
   314  func (f *File) setRecordTypes() {
   315  	if f == nil {
   316  		return
   317  	}
   318  
   319  	f.Header.setRecordType()
   320  	for i := range f.CashLetters {
   321  		f.CashLetters[i].setRecordType()
   322  	}
   323  	for i := range f.Bundles {
   324  		f.Bundles[i].setRecordType()
   325  	}
   326  	f.Control.setRecordType()
   327  }