diff --git a/README.md b/README.md index aa37618..685de2f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,19 @@ # aggregate-inecobank-statement -[CLI yet] tool to aggregates data from Armenian's Inecobank "statement" per month -into groups which allows to get insights into your budget. +Local tool to aggregates data from Armenian's [Inecobank](https://online.inecobank.am) +"statements" from multiple accounts monthly, into groups which allows to get insights into your budget. Example of output (numbers are made up, sum may not match): ``` 2023-08-01..2023-08-31: Income (2, sum=1,493,878.00): Main salary : 1,345,343.00 - unknown : 148,535.00 - Expenses (13, sum=1,020,636.38): - Other account : 456,000.00 + Expenses (13, sum= 920,636.38): + Rent : 300,000.00 Tom's health : 240,000.00 - Cash : 178,000.00 + Cash withdrowal : 178,000.00 Groceries : 112,831.00 - Hotels : 90,000.00 + Kindergarten : 90,000.00 Kate's health : 61,000.00 - unknown : 19,370.00 Taxi : 17,600.00 Entertainment : 14,000.00 Subscriptions : 7,787.78 @@ -25,32 +23,27 @@ Example of output (numbers are made up, sum may not match): Income (2, sum=1,516,629.00): ... ``` -Where "unknown" is group-s of "not classified yet" transactions. - -Works with XML statements only, for CSV outputs failed to complete. -All Golang libraries I found are based on "encoding/csv" stdlib package which can't -parse CSV not compliant with [RFC 4180](https://www.ietf.org/rfc/rfc4180.txt). -But world is full of "don't care/know" developers and CSV-s with rows formatted as `cell1,"ce,ll2",3`. # How to use 1. Download binary ("aggregate-inecobank-statements-\*-\*" file) for your operating system from [Releases](https://github.com/AlexanderMakarov/aggregate-inecobank-statement/releases) page. -2. Download "Statement ....xml" file from https://online.inecobank.am. - Click of account from which you want analyze expenses, - next put into 'From' and 'To' fields dates you want to analyze, +2. Download "Statement ....xml" files from https://online.inecobank.am for interesting period and + put them near the "aggregate-inecobank-statements-\*-\*" file. + In details, on [main page](https://online.inecobank.am) click on the chosen account, + specify into 'From' and 'To' fields dates you want to analyze, press 'Search', scroll page to bottom and here at right corner will be 5 icons to download statement. - Press XML button and save file as "Statement.xml" near the binary. + Press XML button and save near "aggregate-inecobank-statements-\*-\*" file. 3. Download example of configuration [config.yaml](https://github.com/AlexanderMakarov/aggregate-inecobank-statement/raw/master/config.yaml). Don't need to update it yet, see step 5. 4. Run binary ("aggregate-inecobank-statements-\*-\*" file). - It would open text file with list from a lot of groups where (most probably) + It would open text file with the list from a lot of groups where (most probably) a lot of names would be taken from "Details" field of transactions but some of them would be from the example config groups. -5. Investigate your personal transaction information and update configuration file groups with some unique +5. Investigate your personal transaction information and update configuration file groups with unique for specific transaction substrings to aggregate transaction into these groups. - See example in configuration file - you may remove not needed and add your own groups. + See examples in configuration file - you may remove not needed and add your own groups. Be careful about syntax and indentations. 6. Run binary again, and repeat configuration changes if need. When number of transactions in "unknown" group would decrease to small enough number @@ -58,6 +51,7 @@ But world is full of "don't care/know" developers and CSV-s with rows formatted If you still want to see all these "unknown" transactions then consider to set `groupAllUnknownTransactions` to `false` - it will cause to put new groups with name equal to "Details" field value. 7. Run binary again - it should provide clean report for manual investigation, comparing months, etc. + On new month it is enough to downloan "Statements" with new transactions and run binary again. # TODO - [x] Add support of statements from different accounts. @@ -65,5 +59,6 @@ But world is full of "don't care/know" developers and CSV-s with rows formatted - [x] Configure way to skip transactions from other file. - [x] Provide good start config. - [x] Add CI/CD with builds for Unix/Windows/MacOS. -- [ ] Clean extra tags from the repo. -- [ ] Update README. \ No newline at end of file +- [x] Clean extra tags from the repo. +- [x] Update README. +- [ ] Create video "How to use". \ No newline at end of file diff --git a/go.mod b/go.mod index 2673fa2..8b7f432 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,6 @@ go 1.20 require ( github.com/alexflint/go-arg v1.4.3 github.com/go-playground/validator/v10 v10.15.1 - github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d - golang.org/x/text v0.11.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,4 +17,5 @@ require ( golang.org/x/crypto v0.7.0 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/sys v0.6.0 // indirect + golang.org/x/text v0.11.0 // indirect ) diff --git a/go.sum b/go.sum index d090f41..7ad9658 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.15.1 h1:BSe8uhN+xQ4r5guV/ywQI4gO59C2raYcGffYWZEjZzM= github.com/go-playground/validator/v10 v10.15.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d h1:KbPOUXFUDJxwZ04vbmDOc3yuruGvVO+LOa7cVER3yWw= -github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/ineco_csv_parser.go b/ineco_csv_parser.go deleted file mode 100644 index bc8608b..0000000 --- a/ineco_csv_parser.go +++ /dev/null @@ -1,220 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "github.com/gocarina/gocsv" -) - -type TransactionCsv struct { - Nn string `csv:"nn"` - Number string `csv:"number"` - Date DateTime `csv:"omitempty"` - Currency string `csv:"currency"` - Income MoneyWith2DecimalPlaces `csv:"omitempty"` - Expense MoneyWith2DecimalPlaces `csv:"omitempty"` - RecieverOrPayerAccount string `csv:"omitempty"` - RecieverOrPayer string `csv:"omitempty"` - Details string `csv:"omitempty"` -} - -// DateTime is a wrapper for standard Time to be parsed from custom format. -type DateTime struct { - time.Time -} - -func (date *DateTime) UnmarshalCSV(field string) (err error) { - date.Time, err = time.Parse(InecoDateFormat, field) - return err -} - -func (m *MoneyWith2DecimalPlaces) UnmarshalCSV(field string) (err error) { - floatVal, err := strconv.ParseFloat(strings.ReplaceAll(field, ",", ""), 32) - if err != nil { - return err - } - m.int = int(floatVal * 100) - return nil -} - -type CSVParser struct{} - -func (p CSVParser) ParseRawTransactionsFromFile(filePath string) ([]InecoTransaction, error) { - - // Open the CSV file. - file, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("Error opening '%s' file: %w", filePath, err) - } - defer file.Close() - - // Prepare reader which may find start and end of data in the CSV. - csvReader := InsideCSVReader( - file, - "n/n,Number,Date,Currency,Income,Expense,Receiver/Payer Account,Receiver/Payer,Details", - "Total", - ) - - // Provide header to the csvutil library. - // Note that standard "encoding/csv" fails because CSV doesn't match standard CSV format. - // Ineco CSV-s have fields enclosed in single double-quote characters with comma inside. - // "github.com/jszwec/csvutil", "github.com/gocarina/gocsv" are based on "encoding/csv" so don't work as well. - - // Configure custom transformations. - gocsv.SetCSVReader(func(in io.Reader) gocsv.CSVReader { - return gocsv.LazyCSVReader(in) // Allows use of quotes in CSV - }) - // Time format is custom. - // unmarshalTime := func(data []byte, t *time.Time) error { - // tt, err := time.Parse(inecoCsvDateFormat, string(data)) - // if err != nil { - // return err - // } - // *t = tt - // return nil - // } - // decoder.Register(unmarshalTime) - // // Money amounts contains commas as thousands separators. - // decoder.Map = func(field, column string, v any) string { - // if _, ok := v.(float32); ok { - // return strings.ReplaceAll(field, ",", "") - // } - // return field - // } - - transactions := []*TransactionCsv{} - if err := gocsv.UnmarshalWithoutHeaders(csvReader, &transactions); err != nil { - return nil, fmt.Errorf("Invalid CSV in '%s' file: %w", filePath, err) - } - - // Read and parse the remaining lines - // for { - // var transaction TransactionCsv - // if err := decoder.Decode(&transaction); err == io.EOF { - // break - // } else if err != nil { - // log.Fatal(err) - // } - - // transactions = append(transactions, &transaction) - // } - // for { - // fields, err := csvReader.Read() - // if err == io.EOF { - // break - // } else if err != nil { - // return nil, err - // } - - // date, err := parseDate(fields[2]) - // if err != nil { - // return nil, fmt.Errorf( - // "Invalid CSV in '%s' file. On the 3 cell of '%v' expected date, but failed with: %w", - // filePath, fields, err, - // ) - // } - // income, err := parseFloat(fields[4]) - // if err != nil { - // return nil, fmt.Errorf( - // "Invalid CSV in '%s' file. On the 5 cell of '%v' expected money amount, but failed with: %w", - // filePath, fields, err, - // ) - // } - // expense, err := parseFloat(fields[5]) - // if err != nil { - // return nil, fmt.Errorf( - // "Invalid CSV in '%s' file. On the 6 cell of '%v' expected money amount, but failed with: %w", - // filePath, fields, err, - // ) - // } - // transaction := &TransactionCsv{ - // Nn: fields[0], - // Number: fields[1], - // Date: *date, - // Currency: fields[3], - // Income: *income, - // Expense: *expense, - // RecieverOrPayerAccount: fields[6], - // RecieverOrPayer: fields[7], - // Details: fields[8], - // } - // transactions = append(transactions, transaction) - // } - - inecoTransactions := make([]InecoTransaction, 0, len(transactions)) - for _, t := range transactions { - inecoTransactions = append(inecoTransactions, InecoTransaction{ - Nn: t.Nn, - Number: t.Number, - Date: t.Date.Time, - Currency: t.Currency, - Income: t.Income, - Expense: t.Expense, - RecieverOrPayerAccount: t.RecieverOrPayer, - RecieverOrPayer: t.RecieverOrPayer, - Details: t.Details, - }) - } - return inecoTransactions, nil -} - -// func (t *TransactionCsv) UnmarshalCSV(fields []string, filePath string) error { -// if len(fields) != 9 { -// return errors.New("invalid number of fields in record") -// } - -// date, err := parseDate(fields[2]) -// if err != nil { -// return fmt.Errorf( -// "Invalid CSV in '%s' file. On the 3 cell of '%v' expected date, but failed with: %w", -// filePath, fields, err, -// ) -// } -// income, err := parseFloat(fields[4]) -// if err != nil { -// return fmt.Errorf( -// "Invalid CSV in '%s' file. On the 5 cell of '%v' expected money amount, but failed with: %w", -// filePath, fields, err, -// ) -// } -// expense, err := parseFloat(fields[5]) -// if err != nil { -// return fmt.Errorf( -// "Invalid CSV in '%s' file. On the 6 cell of '%v' expected money amount, but failed with: %w", -// filePath, fields, err, -// ) -// } -// t.Nn = fields[0] -// t.Number = fields[1] -// t.Date = *date -// t.Currency = fields[3] -// t.Income = *income -// t.Expense = *expense -// t.RecieverOrPayerAccount = fields[6] -// t.RecieverOrPayer = fields[7] -// t.Details = fields[8] - -// return nil -// } - -func parseDate(dateStr string) (*time.Time, error) { - date, err := time.Parse(InecoDateFormat, dateStr) - if err != nil { - return nil, err - } - return &date, nil -} - -func parseFloat(floatStr string) (*float32, error) { - floatVal, err := strconv.ParseFloat(strings.ReplaceAll(floatStr, ",", ""), 32) - if err != nil { - return nil, err - } - value := float32(floatVal) - return &value, nil -} diff --git a/inside_csv_reader.go b/inside_csv_reader.go deleted file mode 100644 index 79696a5..0000000 --- a/inside_csv_reader.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io" - "strings" - - "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" -) - -type LineReader struct { - scanner *bufio.Scanner - startKey string - endKey string - inRange bool -} - -func InsideCSVReader(reader io.Reader, startKey, endKey string) *LineReader { - return &LineReader{ - scanner: bufio.NewScanner(transform.NewReader( - reader, - unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), - )), - startKey: startKey, - endKey: endKey, - inRange: false, - } -} - -func (lr *LineReader) Read(p []byte) (n int, err error) { - i := 0 - if !lr.inRange { - for lr.scanner.Scan() { - i++ - line := lr.scanner.Text() - if strings.HasPrefix(line, lr.startKey) { - fmt.Println("Found start of data on", i, "line.") - lr.inRange = true - break - } - } - if !lr.inRange { - return 0, io.EOF - } - } - - buffer := []string{} - for lr.scanner.Scan() { - i++ - line := lr.scanner.Text() - if strings.HasPrefix(line, lr.endKey) { - fmt.Println("Found end of data on", i, "line.") - lr.inRange = false - break - } - buffer = append(buffer, line) - } - - if err := lr.scanner.Err(); err != nil { - return n, err - } - - if len(buffer) == 0 { - return 0, io.EOF - } - - joinedLines := strings.Join(buffer, "\n") - n = copy(p, joinedLines) - - return n, nil -}