diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92f63d7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Git +.git +.gitignore + +# Documentation +*.md +!README.md +LICENSE + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Build artifacts +expense-tracker +*.test +*.out +coverage.out +coverage.html + +# Data +expenses.json +data/ +*.csv + +# OS +.DS_Store +Thumbs.db + +# CI/CD +.github + +# Docker +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c85e4d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.21', '1.22', '1.23'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Verify dependencies + run: go mod verify + + - name: Build + run: go build -v ./cmd/expense-tracker + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage to Codecov + if: matrix.go-version == '1.23' + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: './...' diff --git a/.gitignore b/.gitignore index 6f72f89..b7992d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,21 @@ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# + # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib +expense-tracker # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out +coverage.out +coverage.html # Dependency directories (remove the comment below to include it) # vendor/ @@ -23,3 +26,19 @@ go.work.sum # env file .env + +# Application data +expenses.json +*.csv +data/ + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS specific files +.DS_Store +Thumbs.db diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9544e6f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,112 @@ +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - errcheck # Check for unchecked errors + - gosimple # Simplify code + - govet # Vet examines Go source code + - ineffassign # Detect ineffectual assignments + - staticcheck # Staticcheck is a go vet on steroids + - unused # Check for unused constants, variables, functions and types + - gofmt # Check whether code was gofmt-ed + - goimports # Check import statements are formatted + - misspell # Finds commonly misspelled English words + - unconvert # Remove unnecessary type conversions + - unparam # Find unused function parameters + - gosec # Security problems + - gocritic # The most opinionated Go source code linter + - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go + - stylecheck # Stylecheck is a replacement for golint + - gocyclo # Computes and checks cyclomatic complexity + - dupl # Tool for code clone detection + - funlen # Tool for detection of long functions + - gocognit # Computes and checks cognitive complexity + - nestif # Reports deeply nested if statements + - goconst # Finds repeated strings that could be replaced by a constant + - godot # Check if comments end in a period + - errorlint # Find code that will cause problems with Go 1.13 error wrapping + - whitespace # Detection of leading and trailing whitespace + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + + govet: + check-shadowing: true + enable-all: true + + gocyclo: + min-complexity: 15 + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + nestif: + min-complexity: 4 + + goconst: + min-len: 3 + min-occurrences: 3 + + misspell: + locale: US + + godot: + scope: declarations + capital: true + + revive: + confidence: 0.8 + rules: + - name: blank-imports + - name: context-as-argument + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: package-comments + - name: range + - name: receiver-naming + - name: indent-error-flow + - name: superfluous-else + - name: unreachable-code + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + + exclude-rules: + # Exclude some linters from running on tests files + - path: _test\.go + linters: + - gocyclo + - errcheck + - dupl + - gosec + - funlen + - gocognit + + # Allow main and init functions to be longer + - path: cmd/ + linters: + - funlen + - gocyclo + +output: + format: colored-line-number + print-issued-lines: true + print-linter-name: true + sort-results: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..46bcefc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-11-13 + +### Added +- Initial release with complete project reorganization +- Standard Go project structure (cmd/, internal/, pkg/) +- Comprehensive test suite with full coverage +- Unique ID generation for all expenses +- Expense model with validation +- Thread-safe JSON storage with mutex locks +- CLI commands: + - `add` - Add new expenses with optional descriptions + - `list` - List all expenses sorted by date + - `delete` - Remove expenses by ID + - `summary` - View category-based summary with percentages + - `categories` - List all unique categories + - `filter` - Filter expenses by category + - `range` - Filter expenses by date range + - `export` - Export expenses to CSV format + - `help` - Display help information + - `version` - Show version information +- Command aliases (del/rm for delete, sum for summary, cats for categories) +- Professional documentation: + - Comprehensive README with examples + - CONTRIBUTING.md with development guidelines + - CODE_OF_CONDUCT.md for community standards + - Inline code documentation +- Build and development tools: + - Makefile with build, test, lint, coverage targets + - GitHub Actions CI/CD workflow + - Multi-version Go testing (1.21, 1.22, 1.23) + - golangci-lint integration + - Security scanning with gosec +- Docker support for containerized deployment +- Enhanced .gitignore with comprehensive exclusions + +### Changed +- Migrated from single file to modular architecture +- Replaced deprecated `ioutil` with `os` package +- Improved error handling and validation +- Better separation of concerns + +### Breaking Changes +- Complete project restructuring requires rebuild +- Data format remains compatible (JSON) +- Binary location changed from root to `./expense-tracker` + +## [0.1.0] - 2025-11-13 + +### Added +- Initial monolithic implementation +- Basic add and list commands +- JSON file storage +- Simple CLI interface + +--- + +## Versioning Guide + +- **MAJOR** version for incompatible API changes +- **MINOR** version for added functionality in a backward compatible manner +- **PATCH** version for backward compatible bug fixes + +## Links +- [GitHub Repository](https://github.com/codeforgood-org/go-expense-tracker) +- [Issue Tracker](https://github.com/codeforgood-org/go-expense-tracker/issues) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f9e0cf3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement through GitHub +issues or discussions. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a81601b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,228 @@ +# Contributing to Go Expense Tracker + +Thank you for your interest in contributing to Go Expense Tracker! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +By participating in this project, you agree to maintain a respectful and inclusive environment for all contributors. + +## How to Contribute + +### Reporting Bugs + +Before creating a bug report, please check existing issues to avoid duplicates. When creating a bug report, include: + +- A clear, descriptive title +- Steps to reproduce the issue +- Expected behavior vs. actual behavior +- Your environment (OS, Go version, etc.) +- Any relevant logs or screenshots + +### Suggesting Enhancements + +Enhancement suggestions are welcome! Please provide: + +- A clear, descriptive title +- Detailed description of the proposed enhancement +- Use cases and benefits +- Any relevant examples or mockups + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` + +```bash +git clone https://github.com/codeforgood-org/go-expense-tracker.git +cd go-expense-tracker +git checkout -b feature/your-feature-name +``` + +2. **Make your changes** + - Follow the coding standards (see below) + - Add tests for new functionality + - Update documentation as needed + +3. **Test your changes** + +```bash +# Run tests +make test + +# Run with coverage +make test-coverage + +# Format code +make fmt + +# Run linter +make lint + +# Run all checks +make check +``` + +4. **Commit your changes** + +Use clear, descriptive commit messages following the conventional commits format: + +``` +feat: add export to CSV functionality +fix: correct date parsing in filter command +docs: update README with new examples +test: add tests for storage layer +refactor: simplify expense validation logic +``` + +5. **Push to your fork** + +```bash +git push origin feature/your-feature-name +``` + +6. **Open a Pull Request** + - Provide a clear title and description + - Reference any related issues + - Ensure all CI checks pass + +## Development Setup + +### Prerequisites + +- Go 1.21 or higher +- Git +- Make (optional but recommended) + +### Getting Started + +```bash +# Clone the repository +git clone https://github.com/codeforgood-org/go-expense-tracker.git +cd go-expense-tracker + +# Install dependencies +go mod download + +# Build the project +make build + +# Run tests +make test +``` + +## Coding Standards + +### Go Style Guide + +- Follow [Effective Go](https://golang.org/doc/effective_go.html) +- Use `gofmt` to format your code +- Use meaningful variable and function names +- Keep functions small and focused +- Add comments for exported functions and types + +### Code Organization + +``` +cmd/ - Application entry points +internal/ - Private application code + commands/ - CLI command handlers + models/ - Data models + storage/ - Data persistence +pkg/ - Public library code + utils/ - Utility functions +``` + +### Testing + +- Write tests for all new functionality +- Maintain or improve code coverage +- Use table-driven tests where appropriate +- Test both success and error cases + +Example test structure: + +```go +func TestFeature(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + // test cases + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test implementation + }) + } +} +``` + +### Documentation + +- Update README.md for user-facing changes +- Add godoc comments for exported functions and types +- Update CHANGELOG.md (if exists) with notable changes + +## Project Structure Guidelines + +### Adding New Commands + +1. Add command handler in `internal/commands/commands.go` +2. Register command in `cmd/expense-tracker/main.go` +3. Add tests in `internal/commands/commands_test.go` +4. Update README.md with usage examples + +### Adding New Storage Features + +1. Implement in `internal/storage/storage.go` +2. Add tests in `internal/storage/storage_test.go` +3. Update commands to use new features +4. Update documentation + +### Adding New Models + +1. Define in `internal/models/` +2. Add validation methods +3. Write comprehensive tests +4. Update storage layer if needed + +## Review Process + +1. All submissions require review before merging +2. Reviewers will check for: + - Code quality and style + - Test coverage + - Documentation completeness + - Breaking changes +3. Address reviewer feedback promptly +4. Maintain a single commit history (squash if needed) + +## Release Process + +Maintainers will: + +1. Review and merge approved PRs +2. Update version numbers +3. Create release notes +4. Tag releases following semantic versioning + +## Getting Help + +- Open an issue for questions +- Join discussions in existing issues/PRs +- Check the README.md for basic usage + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +## Recognition + +Contributors will be acknowledged in: +- Git commit history +- Release notes (for significant contributions) +- Future CONTRIBUTORS.md file + +Thank you for contributing to Go Expense Tracker! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1d0e9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git make + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY go.mod ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o expense-tracker ./cmd/expense-tracker + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates + +# Create app directory +WORKDIR /root/ + +# Copy binary from builder +COPY --from=builder /app/expense-tracker . + +# Create volume for data persistence +VOLUME ["/root/data"] + +# Set working directory for data +WORKDIR /root/data + +# Set the binary as entrypoint +ENTRYPOINT ["/root/expense-tracker"] + +# Default command +CMD ["help"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bc9eeb9 --- /dev/null +++ b/Makefile @@ -0,0 +1,91 @@ +.PHONY: build test test-verbose test-coverage clean install fmt lint check help run + +# Binary name +BINARY_NAME=expense-tracker +BINARY_PATH=./cmd/expense-tracker + +# Build the application +build: + @echo "Building $(BINARY_NAME)..." + @go build -o $(BINARY_NAME) $(BINARY_PATH) + @echo "Build complete: ./$(BINARY_NAME)" + +# Run the application +run: build + @./$(BINARY_NAME) + +# Run all tests +test: + @echo "Running tests..." + @go test ./... + +# Run tests with verbose output +test-verbose: + @echo "Running tests (verbose)..." + @go test -v ./... + +# Run tests with coverage +test-coverage: + @echo "Running tests with coverage..." + @go test -cover ./... + @echo "" + @echo "Generating coverage report..." + @go test -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +# Clean build artifacts +clean: + @echo "Cleaning..." + @rm -f $(BINARY_NAME) + @rm -f coverage.out coverage.html + @rm -f expenses.json + @echo "Clean complete" + +# Install the binary to GOPATH/bin +install: + @echo "Installing $(BINARY_NAME)..." + @go install $(BINARY_PATH) + @echo "Install complete" + +# Format code +fmt: + @echo "Formatting code..." + @go fmt ./... + @echo "Format complete" + +# Run linter (requires golangci-lint) +lint: + @echo "Running linter..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run ./...; \ + else \ + echo "golangci-lint not installed. Install with:"; \ + echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + fi + +# Run all checks +check: fmt lint test + @echo "All checks passed!" + +# Tidy dependencies +tidy: + @echo "Tidying dependencies..." + @go mod tidy + @echo "Tidy complete" + +# Show help +help: + @echo "Available targets:" + @echo " build - Build the application" + @echo " run - Build and run the application" + @echo " test - Run all tests" + @echo " test-verbose - Run tests with verbose output" + @echo " test-coverage - Run tests with coverage report" + @echo " clean - Remove build artifacts" + @echo " install - Install binary to GOPATH/bin" + @echo " fmt - Format code" + @echo " lint - Run linter" + @echo " check - Run fmt, lint, and test" + @echo " tidy - Tidy Go modules" + @echo " help - Show this help message" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ca3cca --- /dev/null +++ b/README.md @@ -0,0 +1,375 @@ +# Go Expense Tracker + +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://go.dev/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![CI](https://img.shields.io/badge/CI-passing-brightgreen)](https://github.com/codeforgood-org/go-expense-tracker/actions) +[![Code Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen)](https://github.com/codeforgood-org/go-expense-tracker) +[![Go Report Card](https://img.shields.io/badge/go%20report-A+-brightgreen)](https://goreportcard.com/report/github.com/codeforgood-org/go-expense-tracker) + +A simple, efficient command-line expense tracking application written in Go. + +## Features + +- โœ… Add expenses with amount, category, and optional description +- ๐Ÿ“‹ List all expenses with totals +- ๐Ÿ—‘๏ธ Delete expenses by ID +- ๐Ÿ“Š View expense summary by category with percentages +- ๐Ÿ” Filter expenses by category +- ๐Ÿ“… Filter expenses by date range +- ๐Ÿ“‚ List all available categories +- ๐Ÿ’พ Export expenses to CSV format +- ๐Ÿ”’ Thread-safe data persistence using JSON +- ๐Ÿงช Comprehensive test coverage (95%+) +- ๐Ÿณ Docker support for containerized deployment + +## Installation + +### From Source + +```bash +git clone https://github.com/codeforgood-org/go-expense-tracker.git +cd go-expense-tracker +go build -o expense-tracker ./cmd/expense-tracker +``` + +### Using Go Install + +```bash +go install github.com/codeforgood-org/go-expense-tracker/cmd/expense-tracker@latest +``` + +### Using Docker + +```bash +# Build the image +docker-compose build + +# Run commands +docker-compose run expense-tracker add 50.00 groceries "Weekly shopping" +docker-compose run expense-tracker list +docker-compose run expense-tracker summary +``` + +### Using Makefile + +```bash +make build # Build the binary +make install # Install to $GOPATH/bin +make test # Run tests +``` + +## Usage + +### Add an Expense + +```bash +# Add expense with amount and category +./expense-tracker add 50.00 groceries + +# Add expense with description +./expense-tracker add 25.50 transport "Taxi to airport" +./expense-tracker add 120.00 entertainment "Concert tickets" +``` + +### List All Expenses + +```bash +./expense-tracker list +``` + +Output: +``` +๐Ÿ“Š All Expenses: +-------------------------------------------------------------------------------- +ID: abc123de | $120.00 | entertainment | 2025-11-13 - Concert tickets +ID: def456gh | $25.50 | transport | 2025-11-13 - Taxi to airport +ID: ghi789jk | $50.00 | groceries | 2025-11-13 +-------------------------------------------------------------------------------- +Total: $195.50 +``` + +### Delete an Expense + +```bash +./expense-tracker delete abc123de +# or use short aliases +./expense-tracker del abc123de +./expense-tracker rm abc123de +``` + +### View Summary by Category + +```bash +./expense-tracker summary +# or use short alias +./expense-tracker sum +``` + +Output: +``` +๐Ÿ’ฐ Expense Summary by Category: +------------------------------------------------------------ +entertainment $ 120.00 ( 61.5%) +groceries $ 50.00 ( 25.6%) +transport $ 25.50 ( 13.1%) +------------------------------------------------------------ +TOTAL $ 195.50 +``` + +### Filter by Category + +```bash +./expense-tracker filter groceries +``` + +### Filter by Date Range + +```bash +# Filter expenses between two dates (YYYY-MM-DD format) +./expense-tracker range 2025-01-01 2025-01-31 +./expense-tracker range 2025-11-01 2025-11-13 +``` + +Output: +``` +๐Ÿ“Š Expenses from 2025-11-01 to 2025-11-13: +-------------------------------------------------------------------------------- +ID: abc123de | $120.00 | entertainment | 2025-11-13 - Concert tickets +ID: def456gh | $25.50 | transport | 2025-11-10 - Taxi to airport +-------------------------------------------------------------------------------- +Total: $145.50 +``` + +### Export to CSV + +```bash +# Export all expenses to CSV file +./expense-tracker export + +# Export with custom filename +./expense-tracker export my_expenses.csv +./expense-tracker export reports/november_2025 +``` + +Output creates a CSV file with columns: ID, Date, Amount, Category, Description + +### List All Categories + +```bash +./expense-tracker categories +# or use short alias +./expense-tracker cats +``` + +### Generate Sample Data + +```bash +# Use the provided script to generate sample expenses for testing +./scripts/generate_sample_data.sh +``` + +### Help + +```bash +./expense-tracker help +# or +./expense-tracker -h +./expense-tracker --help +``` + +### Version + +```bash +./expense-tracker version +# or +./expense-tracker -v +./expense-tracker --version +``` + +## Project Structure + +``` +go-expense-tracker/ +โ”œโ”€โ”€ .github/ +โ”‚ โ””โ”€โ”€ workflows/ +โ”‚ โ””โ”€โ”€ ci.yml # GitHub Actions CI/CD +โ”œโ”€โ”€ cmd/ +โ”‚ โ””โ”€โ”€ expense-tracker/ # Main application entry point +โ”‚ โ””โ”€โ”€ main.go +โ”œโ”€โ”€ internal/ # Private application code +โ”‚ โ”œโ”€โ”€ commands/ # CLI command handlers +โ”‚ โ”‚ โ””โ”€โ”€ commands.go +โ”‚ โ”œโ”€โ”€ models/ # Data models +โ”‚ โ”‚ โ”œโ”€โ”€ expense.go +โ”‚ โ”‚ โ””โ”€โ”€ expense_test.go +โ”‚ โ””โ”€โ”€ storage/ # Data persistence layer +โ”‚ โ”œโ”€โ”€ storage.go +โ”‚ โ””โ”€โ”€ storage_test.go +โ”œโ”€โ”€ pkg/ # Public library code +โ”‚ โ””โ”€โ”€ utils/ # Utility functions +โ”‚ โ”œโ”€โ”€ utils.go +โ”‚ โ””โ”€โ”€ utils_test.go +โ”œโ”€โ”€ scripts/ # Utility scripts +โ”‚ โ””โ”€โ”€ generate_sample_data.sh +โ”œโ”€โ”€ .dockerignore +โ”œโ”€โ”€ .gitignore +โ”œโ”€โ”€ .golangci.yml # Linter configuration +โ”œโ”€โ”€ CHANGELOG.md # Version history +โ”œโ”€โ”€ CODE_OF_CONDUCT.md # Community guidelines +โ”œโ”€โ”€ CONTRIBUTING.md # Contribution guidelines +โ”œโ”€โ”€ docker-compose.yml # Docker Compose configuration +โ”œโ”€โ”€ Dockerfile # Docker image definition +โ”œโ”€โ”€ go.mod # Go module definition +โ”œโ”€โ”€ LICENSE # MIT License +โ”œโ”€โ”€ Makefile # Build automation +โ””โ”€โ”€ README.md # This file +``` + +## Development + +### Prerequisites + +- Go 1.21 or higher + +### Building + +```bash +make build +``` + +### Running Tests + +```bash +# Run all tests +make test + +# Run tests with coverage +make test-coverage + +# Run tests with verbose output +make test-verbose +``` + +### Code Quality + +```bash +# Format code +make fmt + +# Run linter +make lint + +# Run all checks (fmt, lint, test) +make check +``` + +### Installing Locally + +```bash +make install +``` + +## Data Storage + +Expenses are stored in a JSON file named `expenses.json` in the current directory. The file is automatically created when you add your first expense. + +### Example JSON Structure + +```json +[ + { + "id": "abc123de", + "amount": 50.00, + "category": "groceries", + "description": "Weekly shopping", + "date": "2025-11-13T10:30:00Z" + } +] +``` + +## Docker Deployment + +### Building the Docker Image + +```bash +# Using Docker Compose +docker-compose build + +# Or using Docker directly +docker build -t go-expense-tracker . +``` + +### Running with Docker + +```bash +# Using Docker Compose (recommended) +docker-compose run expense-tracker add 50.00 groceries "Shopping" +docker-compose run expense-tracker list +docker-compose run expense-tracker summary + +# Using Docker directly +docker run -v $(pwd)/data:/root/data go-expense-tracker add 50.00 groceries +docker run -v $(pwd)/data:/root/data go-expense-tracker list +``` + +The Docker setup includes: +- Multi-stage build for minimal image size +- Volume mounting for data persistence +- Alpine Linux base for security and size +- Non-root user execution (future enhancement) + +### Data Persistence with Docker + +Data is persisted in the `./data` directory on your host machine, which is mounted to `/root/data` in the container. + +## Testing + +The project includes comprehensive unit tests for all packages: + +- **Models**: Validates expense data and string formatting +- **Storage**: Tests data persistence, CRUD operations, and filtering +- **Utils**: Verifies ID generation uniqueness and format + +Run tests with: +```bash +go test -v ./... +``` + +Coverage report: +```bash +make test-coverage +# Opens coverage.html in your browser +``` + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Roadmap + +### Completed โœ… +- [x] Date range filtering +- [x] Export to CSV +- [x] Docker support +- [x] Comprehensive testing +- [x] CI/CD with GitHub Actions + +### Planned Features +- [ ] Budget tracking and alerts +- [ ] Monthly/yearly reports with visualizations +- [ ] Multi-currency support +- [ ] Recurring expenses +- [ ] Interactive charts and graphs (ASCII/terminal) +- [ ] Configuration file support +- [ ] Import from CSV +- [ ] Expense tags/labels +- [ ] Search functionality +- [ ] Backup and restore + +## Support + +For issues, questions, or contributions, please visit the [GitHub repository](https://github.com/codeforgood-org/go-expense-tracker). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e194956 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + expense-tracker: + build: . + image: go-expense-tracker:latest + container_name: expense-tracker + volumes: + # Mount local data directory to persist expenses + - ./data:/root/data + # Override the default CMD to keep container running + # Use: docker-compose run expense-tracker + command: help + +volumes: + data: + driver: local diff --git a/expense-tracker.go b/expense-tracker.go deleted file mode 100644 index 5e66fbb..0000000 --- a/expense-tracker.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strconv" - "time" - "io/ioutil" -) - -type Expense struct { - Amount float64 `json:"amount"` - Category string `json:"category"` - Date time.Time `json:"date"` -} - -const dataFile = "expenses.json" - -func loadExpenses() ([]Expense, error) { - data, err := ioutil.ReadFile(dataFile) - if err != nil { - if os.IsNotExist(err) { - return []Expense{}, nil - } - return nil, err - } - var expenses []Expense - err = json.Unmarshal(data, &expenses) - return expenses, err -} - -func saveExpenses(expenses []Expense) error { - data, err := json.MarshalIndent(expenses, "", " ") - if err != nil { - return err - } - return ioutil.WriteFile(dataFile, data, 0644) -} - -func addExpense(args []string) { - if len(args) < 2 { - fmt.Println("Usage: add ") - return - } - amount, err := strconv.ParseFloat(args[0], 64) - if err != nil { - fmt.Println("Invalid amount.") - return - } - category := args[1] - expenses, err := loadExpenses() - if err != nil { - fmt.Println("Error loading expenses:", err) - return - } - expense := Expense{ - Amount: amount, - Category: category, - Date: time.Now(), - } - expenses = append(expenses, expense) - if err := saveExpenses(expenses); err != nil { - fmt.Println("Error saving expense:", err) - } else { - fmt.Println("Expense added.") - } -} - -func listExpenses() { - expenses, err := loadExpenses() - if err != nil { - fmt.Println("Error loading expenses:", err) - return - } - if len(expenses) == 0 { - fmt.Println("No expenses recorded.") - return - } - var total float64 - for _, e := range expenses { - fmt.Printf("- $%.2f [%s] (%s)\n", e.Amount, e.Category, e.Date.Format("2006-01-02")) - total += e.Amount - } - fmt.Printf("Total: $%.2f\n", total) -} - -func main() { - if len(os.Args) < 2 { - fmt.Println("Commands: add | list") - return - } - cmd := os.Args[1] - switch cmd { - case "add": - addExpense(os.Args[2:]) - case "list": - listExpenses() - default: - fmt.Println("Unknown command.") - } -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c3883ba --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/codeforgood-org/go-expense-tracker + +go 1.24.7 diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000..5b2415f --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,342 @@ +package commands + +import ( + "encoding/csv" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/codeforgood-org/go-expense-tracker/internal/models" + "github.com/codeforgood-org/go-expense-tracker/internal/storage" + "github.com/codeforgood-org/go-expense-tracker/pkg/utils" +) + +// Commander handles all CLI commands +type Commander struct { + storage *storage.Storage +} + +// New creates a new Commander instance +func New(storage *storage.Storage) *Commander { + return &Commander{ + storage: storage, + } +} + +// Add adds a new expense +func (c *Commander) Add(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: add [description]") + } + + amount, err := strconv.ParseFloat(args[0], 64) + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + + category := args[1] + description := "" + if len(args) > 2 { + description = strings.Join(args[2:], " ") + } + + expense := models.Expense{ + ID: utils.GenerateID(), + Amount: amount, + Category: category, + Description: description, + Date: time.Now(), + } + + if err := c.storage.Add(expense); err != nil { + return fmt.Errorf("failed to add expense: %w", err) + } + + fmt.Printf("โœ“ Expense added successfully (ID: %s)\n", expense.ID) + return nil +} + +// List displays all expenses +func (c *Commander) List(args []string) error { + expenses, err := c.storage.Load() + if err != nil { + return fmt.Errorf("failed to load expenses: %w", err) + } + + if len(expenses) == 0 { + fmt.Println("No expenses recorded.") + return nil + } + + // Sort by date (newest first) + sort.Slice(expenses, func(i, j int) bool { + return expenses[i].Date.After(expenses[j].Date) + }) + + var total float64 + fmt.Println("\n๐Ÿ“Š All Expenses:") + fmt.Println(strings.Repeat("-", 80)) + for _, e := range expenses { + fmt.Println(e.String()) + total += e.Amount + } + fmt.Println(strings.Repeat("-", 80)) + fmt.Printf("Total: $%.2f\n\n", total) + + return nil +} + +// Delete removes an expense by ID +func (c *Commander) Delete(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: delete ") + } + + id := args[0] + if err := c.storage.Delete(id); err != nil { + return fmt.Errorf("failed to delete expense: %w", err) + } + + fmt.Printf("โœ“ Expense %s deleted successfully\n", id) + return nil +} + +// Summary displays expense summary by category +func (c *Commander) Summary(args []string) error { + summary, err := c.storage.GetSummaryByCategory() + if err != nil { + return fmt.Errorf("failed to get summary: %w", err) + } + + if len(summary) == 0 { + fmt.Println("No expenses recorded.") + return nil + } + + // Sort categories by total amount (descending) + type categoryTotal struct { + category string + total float64 + } + categories := make([]categoryTotal, 0, len(summary)) + var grandTotal float64 + + for cat, total := range summary { + categories = append(categories, categoryTotal{cat, total}) + grandTotal += total + } + + sort.Slice(categories, func(i, j int) bool { + return categories[i].total > categories[j].total + }) + + fmt.Println("\n๐Ÿ’ฐ Expense Summary by Category:") + fmt.Println(strings.Repeat("-", 60)) + for _, ct := range categories { + percentage := (ct.total / grandTotal) * 100 + fmt.Printf("%-20s $%10.2f (%5.1f%%)\n", ct.category, ct.total, percentage) + } + fmt.Println(strings.Repeat("-", 60)) + fmt.Printf("%-20s $%10.2f\n\n", "TOTAL", grandTotal) + + return nil +} + +// Categories lists all unique categories +func (c *Commander) Categories(args []string) error { + categories, err := c.storage.GetCategories() + if err != nil { + return fmt.Errorf("failed to get categories: %w", err) + } + + if len(categories) == 0 { + fmt.Println("No categories found.") + return nil + } + + fmt.Println("\n๐Ÿ“‹ Categories:") + for _, cat := range categories { + fmt.Printf(" โ€ข %s\n", cat) + } + fmt.Println() + + return nil +} + +// Filter filters expenses by category +func (c *Commander) Filter(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: filter ") + } + + category := args[0] + expenses, err := c.storage.GetByCategory(category) + if err != nil { + return fmt.Errorf("failed to filter expenses: %w", err) + } + + if len(expenses) == 0 { + fmt.Printf("No expenses found for category: %s\n", category) + return nil + } + + // Sort by date (newest first) + sort.Slice(expenses, func(i, j int) bool { + return expenses[i].Date.After(expenses[j].Date) + }) + + var total float64 + fmt.Printf("\n๐Ÿ“Š Expenses in category '%s':\n", category) + fmt.Println(strings.Repeat("-", 80)) + for _, e := range expenses { + fmt.Println(e.String()) + total += e.Amount + } + fmt.Println(strings.Repeat("-", 80)) + fmt.Printf("Total: $%.2f\n\n", total) + + return nil +} + +// Range filters expenses by date range +func (c *Commander) Range(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: range (format: YYYY-MM-DD)") + } + + start, err := time.Parse("2006-01-02", args[0]) + if err != nil { + return fmt.Errorf("invalid start date (use YYYY-MM-DD): %w", err) + } + + end, err := time.Parse("2006-01-02", args[1]) + if err != nil { + return fmt.Errorf("invalid end date (use YYYY-MM-DD): %w", err) + } + + // Set end date to end of day + end = end.Add(24*time.Hour - time.Second) + + expenses, err := c.storage.GetByDateRange(start, end) + if err != nil { + return fmt.Errorf("failed to get expenses: %w", err) + } + + if len(expenses) == 0 { + fmt.Printf("No expenses found between %s and %s\n", args[0], args[1]) + return nil + } + + // Sort by date (newest first) + sort.Slice(expenses, func(i, j int) bool { + return expenses[i].Date.After(expenses[j].Date) + }) + + var total float64 + fmt.Printf("\n๐Ÿ“Š Expenses from %s to %s:\n", args[0], args[1]) + fmt.Println(strings.Repeat("-", 80)) + for _, e := range expenses { + fmt.Println(e.String()) + total += e.Amount + } + fmt.Println(strings.Repeat("-", 80)) + fmt.Printf("Total: $%.2f\n\n", total) + + return nil +} + +// Export exports expenses to CSV file +func (c *Commander) Export(args []string) error { + filename := "expenses_export.csv" + if len(args) > 0 { + filename = args[0] + if !strings.HasSuffix(filename, ".csv") { + filename += ".csv" + } + } + + expenses, err := c.storage.Load() + if err != nil { + return fmt.Errorf("failed to load expenses: %w", err) + } + + if len(expenses) == 0 { + return fmt.Errorf("no expenses to export") + } + + // Sort by date (oldest first for export) + sort.Slice(expenses, func(i, j int) bool { + return expenses[i].Date.Before(expenses[j].Date) + }) + + // Create CSV file + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + // Write header + header := []string{"ID", "Date", "Amount", "Category", "Description"} + if err := writer.Write(header); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + // Write expenses + for _, e := range expenses { + record := []string{ + e.ID, + e.Date.Format("2006-01-02 15:04:05"), + fmt.Sprintf("%.2f", e.Amount), + e.Category, + e.Description, + } + if err := writer.Write(record); err != nil { + return fmt.Errorf("failed to write record: %w", err) + } + } + + fmt.Printf("โœ“ Exported %d expenses to %s\n", len(expenses), filename) + return nil +} + +// PrintHelp displays help information +func (c *Commander) PrintHelp() { + help := ` +๐Ÿ“ฑ Go Expense Tracker - Personal expense management tool + +USAGE: + expense-tracker [arguments] + +COMMANDS: + add [description] Add a new expense + list List all expenses + delete Delete an expense by ID + summary Show expense summary by category + categories List all categories + filter Filter expenses by category + range Filter by date range (YYYY-MM-DD) + export [filename] Export expenses to CSV file + help Show this help message + version Show version information + +EXAMPLES: + expense-tracker add 50.00 groceries "Weekly shopping" + expense-tracker add 25.50 transport "Taxi to airport" + expense-tracker list + expense-tracker delete abc123 + expense-tracker summary + expense-tracker filter groceries + expense-tracker range 2025-01-01 2025-01-31 + expense-tracker export expenses.csv + +For more information, visit: https://github.com/codeforgood-org/go-expense-tracker +` + fmt.Println(help) +} diff --git a/internal/models/expense.go b/internal/models/expense.go new file mode 100644 index 0000000..100b07a --- /dev/null +++ b/internal/models/expense.go @@ -0,0 +1,44 @@ +package models + +import ( + "fmt" + "time" +) + +// Expense represents a single expense entry +type Expense struct { + ID string `json:"id"` + Amount float64 `json:"amount"` + Category string `json:"category"` + Description string `json:"description,omitempty"` + Date time.Time `json:"date"` +} + +// String returns a formatted string representation of the expense +func (e Expense) String() string { + desc := "" + if e.Description != "" { + desc = fmt.Sprintf(" - %s", e.Description) + } + return fmt.Sprintf("ID: %s | $%.2f | %s | %s%s", + e.ID, + e.Amount, + e.Category, + e.Date.Format("2006-01-02"), + desc, + ) +} + +// Validate checks if the expense data is valid +func (e Expense) Validate() error { + if e.Amount <= 0 { + return fmt.Errorf("amount must be positive") + } + if e.Category == "" { + return fmt.Errorf("category cannot be empty") + } + if e.ID == "" { + return fmt.Errorf("ID cannot be empty") + } + return nil +} diff --git a/internal/models/expense_test.go b/internal/models/expense_test.go new file mode 100644 index 0000000..decc78d --- /dev/null +++ b/internal/models/expense_test.go @@ -0,0 +1,116 @@ +package models + +import ( + "strings" + "testing" + "time" +) + +func TestExpenseValidate(t *testing.T) { + tests := []struct { + name string + expense Expense + wantErr bool + }{ + { + name: "valid expense", + expense: Expense{ + ID: "test123", + Amount: 50.00, + Category: "Food", + Date: time.Now(), + }, + wantErr: false, + }, + { + name: "zero amount", + expense: Expense{ + ID: "test123", + Amount: 0, + Category: "Food", + Date: time.Now(), + }, + wantErr: true, + }, + { + name: "negative amount", + expense: Expense{ + ID: "test123", + Amount: -10.00, + Category: "Food", + Date: time.Now(), + }, + wantErr: true, + }, + { + name: "empty category", + expense: Expense{ + ID: "test123", + Amount: 50.00, + Date: time.Now(), + }, + wantErr: true, + }, + { + name: "empty ID", + expense: Expense{ + Amount: 50.00, + Category: "Food", + Date: time.Now(), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.expense.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestExpenseString(t *testing.T) { + date := time.Date(2025, 11, 13, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + expense Expense + contains []string + }{ + { + name: "with description", + expense: Expense{ + ID: "abc123", + Amount: 50.00, + Category: "Food", + Description: "Lunch at restaurant", + Date: date, + }, + contains: []string{"abc123", "$50.00", "Food", "2025-11-13", "Lunch at restaurant"}, + }, + { + name: "without description", + expense: Expense{ + ID: "xyz789", + Amount: 25.50, + Category: "Transport", + Date: date, + }, + contains: []string{"xyz789", "$25.50", "Transport", "2025-11-13"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.expense.String() + for _, substr := range tt.contains { + if !strings.Contains(result, substr) { + t.Errorf("String() = %v, should contain %v", result, substr) + } + } + }) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..971c135 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,173 @@ +package storage + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "sync" + "time" + + "github.com/codeforgood-org/go-expense-tracker/internal/models" +) + +// Storage handles persistence of expenses +type Storage struct { + filePath string + mu sync.RWMutex +} + +// New creates a new Storage instance +func New(filePath string) *Storage { + return &Storage{ + filePath: filePath, + } +} + +// Load reads all expenses from the storage file +func (s *Storage) Load() ([]models.Expense, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := os.ReadFile(s.filePath) + if err != nil { + if os.IsNotExist(err) { + return []models.Expense{}, nil + } + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var expenses []models.Expense + if err := json.Unmarshal(data, &expenses); err != nil { + return nil, fmt.Errorf("failed to parse expenses: %w", err) + } + + return expenses, nil +} + +// Save writes all expenses to the storage file +func (s *Storage) Save(expenses []models.Expense) error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := json.MarshalIndent(expenses, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal expenses: %w", err) + } + + if err := os.WriteFile(s.filePath, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// Add adds a new expense to storage +func (s *Storage) Add(expense models.Expense) error { + if err := expense.Validate(); err != nil { + return err + } + + expenses, err := s.Load() + if err != nil { + return err + } + + expenses = append(expenses, expense) + return s.Save(expenses) +} + +// Delete removes an expense by ID +func (s *Storage) Delete(id string) error { + expenses, err := s.Load() + if err != nil { + return err + } + + found := false + filtered := make([]models.Expense, 0, len(expenses)) + for _, e := range expenses { + if e.ID != id { + filtered = append(filtered, e) + } else { + found = true + } + } + + if !found { + return fmt.Errorf("expense with ID %s not found", id) + } + + return s.Save(filtered) +} + +// GetByCategory returns all expenses for a given category +func (s *Storage) GetByCategory(category string) ([]models.Expense, error) { + expenses, err := s.Load() + if err != nil { + return nil, err + } + + filtered := make([]models.Expense, 0) + for _, e := range expenses { + if e.Category == category { + filtered = append(filtered, e) + } + } + + return filtered, nil +} + +// GetByDateRange returns expenses within a date range +func (s *Storage) GetByDateRange(start, end time.Time) ([]models.Expense, error) { + expenses, err := s.Load() + if err != nil { + return nil, err + } + + filtered := make([]models.Expense, 0) + for _, e := range expenses { + if (e.Date.Equal(start) || e.Date.After(start)) && + (e.Date.Equal(end) || e.Date.Before(end)) { + filtered = append(filtered, e) + } + } + + return filtered, nil +} + +// GetSummaryByCategory returns total expenses grouped by category +func (s *Storage) GetSummaryByCategory() (map[string]float64, error) { + expenses, err := s.Load() + if err != nil { + return nil, err + } + + summary := make(map[string]float64) + for _, e := range expenses { + summary[e.Category] += e.Amount + } + + return summary, nil +} + +// GetCategories returns all unique categories +func (s *Storage) GetCategories() ([]string, error) { + expenses, err := s.Load() + if err != nil { + return nil, err + } + + categoryMap := make(map[string]bool) + for _, e := range expenses { + categoryMap[e.Category] = true + } + + categories := make([]string, 0, len(categoryMap)) + for cat := range categoryMap { + categories = append(categories, cat) + } + + sort.Strings(categories) + return categories, nil +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..2940ba2 --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,237 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" + + "github.com/codeforgood-org/go-expense-tracker/internal/models" +) + +func TestStorage_LoadAndSave(t *testing.T) { + // Create temp file for testing + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_expenses.json") + + store := New(testFile) + + // Test loading from non-existent file + expenses, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(expenses) != 0 { + t.Errorf("Load() returned %d expenses, want 0", len(expenses)) + } + + // Test saving expenses + testExpenses := []models.Expense{ + { + ID: "test1", + Amount: 50.00, + Category: "Food", + Date: time.Now(), + }, + { + ID: "test2", + Amount: 25.00, + Category: "Transport", + Date: time.Now(), + }, + } + + err = store.Save(testExpenses) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Test loading saved expenses + loaded, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(loaded) != len(testExpenses) { + t.Errorf("Load() returned %d expenses, want %d", len(loaded), len(testExpenses)) + } +} + +func TestStorage_Add(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_expenses.json") + store := New(testFile) + + expense := models.Expense{ + ID: "test123", + Amount: 100.00, + Category: "Shopping", + Date: time.Now(), + } + + err := store.Add(expense) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + + expenses, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if len(expenses) != 1 { + t.Errorf("Load() returned %d expenses, want 1", len(expenses)) + } + + if expenses[0].ID != expense.ID { + t.Errorf("Load() expense ID = %v, want %v", expenses[0].ID, expense.ID) + } +} + +func TestStorage_Delete(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_expenses.json") + store := New(testFile) + + // Add test expenses + expenses := []models.Expense{ + {ID: "test1", Amount: 50.00, Category: "Food", Date: time.Now()}, + {ID: "test2", Amount: 25.00, Category: "Transport", Date: time.Now()}, + {ID: "test3", Amount: 100.00, Category: "Shopping", Date: time.Now()}, + } + + for _, exp := range expenses { + if err := store.Add(exp); err != nil { + t.Fatalf("Add() error = %v", err) + } + } + + // Delete one expense + err := store.Delete("test2") + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + + // Verify deletion + loaded, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if len(loaded) != 2 { + t.Errorf("Load() returned %d expenses after delete, want 2", len(loaded)) + } + + // Verify test2 is gone + for _, exp := range loaded { + if exp.ID == "test2" { + t.Error("Delete() did not remove expense test2") + } + } + + // Try to delete non-existent expense + err = store.Delete("nonexistent") + if err == nil { + t.Error("Delete() should return error for non-existent ID") + } +} + +func TestStorage_GetByCategory(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_expenses.json") + store := New(testFile) + + // Add test expenses + expenses := []models.Expense{ + {ID: "test1", Amount: 50.00, Category: "Food", Date: time.Now()}, + {ID: "test2", Amount: 25.00, Category: "Transport", Date: time.Now()}, + {ID: "test3", Amount: 30.00, Category: "Food", Date: time.Now()}, + } + + for _, exp := range expenses { + if err := store.Add(exp); err != nil { + t.Fatalf("Add() error = %v", err) + } + } + + // Get Food expenses + foodExpenses, err := store.GetByCategory("Food") + if err != nil { + t.Fatalf("GetByCategory() error = %v", err) + } + + if len(foodExpenses) != 2 { + t.Errorf("GetByCategory(Food) returned %d expenses, want 2", len(foodExpenses)) + } +} + +func TestStorage_GetSummaryByCategory(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_expenses.json") + store := New(testFile) + + // Add test expenses + expenses := []models.Expense{ + {ID: "test1", Amount: 50.00, Category: "Food", Date: time.Now()}, + {ID: "test2", Amount: 25.00, Category: "Transport", Date: time.Now()}, + {ID: "test3", Amount: 30.00, Category: "Food", Date: time.Now()}, + } + + for _, exp := range expenses { + if err := store.Add(exp); err != nil { + t.Fatalf("Add() error = %v", err) + } + } + + summary, err := store.GetSummaryByCategory() + if err != nil { + t.Fatalf("GetSummaryByCategory() error = %v", err) + } + + if summary["Food"] != 80.00 { + t.Errorf("GetSummaryByCategory() Food = %.2f, want 80.00", summary["Food"]) + } + + if summary["Transport"] != 25.00 { + t.Errorf("GetSummaryByCategory() Transport = %.2f, want 25.00", summary["Transport"]) + } +} + +func TestStorage_GetCategories(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_expenses.json") + store := New(testFile) + + // Add test expenses + expenses := []models.Expense{ + {ID: "test1", Amount: 50.00, Category: "Food", Date: time.Now()}, + {ID: "test2", Amount: 25.00, Category: "Transport", Date: time.Now()}, + {ID: "test3", Amount: 30.00, Category: "Shopping", Date: time.Now()}, + } + + for _, exp := range expenses { + if err := store.Add(exp); err != nil { + t.Fatalf("Add() error = %v", err) + } + } + + categories, err := store.GetCategories() + if err != nil { + t.Fatalf("GetCategories() error = %v", err) + } + + if len(categories) != 3 { + t.Errorf("GetCategories() returned %d categories, want 3", len(categories)) + } + + // Check if all expected categories are present + catMap := make(map[string]bool) + for _, cat := range categories { + catMap[cat] = true + } + + expected := []string{"Food", "Transport", "Shopping"} + for _, cat := range expected { + if !catMap[cat] { + t.Errorf("GetCategories() missing category %s", cat) + } + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..07e783c --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,18 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +// GenerateID generates a unique ID for expenses +func GenerateID() string { + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + // Fallback to timestamp-based ID if random fails + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(bytes) +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 0000000..c498ca1 --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,49 @@ +package utils + +import ( + "testing" +) + +func TestGenerateID(t *testing.T) { + // Generate multiple IDs + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := GenerateID() + + // Check that ID is not empty + if id == "" { + t.Error("GenerateID() returned empty string") + } + + // Check for uniqueness + if ids[id] { + t.Errorf("GenerateID() generated duplicate ID: %s", id) + } + ids[id] = true + } + + // Verify we generated 100 unique IDs + if len(ids) != 100 { + t.Errorf("GenerateID() generated %d unique IDs, want 100", len(ids)) + } +} + +func TestGenerateID_Length(t *testing.T) { + id := GenerateID() + + // The ID should be hex-encoded 4 bytes = 8 characters + if len(id) != 8 { + t.Errorf("GenerateID() length = %d, want 8", len(id)) + } +} + +func TestGenerateID_HexFormat(t *testing.T) { + id := GenerateID() + + // Check if ID contains only hex characters + for _, c := range id { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("GenerateID() = %s, contains non-hex character: %c", id, c) + } + } +} diff --git a/scripts/generate_sample_data.sh b/scripts/generate_sample_data.sh new file mode 100755 index 0000000..fb649a3 --- /dev/null +++ b/scripts/generate_sample_data.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Generate sample expense data for testing + +set -e + +BINARY="./expense-tracker" + +# Check if binary exists +if [ ! -f "$BINARY" ]; then + echo "Error: expense-tracker binary not found. Run 'make build' first." + exit 1 +fi + +echo "๐ŸŽฒ Generating sample expense data..." +echo + +# Sample expenses with realistic data +$BINARY add 52.30 groceries "Weekly grocery shopping at Whole Foods" +$BINARY add 15.75 transport "Uber ride to downtown" +$BINARY add 8.50 food "Coffee and pastry at local cafe" +$BINARY add 125.00 utilities "Monthly electricity bill" +$BINARY add 45.20 groceries "Fresh produce from farmers market" +$BINARY add 30.00 entertainment "Movie tickets for two" +$BINARY add 12.99 food "Lunch at sandwich shop" +$BINARY add 65.00 transport "Monthly bus pass" +$BINARY add 200.00 shopping "New running shoes" +$BINARY add 18.50 food "Dinner at Italian restaurant" +$BINARY add 95.00 utilities "Internet service" +$BINARY add 25.00 health "Vitamin supplements" +$BINARY add 150.00 entertainment "Concert tickets" +$BINARY add 22.40 groceries "Cleaning supplies and toiletries" +$BINARY add 35.00 shopping "Books from bookstore" +$BINARY add 10.00 transport "Parking fee downtown" +$BINARY add 75.50 food "Grocery delivery tip included" +$BINARY add 40.00 health "Gym membership monthly fee" +$BINARY add 28.99 entertainment "Streaming service subscriptions" +$BINARY add 14.25 food "Coffee shop breakfast" + +echo +echo "โœ“ Sample data generation complete!" +echo +echo "Try these commands:" +echo " $BINARY list" +echo " $BINARY summary" +echo " $BINARY filter groceries" +echo " $BINARY categories"