diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4e427f5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci.yaml + +on: + pull_request: + branches: [ main ] + +jobs: + lint-and-test: + name: Lint and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.4.0 + + - name: Run unit tests + run: go test -v -race ./... diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..5706c49 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,170 @@ +--- +version: "2" + +linters: + default: none + enable: + # enabled by default by golangci-lint + - errcheck # Errcheck is a program for checking for unchecked errors in Go code. These unchecked errors can be critical bugs in some cases. + - govet # Vet examines Go source code and reports suspicious constructs. It is roughly the same as 'go vet' and uses its passes. + - ineffassign # Detects when assignments to existing variables are not used. + - staticcheck # It's the set of rules from staticcheck. + - unused # Checks Go code for unused constants, variables, functions and types. + # extra enabled by us + - arangolint # Opinionated best practices for arangodb client. + - asasalint # Check for pass []any as any in variadic func(...any). + - asciicheck # Checks that all code identifiers does not have non-ASCII symbols in the name. + - bidichk # Checks for dangerous unicode character sequences. + - bodyclose # Checks whether HTTP response body is closed successfully. + - canonicalheader # Canonicalheader checks whether net/http.Header uses canonical header. + - containedctx # Containedctx is a linter that detects struct contained context.Context field. + - contextcheck # Check whether the function uses a non-inherited context. + - copyloopvar # A linter detects places where loop variables are copied. + - cyclop # Checks function and package cyclomatic complexity. + - decorder # Check declaration order and count of types, constants, variables and functions. + - depguard # Go linter that checks if package imports are in a list of acceptable packages. + - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, #= f()). + - dupl # Detects duplicate fragments of code. + - dupword # Checks for duplicate words in the source code. + - durationcheck # Check for two durations multiplied together. + - embeddedstructfieldcheck # Embedded types should be at the top of the field list of a struct, and there must be an empty line separating embedded fields from regular fields. + - errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and reports occurrences where the check for the returned error can be omitted. + - errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`. + - errorlint # Find code that can cause problems with the error wrapping scheme introduced in Go 1.13. + - exhaustive # Check exhaustiveness of enum switch statements. + - exptostd # Detects functions from golang.org/x/exp/ that can be replaced by std functions. + - fatcontext # Detects nested contexts in loops and function literals. + - forbidigo # Forbids identifiers. + - forcetypeassert # Find forced type assertions. + - funcorder # Checks the order of functions, methods, and constructors. + - funlen # Checks for long functions. + - ginkgolinter # Enforces standards of using ginkgo and gomega. + - gocheckcompilerdirectives # Checks that go compiler directive comments (//go #) are valid. + - gochecksumtype # Run exhaustiveness checks on Go "sum types". + - gocognit # Computes and checks the cognitive complexity of functions. + - goconst # Finds repeated strings that could be replaced by a constant. + - gocritic # Provides diagnostics that check for bugs, performance and style issues. + - godot # Check if comments end in a period. + - goheader # Check if file header matches to pattern. + - gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod. + - gomodguard # Allow and blocklist linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations. + - goprintffuncname # Checks that printf-like functions are named with `f` at the end. + - gosec # Inspects source code for security problems. + - gosmopolitan # Report certain i18n/l10n anti-patterns in your Go codebase. + - grouper # Analyze expression groups. + - iface # Detect the incorrect use of interfaces, helping developers avoid interface pollution. + - importas # Enforces consistent import aliases. + - inamedparam # Reports interfaces with unnamed method parameters. + - interfacebloat # A linter that checks the number of methods inside an interface. + - intrange # Intrange is a linter to find places where for loops could make use of an integer range. + - ireturn # Accept Interfaces, Return Concrete Types. + - loggercheck # Checks key value pairs for common logger libraries (kitlog,klog,logr,slog,zap). + - maintidx # Maintidx measures the maintainability index of each function. + - makezero # Find slice declarations with non-zero initial length. + - mirror # Reports wrong mirror patterns of bytes/strings usage. + - misspell # Finds commonly misspelled English words. + - mnd # An analyzer to detect magic numbers. + - musttag # Enforce field tags in (un)marshaled structs. + - nakedret # Checks that functions with naked returns are not longer than a maximum size (can be zero). + - nestif # Reports deeply nested if statements. + - nilerr # Find the code that returns nil even if it checks that the error is not nil. + - nilnesserr # Reports constructs that checks for err != nil, but returns a different nil value error. + - nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value. + - nlreturn # Checks for a new line before return and branch statements to increase code clarity. + - nolintlint # Reports ill-formed or insufficient nolint directives. + - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. + - paralleltest # Detects missing usage of t.Parallel() method in your Go test. + - perfsprint # Checks that fmt.Sprintf can be replaced with a faster alternative. + - prealloc # Find slice declarations that could potentially be pre-allocated. + - predeclared # Find code that shadows one of Go's predeclared identifiers. + - promlinter # Check Prometheus metrics naming via promlint. + - protogetter # Reports direct reads from proto message fields when getters should be used. + - reassign # Checks that package variables are not reassigned. + - recvcheck # Checks for receiver type consistency. + - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. + - rowserrcheck # Checks whether Rows.Err of rows is checked successfully. + - sloglint # Ensure consistent code style when using log/slog. + - spancheck # Checks for mistakes with OpenTelemetry/Census spans. + - sqlclosecheck # Checks that sql.Rows, sql.Stmt, sqlx.NamedStmt, pgx.Query are closed. + - tagalign # Check that struct tags are well aligned. + - tagliatelle # Checks the struct tags. + - testableexamples # Linter checks if examples are testable (have an expected output). + - testifylint # Checks usage of github.com/stretchr/testify. + - testpackage # Linter that makes you use a separate _test package. + - thelper # Thelper detects tests helpers which is not start with t.Helper() method. + - tparallel # Tparallel detects inappropriate usage of t.Parallel() method in your Go test codes. + - unconvert # Remove unnecessary type conversions. + - unparam # Reports unused function parameters. + - usestdlibvars # A linter that detect the possibility to use variables/constants from the Go standard library. + - usetesting # Reports uses of functions with replacement inside the testing package. + - wastedassign # Finds wasted assignment statements. + - whitespace # Whitespace is a linter that checks for unnecessary newlines at the start and end of functions, if, for, etc. + - wsl_v5 # Add or remove empty lines. + - zerologlint # Detects the wrong usage of `zerolog` that a user forgets to dispatch with `Send` or `Msg`. + + exclusions: + rules: + - path: _test\.go + linters: + - cyclop # Checks function and package cyclomatic complexity. + - errcheck # Errcheck is a program for checking for unchecked errors in Go code. These unchecked errors can be critical bugs in some cases. + - dupl # Detects duplicate fragments of code. + - funlen # Checks for long functions. + - gosec # Inspects source code for security problems. + - nestif # Reports deeply nested if statements. + - paralleltest # Detects missing usage of t.Parallel() method in your Go test. + - testpackage # Linter that makes you use a separate _test package. + + settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 15 + depguard: + rules: + main: + deny: + - pkg: "math/rand$" + desc: use math/rand/v2 + - pkg: "github.com/sirupsen/logrus" + desc: no longer maintained + - pkg: "github.com/pkg/errors" + desc: Should be replaced by standard lib errors package + forbidigo: + # Forbid the following identifiers (list of regexp). + # Default: ["^(fmt\\.Print(|f|ln)|print|println)$"] + forbid: + - pattern: ^(fmt\\.Print(|f|ln)|print|println)$ + - pattern: http\.NotFound.* + msg: return RFC 7807 problem details instead + - pattern: http\.Error.* + msg: return RFC 7807 problem details instead + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + nestif: + # Minimal complexity of if statements to report. + # Default: 5 + min-complexity: 6 + +formatters: + enable: + - gofmt # checks if the code is formatted according to 'gofmt' command + - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt + - golines # checks if code is formatted, and fixes long lines. + +output: + formats: + text: + print-linter-name: true + print-issued-lines: true + colors: true + +run: + # Timeout for analysis. + timeout: 5m + + # Modules download mode (do not modify go.mod) + modules-download-mode: readonly \ No newline at end of file diff --git a/README.md b/README.md index 4cf4c41..551de37 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,9 @@ Give clarity in your bank expenses. ## Run -Import csv into sqlite DB. +Generate report from csv ```shell -go mod init -go mod tidy - -go test ./... -v - -go build -o expenses cmd/main.go ./expenses \ --csv=testdata/transactions.csv \ @@ -21,6 +15,20 @@ go build -o expenses cmd/main.go --period=3y +``` + +## Development setup + +```shell + +go mod init +go mod tidy + +go test ./... -v +golangci-lint run # Install golangci-lint from https://golangci-lint.run/usage/install + +go build -ldflags="-X 'main.Version=$(git describe --tags --always)'" -o expenses cmd/main.go + ``` ## CSV Columns @@ -40,7 +48,8 @@ Boekdatum Rekeningnummer Bedrag Debet / Credit Naam tegenrekening Tegenrekening ### v2.1 - [x] report: all untyped transactions -- [ ] CLI +- [x] CLI +- [x] linting ### v2.2* diff --git a/cmd/main.go b/cmd/main.go index c7c019a..0f9cadc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,8 +1,8 @@ +// Package main creates the CLI. package main import ( "flag" - "fmt" "log" "os" @@ -11,15 +11,36 @@ import ( "github.com/dirc/expenses/internal/reports" ) +var Version = "dev" // Default value, overwrite during build. + func main() { // Define CLI flags - csvPath := flag.String("csv", "", "Path to the CSV file containing transactions") - yamlPath := flag.String("yaml", "", "Path to the YAML file containing transaction types") - reportPeriod := flag.String("period", "", "Report period (e.g., '3m' for 3 months, '4y' for 4 years)") + versionFlag := flag.Bool("version", false, "Print the version and exit") + csvPath := flag.String( + "csv", + "", + "Path to the CSV file containing transactions", + ) + yamlPath := flag.String( + "yaml", + "", + "Path to the YAML file containing transaction types", + ) + reportPeriod := flag.String( + "period", + "", + "Report period (e.g., '3m' for 3 months, '4y' for 4 years)", + ) // Parse flags flag.Parse() + // Handle version flag + if *versionFlag { + log.Printf("expenses version: %s\n", Version) + os.Exit(0) + } + // Validate required flags if *csvPath == "" || *yamlPath == "" || *reportPeriod == "" { flag.Usage() @@ -48,6 +69,6 @@ func main() { } // Print the report - reports.PrintPeriodicReport(report, fmt.Sprintf("Report for %s", *reportPeriod)) + reports.PrintPeriodicReport(report, "Report for "+*reportPeriod) reports.GenerateUntypedReport(transactions) } diff --git a/go.sum b/go.sum index 2052145..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index d912156..cad403e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1 +1,2 @@ +// Package config contains configuration methods. package config diff --git a/internal/etl/extract/extract.go b/internal/etl/extract/extract.go index dcc4072..4419ad1 100644 --- a/internal/etl/extract/extract.go +++ b/internal/etl/extract/extract.go @@ -1,7 +1,9 @@ +// Package extract extracts data into memory. package extract import ( "encoding/csv" + "log" "os" "strconv" "strings" @@ -10,15 +12,21 @@ import ( "github.com/dirc/expenses/internal/models" ) +// ReadCSV read csv file containing transactions. func ReadCSV(filePath string) ([]models.Transaction, error) { - file, err := os.Open(filePath) + file, err := os.Open(filePath) // #nosec G304 if err != nil { return nil, err } - defer file.Close() + + defer func() { + if err := file.Close(); err != nil { + log.Printf("Failed to close file: %v", err) + } + }() reader := csv.NewReader(file) - reader.Comma = ',' // CSV is comma-separated + reader.Comma = ',' // Read and discard header _, err = reader.Read() @@ -27,6 +35,7 @@ func ReadCSV(filePath string) ([]models.Transaction, error) { } var transactions []models.Transaction + for { record, err := reader.Read() if err != nil { diff --git a/internal/etl/extract/extract_test.go b/internal/etl/extract/extract_test.go index 6ee73f2..f4bcf7c 100644 --- a/internal/etl/extract/extract_test.go +++ b/internal/etl/extract/extract_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestReadCSV(t *testing.T) { @@ -14,34 +15,36 @@ func TestReadCSV(t *testing.T) { "07-12-2022","NL99TRIO0123456789","58,08","Debet","","","XS","CCV*Auto-Veer \DIRC","93,95" "08-12-2022","NL99TRIO0123456789","12,80","Debet","","","XS","Bakkerij A","81,15"` - tmpFile, err := os.CreateTemp("", "test*.csv") - assert.NoError(t, err) + tmpFile, err := os.CreateTemp(t.TempDir(), "test*.csv") + require.NoError(t, err) + // require.noError(t, err) + defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(csvContent) - assert.NoError(t, err) + require.NoError(t, err) err = tmpFile.Close() - assert.NoError(t, err) + require.NoError(t, err) // Call the function transactions, err := ReadCSV(tmpFile.Name()) - assert.NoError(t, err) + require.NoError(t, err) // Assertions assert.Len(t, transactions, 2) // Check first transaction assert.Equal(t, "NL99TRIO0123456789", transactions[0].Rekeningnummer) - assert.Equal(t, 58.08, transactions[0].Bedrag) assert.Equal(t, "Debet", transactions[0].DebetCredit) assert.Equal(t, "CCV*Auto-Veer \\DIRC", transactions[0].Omschrijving) - assert.Equal(t, 93.95, transactions[0].SaldoNaBoeking) + assert.InEpsilon(t, 58.08, transactions[0].Bedrag, 0.001) + assert.InEpsilon(t, 93.95, transactions[0].SaldoNaBoeking, 0.001) // Check second transaction assert.Equal(t, "NL99TRIO0123456789", transactions[1].Rekeningnummer) - assert.Equal(t, 12.80, transactions[1].Bedrag) assert.Equal(t, "Bakkerij A", transactions[1].Omschrijving) - assert.Equal(t, 81.15, transactions[1].SaldoNaBoeking) + assert.InEpsilon(t, 12.80, transactions[1].Bedrag, 0.001) + assert.InEpsilon(t, 81.15, transactions[1].SaldoNaBoeking, 0.001) // Check date parsing expectedDate, _ := time.Parse("02-01-2006", "07-12-2022") diff --git a/internal/etl/load/load.go b/internal/etl/load/load.go index a4ac056..2f65799 100644 --- a/internal/etl/load/load.go +++ b/internal/etl/load/load.go @@ -1 +1,2 @@ +// Package load loads enriched transactions to database. package load diff --git a/internal/etl/transform/transform.go b/internal/etl/transform/transform.go index a32701d..53c4a6e 100644 --- a/internal/etl/transform/transform.go +++ b/internal/etl/transform/transform.go @@ -1,3 +1,4 @@ +// Package transform enriches data. package transform import ( @@ -8,13 +9,15 @@ import ( "gopkg.in/yaml.v3" ) +// LoadTransactionTypes loads yaml file with transactiontypes. func LoadTransactionTypes(configPath string) ([]models.TransactionType, error) { - data, err := os.ReadFile(configPath) + data, err := os.ReadFile(configPath) // #nosec G304 if err != nil { return nil, err } var config models.TransactionTypeConfig + err = yaml.Unmarshal(data, &config) if err != nil { return nil, err @@ -23,21 +26,28 @@ func LoadTransactionTypes(configPath string) ([]models.TransactionType, error) { return config.TransactionTypes, nil } -func EnrichTransactions(transactions []models.Transaction, types []models.TransactionType) []models.Transaction { +// EnrichTransactions add transaction types for each transaction. +func EnrichTransactions( + transactions []models.Transaction, + types []models.TransactionType, +) []models.Transaction { for i, tx := range transactions { for _, tt := range types { if matchesSearchValues(tx, tt.SearchValues) { transactions[i].TransactionType = tt.Type + break } } } + return transactions } func matchesSearchValues(tx models.Transaction, searchValues map[string][]string) bool { for field, patterns := range searchValues { var value string + switch field { case "naamTegenrekening": value = tx.NaamTegenrekening @@ -50,29 +60,34 @@ func matchesSearchValues(tx models.Transaction, searchValues map[string][]string } for _, pattern := range patterns { - // Handle wildcards - if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") { - // Match any substring - if strings.Contains(strings.ToUpper(value), strings.ToUpper(strings.Trim(pattern, "*"))) { - return true - } - } else if strings.HasPrefix(pattern, "*") { - // Match suffix - if strings.HasSuffix(strings.ToUpper(value), strings.ToUpper(strings.TrimPrefix(pattern, "*"))) { - return true - } - } else if strings.HasSuffix(pattern, "*") { - // Match prefix - if strings.HasPrefix(strings.ToUpper(value), strings.ToUpper(strings.TrimSuffix(pattern, "*"))) { - return true - } - } else { - // Exact match - if strings.EqualFold(value, pattern) { - return true - } + upperValue := strings.ToUpper(value) + + if matchPattern(upperValue, pattern) { + return true } } } + return false } + +func matchPattern(upperValue, pattern string) bool { + upperPattern := strings.ToUpper(pattern) + + switch { + case strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*"): + // Match any substring + trimmed := strings.Trim(upperPattern, "*") + + return strings.Contains(upperValue, trimmed) + case strings.HasPrefix(pattern, "*"): + // Match suffix + return strings.HasSuffix(upperValue, strings.TrimPrefix(upperPattern, "*")) + case strings.HasSuffix(pattern, "*"): + // Match prefix + return strings.HasPrefix(upperValue, strings.TrimSuffix(upperPattern, "*")) + default: + // Exact match + return strings.EqualFold(upperValue, pattern) + } +} diff --git a/internal/etl/transform/transform_test.go b/internal/etl/transform/transform_test.go index f1815a4..42c6ef2 100644 --- a/internal/etl/transform/transform_test.go +++ b/internal/etl/transform/transform_test.go @@ -6,6 +6,7 @@ import ( "github.com/dirc/expenses/internal/models" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMatchesSearchValues(t *testing.T) { @@ -34,7 +35,7 @@ func TestMatchesSearchValues(t *testing.T) { "omschrijving": {"Bakkerij*"}, })) assert.False(t, matchesSearchValues(tx, map[string][]string{ - "omschrijving": {"*Supermarkt*"}, + "omschrijving": {"*Supermarkt*"}, //nolint:misspell })) // Test case insensitivity @@ -85,28 +86,29 @@ func TestEnrichTransactions(t *testing.T) { func TestLoadTransactionTypes(t *testing.T) { // Create a temporary YAML file for testing yamlContent := ` -TransactionTypes: +transactionTypes: - type: Boodschappen - SearchValues: + searchValues: naamTegenrekening: ["PANIC"] omschrijving: ["*Bakkerij*"] - type: Auto - SearchValues: + searchValues: omschrijving: ["*Auto-Veer*"] ` - tmpFile, err := os.CreateTemp("", "test*.yaml") - assert.NoError(t, err) + tmpFile, err := os.CreateTemp(t.TempDir(), "test*.yaml") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(yamlContent) - assert.NoError(t, err) + require.NoError(t, err) err = tmpFile.Close() - assert.NoError(t, err) + require.NoError(t, err) types, err := LoadTransactionTypes(tmpFile.Name()) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, types, 2) assert.Equal(t, "Boodschappen", types[0].Type) assert.Equal(t, "Auto", types[1].Type) diff --git a/internal/models/models.go b/internal/models/models.go index 4319759..d2dd3ff 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,7 +1,9 @@ +// Package models for all models. package models import "time" +// Transaction of a bank account with added column: TransactionType. type Transaction struct { Boekdatum time.Time `csv:"Boekdatum"` Rekeningnummer string `csv:"Rekeningnummer"` @@ -15,11 +17,13 @@ type Transaction struct { TransactionType string // Will be set during transform } +// TransactionTypeConfig is a array of transactionTypes. type TransactionTypeConfig struct { - TransactionTypes []TransactionType `yaml:"TransactionTypes"` + TransactionTypes []TransactionType `yaml:"transactionTypes"` } +// TransactionType has a name and one or more search values so it can be matched on a transaction column. type TransactionType struct { Type string `yaml:"type"` - SearchValues map[string][]string `yaml:"SearchValues"` + SearchValues map[string][]string `yaml:"searchValues"` } diff --git a/internal/reports/reports.go b/internal/reports/reports.go index a36b587..ec816a0 100644 --- a/internal/reports/reports.go +++ b/internal/reports/reports.go @@ -1,6 +1,8 @@ +// Package reports generate reports of all transactions. package reports import ( + "errors" "fmt" "strconv" "strings" @@ -10,7 +12,7 @@ import ( ) // GeneratePeriodicReport generates a report for the last N periods (months/years). -// durationStr: e.g., "3m" for 3 months, "4y" for 4 years +// durationStr: e.g., "3m" for 3 months, "4y" for 4 years. func GeneratePeriodicReport( transactions []models.Transaction, durationStr string, @@ -18,13 +20,15 @@ func GeneratePeriodicReport( // Parse duration string (e.g., "3m" or "4y") duration, err := strconv.Atoi(durationStr[:len(durationStr)-1]) if err != nil { - return nil, fmt.Errorf("invalid duration: %v", err) + return nil, fmt.Errorf("invalid duration: %w", err) } isMonth := strings.HasSuffix(durationStr, "m") + isYear := strings.HasSuffix(durationStr, "y") + if !isMonth && !isYear { - return nil, fmt.Errorf("invalid duration format, use e.g. '3m' or '4y'") + return nil, errors.New("invalid duration format: use e.g. '3m' or '4y'") } now := time.Now() @@ -51,7 +55,9 @@ func GeneratePeriodicReport( } else { periodKey = tx.Boekdatum.Format("2006") } + txType := tx.TransactionType + if txType == "" { txType = "Untyped" } @@ -59,6 +65,7 @@ func GeneratePeriodicReport( if _, ok := report[periodKey]; !ok { report[periodKey] = make(map[string]float64) } + report[periodKey][txType] += tx.Bedrag } @@ -68,24 +75,33 @@ func GeneratePeriodicReport( // PrintPeriodicReport prints the report in a readable format. func PrintPeriodicReport(report map[string]map[string]float64, title string) { fmt.Printf("\n=== %s ===\n", title) + for period, types := range report { fmt.Printf("\nPeriod: %s\n", period) + for txType, amount := range types { fmt.Printf(" %s: €%.2f\n", txType, amount) } } } -// Filter and print transactions without a type +// GenerateUntypedReport prints transactions without a type. func GenerateUntypedReport(transactions []models.Transaction) { fmt.Println("Untyped Transactions:") hasUntyped := false + for _, tx := range transactions { if tx.TransactionType == "" { hasUntyped = true - fmt.Printf("Boekdatum: %s, NaamTegenrekening: %s, Tegenrekening: %s, Omschrijving: %s\n", - tx.Boekdatum.Format("2006-01-02"), tx.NaamTegenrekening, tx.Tegenrekening, tx.Omschrijving) + + fmt.Printf( + "Boekdatum: %s, NaamTegenrekening: %s, "+ + "Tegenrekening: %s, Omschrijving: %s\n", + tx.Boekdatum.Format("2006-01-02"), + tx.NaamTegenrekening, + tx.Tegenrekening, + tx.Omschrijving) } } diff --git a/internal/reports/reports_test.go b/internal/reports/reports_test.go index aeeaf65..0fc997a 100644 --- a/internal/reports/reports_test.go +++ b/internal/reports/reports_test.go @@ -1,12 +1,12 @@ package reports import ( - "bytes" "testing" "time" "github.com/dirc/expenses/internal/models" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGenerateReport(t *testing.T) { @@ -37,44 +37,45 @@ func TestGenerateReport(t *testing.T) { t.Run("Monthly Report", func(t *testing.T) { report, err := GeneratePeriodicReport(transactions, "3m") - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, report, 3) // Only 3 periods within last 3 months // Check amounts for period, types := range report { if period == now.AddDate(0, -1, 0).Format("2006-01") { - assert.Equal(t, 100.00, types["Boodschappen"]) + assert.InEpsilon(t, 100.00, types["Boodschappen"], 0.001) } + if period == now.AddDate(0, 0, 0).Format("2006-01") { - assert.Equal(t, 400.00, types["Untyped"]) + assert.InEpsilon(t, 400.00, types["Untyped"], 0.001) } } }) t.Run("Yearly Report", func(t *testing.T) { report, err := GeneratePeriodicReport(transactions, "1y") - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, report, 1) // All transactions within last year // Check amounts for period, types := range report { if period == now.Format("2006") { - assert.Equal(t, 400.00, types["Boodschappen"]) - assert.Equal(t, 200.00, types["Auto"]) - assert.Equal(t, 400.00, types["Untyped"]) + assert.InEpsilon(t, 400.00, types["Boodschappen"], 0.001) + assert.InEpsilon(t, 200.00, types["Auto"], 0.001) + assert.InEpsilon(t, 400.00, types["Untyped"], 0.001) } } }) t.Run("Invalid Duration", func(t *testing.T) { _, err := GeneratePeriodicReport(transactions, "3x") - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), "invalid duration format") }) t.Run("Empty Transactions", func(t *testing.T) { report, err := GeneratePeriodicReport([]models.Transaction{}, "3m") - assert.NoError(t, err) + require.NoError(t, err) assert.Empty(t, report) }) } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index d4b585b..a8b5cc3 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -1 +1,2 @@ -package utils +// Package utils for all general helper functions. +package utils //nolint:revive diff --git a/testdata/transactiontypes.yaml b/testdata/transactiontypes.yaml index 25a6db1..6455b76 100644 --- a/testdata/transactiontypes.yaml +++ b/testdata/transactiontypes.yaml @@ -1,27 +1,27 @@ --- -TransactionTypes: +transactionTypes: - type: Boodschappen - SearchValues: + searchValues: naamTegenrekening: ["PANIC"] tegenrekening: ["nog geen tegenrekening"] omschrijving: ["Bakkerij*", "NICO*"] - type: Auto - SearchValues: + searchValues: naamTegenrekening: ["Parkeren*"] tegenrekening: ["none"] omschrijving: ["*Auto-Veer*"] - type: Spaarrek - SearchValues: + searchValues: naamTegenrekening: ["Dirc"] tegenrekening: ["none"] omschrijving: ["none"] - type: GasWaterLicht - SearchValues: + searchValues: naamTegenrekening: ["ENERGIE SERVICES"] tegenrekening: ["none"] omschrijving: ["none"] - type: Overig - SearchValues: + searchValues: naamTegenrekening: ["M Grupetto"] tegenrekening: ["none"] omschrijving: ["none"]