diff --git a/.github/dave.png b/.github/dave.png new file mode 100644 index 0000000..1707ddb Binary files /dev/null and b/.github/dave.png differ diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 0000000..814feff --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,67 @@ +name: PR Tests + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: write + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Run tests + id: test + run: | + go test -v ./tests/... 2>&1 | tee test-output.txt + echo "test_exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + + - name: Comment PR with test results + uses: actions/github-script@v7 + if: always() + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const testOutput = fs.readFileSync('test-output.txt', 'utf8'); + const testPassed = ${{ steps.test.outputs.test_exit_code }} === 0; + + const emoji = testPassed ? '✅' : '❌'; + const status = testPassed ? 'PASSED' : 'FAILED'; + + const body = `## ${emoji} Test Results: ${status} + +
+ Test Output + + \`\`\` + ${testOutput} + \`\`\` + +
`; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + - name: Fail if tests failed + if: steps.test.outputs.test_exit_code != '0' + run: exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..039442f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,203 @@ +name: Build and Release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v ./tests/... + + version: + name: Calculate Version + runs-on: ubuntu-latest + needs: test + outputs: + new_version: ${{ steps.version.outputs.new_version }} + changelog: ${{ steps.version.outputs.changelog }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get latest tag + id: get_tag + run: | + # Get the latest tag, or use v0.0.0 if no tags exist + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "Latest tag: $LATEST_TAG" + + - name: Calculate new version + id: version + run: | + LATEST_TAG="${{ steps.get_tag.outputs.latest_tag }}" + + # Remove 'v' prefix + VERSION=${LATEST_TAG#v} + + # Split version into parts + IFS='.' read -ra VERSION_PARTS <<< "$VERSION" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=${VERSION_PARTS[2]} + + # Get commit messages since last tag + if [ "$LATEST_TAG" = "v0.0.0" ]; then + COMMITS=$(git log --pretty=format:"%s") + else + COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s") + fi + + echo "Commits since last tag:" + echo "$COMMITS" + + # Check for breaking changes (MAJOR) + if echo "$COMMITS" | grep -qiE "BREAKING CHANGE|major:"; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + BUMP_TYPE="major" + # Check for features (MINOR) + elif echo "$COMMITS" | grep -qiE "^feat|^feature|minor:"; then + MINOR=$((MINOR + 1)) + PATCH=0 + BUMP_TYPE="minor" + # Default to PATCH + else + PATCH=$((PATCH + 1)) + BUMP_TYPE="patch" + fi + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION (${BUMP_TYPE} bump)" + + # Generate changelog + CHANGELOG="## Changes in $NEW_VERSION\n\n" + if [ "$LATEST_TAG" = "v0.0.0" ]; then + CHANGELOG+="Initial release\n\n" + CHANGELOG+=$(git log --pretty=format:"- %s (%h)" | head -20) + else + CHANGELOG+=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s (%h)") + fi + + echo "changelog<> $GITHUB_OUTPUT + echo -e "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + build: + name: Build Binaries + runs-on: ubuntu-latest + needs: version + strategy: + matrix: + include: + # Windows + - goos: windows + goarch: amd64 + output: dave-windows-amd64.exe + - goos: windows + goarch: arm64 + output: dave-windows-arm64.exe + + # macOS + - goos: darwin + goarch: amd64 + output: dave-macos-amd64 + - goos: darwin + goarch: arm64 + output: dave-macos-arm64 + + # Linux + - goos: linux + goarch: amd64 + output: dave-linux-amd64 + - goos: linux + goarch: arm64 + output: dave-linux-arm64 + - goos: linux + goarch: 386 + output: dave-linux-386 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION="${{ needs.version.outputs.new_version }}" + go build -ldflags "-s -w -X main.Version=${VERSION}" -o ${{ matrix.output }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output }} + path: ${{ matrix.output }} + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [version, build] + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./binaries + + - name: Display structure of downloaded files + run: ls -R ./binaries + + - name: Prepare release assets + run: | + mkdir -p release + find ./binaries -type f -exec cp {} ./release/ \; + ls -lh ./release + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.version.outputs.new_version }} + name: Release ${{ needs.version.outputs.new_version }} + body: ${{ needs.version.outputs.changelog }} + files: ./release/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 7c9a718..afb8fd8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,228 @@ -# dave -CLI Debt Snowball Tool +# Dave - Debt Tracking CLI + +Dave is a command-line tool for tracking debts and visualizing your path to becoming debt-free using the snowball or avalanche method. + +
+ Dave Screenshot +

Track your debts, visualize payoff dates, and accelerate your path to financial freedom

+
+ +## Features + +- **Multiple Payoff Strategies**: Snowball (lowest balance first), Avalanche (highest interest first), or Manual ordering +- **Payment Tracking**: Full payment history with interest/principal breakdown, optional custom payment dates +- **Payoff Projections**: See exactly when each debt will be paid off and your overall debt-free date +- **Auto-Snowball**: When you pay off a debt, its minimum payment automatically adds to your snowball amount +- **Order Number Support**: Use position numbers (1, 2, 3) or names for all commands +- **Beautiful Terminal UI**: Clean, colorful tables powered by Lipgloss with order numbers, totals, and projections +- **SQLite Storage**: All data stored locally in `~/.dave/debts.db` +- **Hidden Paid Debts**: Paid-off debts are automatically hidden from view but preserved in the database +- **Reset Command**: Clear all debts and start fresh with a single command (with confirmation prompt) + +## Installation + +```bash +go build -o dave.exe +``` + +Or add to your PATH for system-wide access. + +## Quick Start + +```bash +# Add your first debt +dave add "Credit Card" 5000 18.5 150 + +# Add more debts +dave add "Car Loan" 15000 5.5 350 +dave add "Student Loan" 25000 4.2 200 + +# Set extra monthly payment (snowball amount) +dave snowball 500 + +# View your debt table (default command) +dave + +# Make a payment using debt name +dave pay "Credit Card" 1000 + +# Or use the order number from the table +dave pay 1 1000 + +# Backdate a payment +dave pay 2 500 2024-11-15 + +# Switch to avalanche mode +dave mode avalanche +``` + +## Commands + +### `dave` or `dave show` +Display the debt table with projections. This is the default command. + +### `dave add ` +Add a new debt. +- `creditor`: Name of the creditor (e.g., "Credit Card") +- `balance`: Current balance +- `rate`: Annual Percentage Rate (APR) +- `payment`: Minimum monthly payment + +Example: `dave add "Visa" 3500 19.99 75` + +### `dave remove ` +Remove a debt by name or order number. + +Examples: +- `dave remove "Visa"` +- `dave remove 2` + +### `dave pay [yyyy-mm-dd]` +Record a payment on a debt. Updates the balance and tracks interest vs principal. Optionally backdate the payment. + +When a debt is fully paid off, its minimum payment is automatically added to your snowball amount! + +Examples: +- `dave pay "Visa" 500` (payment today) +- `dave pay 1 500` (using order number) +- `dave pay 2 750 2024-11-15` (backdated payment) + +### `dave snowball ` +Set the extra monthly payment amount to apply on top of minimum payments. + +Example: `dave snowball 500` + +### `dave mode ` +Change the debt sorting/prioritization mode: +- **snowball**: Pay off smallest balance first (psychological wins) +- **avalanche**: Pay off highest interest rate first (mathematically optimal) +- **manual**: Use custom ordering + +Example: `dave mode avalanche` + +### `dave adjust-rate ` +Update the APR for a debt. + +Examples: +- `dave adjust-rate "Visa" 15.99` +- `dave adjust-rate 1 15.99` + +### `dave adjust-amount ` +Manually adjust the current balance (for corrections). + +Examples: +- `dave adjust-amount "Visa" 3400` +- `dave adjust-amount 2 3400` + +### `dave adjust-order ` +Change the priority order (manual mode only). + +Examples: +- `dave adjust-order "Student Loan" 1` +- `dave adjust-order 3 1` + +### `dave reset` +Clear all debts, payment history, and reset settings to defaults. This action cannot be undone. + +You will be prompted for confirmation before deletion: +- Deletes all debts +- Deletes all payment history +- Resets mode to snowball +- Resets snowball amount to $0.00 + +Example: `dave reset` + +**Warning**: This is a destructive operation. All data will be permanently deleted. + +## Table Display + +The debt table shows: +- **#**: Order number (use this in commands instead of typing the full name) +- **Creditor**: Name of the debt +- **Original**: Starting balance +- **Current**: Current balance +- **Rate**: Annual Percentage Rate (APR) +- **Payment**: Minimum monthly payment +- **Interest**: Projected total interest to be paid +- **Payoff**: Estimated payoff date +- **Months**: Months until paid off +- **Years**: Years until paid off (decimal format) + +The table also displays: +- **DEBT FREE DATE**: When all debts will be paid off +- **Total Debt**: Sum of all current balances +- **Totals row**: Summary of all debts +- **Footer**: Current mode, monthly payment total, snowball amount, and total payment + +**Note**: Paid-off debts (balance = $0) are automatically hidden from the table but remain in the database. + +## How It Works + +### Debt Snowball Method +1. List debts from smallest to largest balance +2. Pay minimum on all debts +3. Apply extra payment (snowball amount) to the smallest debt +4. When a debt is paid off, add its minimum payment to the snowball amount +5. Repeat until debt-free! + +### Debt Avalanche Method +Same as snowball, but prioritize by highest interest rate instead of smallest balance. Mathematically optimal but may take longer to see first payoff. + +### Projections +Dave simulates your monthly payments with compound interest to calculate: +- Months to pay off each debt +- Total interest paid on each debt +- Individual payoff dates +- Overall debt-free date + +## Data Storage + +All data is stored in `~/.dave/debts.db` (SQLite database) with three tables: +- **debts**: Current debt information +- **payments**: Full payment history +- **settings**: Current mode and snowball amount + +## Examples + +### Example: Adding debts and seeing the impact of snowball + +```bash +# Start with no extra payment +dave add "Credit Card" 5000 18.5 150 +# Shows: 48 months to payoff, $2072 in interest + +# Add $500 snowball +dave snowball 500 +# Shows: 9 months to payoff, $364 in interest +# Saves $1708 in interest and 39 months! +``` + +### Example: Comparing modes + +```bash +# Snowball mode (smallest balance first) +dave mode snowball +dave + +# Avalanche mode (highest rate first) +dave mode avalanche +dave + +# Manual mode with custom priority +dave mode manual +dave adjust-order "Student Loan" 1 +``` + +## Building from Source + +Requirements: +- Go 1.25 or later + +```bash +go mod download +go build -o dave.exe +``` + +## License + +See LICENSE file for details. diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..deecbf5 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var addCmd = &cobra.Command{ + Use: "add ", + Short: "Add a new debt", + Long: `Add a new debt with creditor name, balance, APR, and minimum monthly payment.`, + Args: cobra.ExactArgs(4), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Trim whitespace from creditor name + creditor := strings.TrimSpace(args[0]) + if creditor == "" { + fmt.Println("Error: Creditor name cannot be empty") + return + } + + balance, err := strconv.ParseFloat(args[1], 64) + if err != nil || balance <= 0 { + fmt.Println("Error: Balance must be a positive number") + return + } + + apr, err := strconv.ParseFloat(args[2], 64) + if err != nil || apr < 0 { + fmt.Println("Error: APR must be a non-negative number") + return + } + + payment, err := strconv.ParseFloat(args[3], 64) + if err != nil || payment <= 0 { + fmt.Println("Error: Payment must be a positive number") + return + } + + // Get current sort mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // Add debt + err = models.AddDebt(db, creditor, balance, apr, payment, settings.SortMode) + if err != nil { + fmt.Printf("Error adding debt: %v\n", err) + return + } + + fmt.Printf("Added %s - $%.2f at %.2f%%\n", creditor, balance, apr) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/adjust_amount.go b/cmd/adjust_amount.go new file mode 100644 index 0000000..fcacaa4 --- /dev/null +++ b/cmd/adjust_amount.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var adjustAmountCmd = &cobra.Command{ + Use: "adjust-amount ", + Short: "Adjust the current balance for a debt", + Long: `Manually adjust the current balance for a debt (use for corrections or adjustments).`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Trim whitespace from identifier + identifier := strings.TrimSpace(args[0]) + if identifier == "" { + fmt.Println("Error: Debt identifier cannot be empty") + return + } + + amount, err := strconv.ParseFloat(args[1], 64) + if err != nil || amount < 0 { + fmt.Println("Error: Amount must be a non-negative number") + return + } + + // Get current sort mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // Find debt by index or name + debt, err := models.GetDebtByIndexOrName(db, identifier, settings.SortMode) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + err = models.UpdateDebtAmount(db, debt.Creditor, amount) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Updated %s balance to $%.2f\n", debt.Creditor, amount) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/adjust_order.go b/cmd/adjust_order.go new file mode 100644 index 0000000..8c4ae4b --- /dev/null +++ b/cmd/adjust_order.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var adjustOrderCmd = &cobra.Command{ + Use: "adjust-order ", + Short: "Adjust the order of a debt (manual mode only)", + Long: `Change the priority order of a debt. Only works when in manual sort mode.`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Check if in manual mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + if settings.SortMode != models.SortModeManual { + fmt.Println("Error: adjust-order only works in manual sort mode") + fmt.Println("Switch to manual mode with: dave mode manual") + return + } + + // Trim whitespace from identifier + identifier := strings.TrimSpace(args[0]) + if identifier == "" { + fmt.Println("Error: Debt identifier cannot be empty") + return + } + + order, err := strconv.Atoi(args[1]) + if err != nil || order < 1 { + fmt.Println("Error: Order must be a positive integer") + return + } + + // Find debt by index or name + debt, err := models.GetDebtByIndexOrName(db, identifier, settings.SortMode) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + err = models.UpdateDebtOrder(db, debt.Creditor, order) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Updated %s order to %d\n", debt.Creditor, order) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/adjust_rate.go b/cmd/adjust_rate.go new file mode 100644 index 0000000..2e6dfa5 --- /dev/null +++ b/cmd/adjust_rate.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var adjustRateCmd = &cobra.Command{ + Use: "adjust-rate ", + Short: "Adjust the interest rate for a debt", + Long: `Update the APR (Annual Percentage Rate) for a debt.`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Trim whitespace from identifier + identifier := strings.TrimSpace(args[0]) + if identifier == "" { + fmt.Println("Error: Debt identifier cannot be empty") + return + } + + rate, err := strconv.ParseFloat(args[1], 64) + if err != nil || rate < 0 { + fmt.Println("Error: Rate must be a non-negative number") + return + } + + // Get current sort mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // Find debt by index or name + debt, err := models.GetDebtByIndexOrName(db, identifier, settings.SortMode) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + err = models.UpdateDebtAPR(db, debt.Creditor, rate) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Updated %s rate to %.2f%%\n", debt.Creditor, rate) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/mode.go b/cmd/mode.go new file mode 100644 index 0000000..46cc8e3 --- /dev/null +++ b/cmd/mode.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var modeCmd = &cobra.Command{ + Use: "mode ", + Short: "Change the debt sorting mode", + Long: `Change how debts are prioritized: + - snowball: Pay off smallest balance first + - avalanche: Pay off highest interest rate first + - manual: Use custom ordering`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + modeStr := args[0] + var mode models.SortMode + + switch modeStr { + case "snowball": + mode = models.SortModeSnowball + case "avalanche": + mode = models.SortModeAvalanche + case "manual": + mode = models.SortModeManual + default: + fmt.Println("Error: Mode must be 'snowball', 'avalanche', or 'manual'") + return + } + + // Get current mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // If switching to manual mode, set sequential ordering + if mode == models.SortModeManual && settings.SortMode != models.SortModeManual { + err = models.SetManualOrdering(db, settings.SortMode) + if err != nil { + fmt.Printf("Error setting manual ordering: %v\n", err) + return + } + } + + // If switching from manual mode, clear ordering + if mode != models.SortModeManual && settings.SortMode == models.SortModeManual { + err = models.ClearManualOrdering(db) + if err != nil { + fmt.Printf("Error clearing manual ordering: %v\n", err) + return + } + } + + // Update mode + err = models.SetSortMode(db, mode) + if err != nil { + fmt.Printf("Error setting mode: %v\n", err) + return + } + + fmt.Printf("Mode set to %s\n", modeStr) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/pay.go b/cmd/pay.go new file mode 100644 index 0000000..2601c19 --- /dev/null +++ b/cmd/pay.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/calculator" + "github.com/tryonlinux/dave/internal/models" +) + +var payCmd = &cobra.Command{ + Use: "pay [yyyy-mm-dd]", + Short: "Record a payment on a debt", + Long: `Record a payment on a debt. Optionally specify the payment date (defaults to today). This will update the balance and track interest vs principal.`, + Args: cobra.RangeArgs(2, 3), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Trim whitespace from identifier + identifier := strings.TrimSpace(args[0]) + if identifier == "" { + fmt.Println("Error: Debt identifier cannot be empty") + return + } + + amount, err := strconv.ParseFloat(args[1], 64) + if err != nil || amount <= 0 { + fmt.Println("Error: Amount must be a positive number") + return + } + + // Parse optional payment date + var paymentDate time.Time + if len(args) == 3 { + paymentDate, err = time.Parse("2006-01-02", args[2]) + if err != nil { + fmt.Printf("Error: Invalid date format. Use yyyy-mm-dd (e.g., 2024-12-20)\n") + return + } + } else { + paymentDate = time.Now() + } + + // Get current sort mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // Find debt by index or name + debt, err := models.GetDebtByIndexOrName(db, identifier, settings.SortMode) + if err != nil { + fmt.Printf("Error: Debt '%s' not found\n", identifier) + return + } + + if amount > debt.CurrentBalance { + fmt.Printf("Error: Payment amount ($%.2f) exceeds current balance ($%.2f)\n", amount, debt.CurrentBalance) + return + } + + // Calculate interest portion + interestPortion := calculator.CalculateInterestPortion(debt.CurrentBalance, debt.APR) + if interestPortion > amount { + interestPortion = amount // Payment only covers interest + } + + balanceBefore := debt.CurrentBalance + balanceAfter := debt.CurrentBalance - amount + + // Record payment with date + err = models.RecordPaymentWithDate(db, debt.ID, amount, balanceBefore, balanceAfter, interestPortion, "Regular payment", paymentDate) + if err != nil { + fmt.Printf("Error recording payment: %v\n", err) + return + } + + // Update debt balance + err = models.UpdateDebtBalance(db, debt.ID, balanceAfter) + if err != nil { + fmt.Printf("Error updating balance: %v\n", err) + return + } + + // If debt is paid off, add its minimum payment to the snowball amount + if balanceAfter <= 0 { + newSnowball := settings.SnowballAmount + debt.MinimumPayment + err = models.SetSnowballAmount(db, newSnowball) + if err == nil { + fmt.Printf("Payment of $%.2f applied to %s. Debt paid off!\n", amount, debt.Creditor) + fmt.Printf("Snowball amount increased to $%.2f (added $%.2f from paid-off debt)\n", newSnowball, debt.MinimumPayment) + } else { + fmt.Printf("Payment of $%.2f applied to %s. Debt paid off!\n", amount, debt.Creditor) + fmt.Printf("Warning: Error updating snowball amount: %v\n", err) + } + } else { + fmt.Printf("Payment of $%.2f applied to %s. New balance: $%.2f\n", amount, debt.Creditor, balanceAfter) + } + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..9b348df --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a debt", + Long: `Remove a debt by creditor name or position number. This will also delete all payment history for this debt.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Trim whitespace from identifier + identifier := strings.TrimSpace(args[0]) + if identifier == "" { + fmt.Println("Error: Debt identifier cannot be empty") + return + } + + // Get current sort mode + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // Find debt by index or name + debt, err := models.GetDebtByIndexOrName(db, identifier, settings.SortMode) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + // Remove by creditor name + err = models.RemoveDebt(db, debt.Creditor) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Removed %s\n", debt.Creditor) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/reset.go b/cmd/reset.go new file mode 100644 index 0000000..fee382f --- /dev/null +++ b/cmd/reset.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var resetCmd = &cobra.Command{ + Use: "reset", + Short: "Clear all debts and start over", + Long: `Deletes all debts, payments, and resets settings to defaults. This action cannot be undone. You will be prompted for confirmation.`, + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Get confirmation from user + fmt.Println("WARNING: This will delete ALL debts, payment history, and reset all settings.") + fmt.Print("Are you sure you want to continue? (yes/no): ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "yes" && response != "y" { + fmt.Println("Reset cancelled.") + return + } + + // Delete all payments first + _, err = db.Exec("DELETE FROM payments") + if err != nil { + fmt.Printf("Error deleting payments: %v\n", err) + return + } + + // Delete all debts + _, err = db.Exec("DELETE FROM debts") + if err != nil { + fmt.Printf("Error deleting debts: %v\n", err) + return + } + + // Reset settings to defaults + err = models.SetSortMode(db, models.SortModeSnowball) + if err != nil { + fmt.Printf("Error resetting sort mode: %v\n", err) + return + } + + err = models.SetSnowballAmount(db, 0.0) + if err != nil { + fmt.Printf("Error resetting snowball amount: %v\n", err) + return + } + + fmt.Println("All debts and payment history have been deleted.") + fmt.Println("Settings have been reset to defaults (mode: snowball, snowball amount: $0.00).") + fmt.Println("You can start fresh by adding new debts with: dave add ") + }, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..307b566 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/config" + "github.com/tryonlinux/dave/internal/database" +) + +var db *database.DB + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "dave", + Short: "Dave - Debt tracking and payoff calculator", + Long: `Dave helps you track your debts and visualize your path to becoming debt-free +using the snowball or avalanche method.`, + Run: func(cmd *cobra.Command, args []string) { + // Default behavior: run show command + showCmd.Run(cmd, args) + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + // Clean up database connection on exit + cleanupDatabase() + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initDatabase) + + // Add all subcommands + rootCmd.AddCommand(showCmd) + rootCmd.AddCommand(addCmd) + rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(payCmd) + rootCmd.AddCommand(adjustRateCmd) + rootCmd.AddCommand(adjustAmountCmd) + rootCmd.AddCommand(adjustOrderCmd) + rootCmd.AddCommand(snowballCmd) + rootCmd.AddCommand(modeCmd) + rootCmd.AddCommand(resetCmd) +} + +// initDatabase initializes the database connection +func initDatabase() { + dbPath, err := config.GetDatabasePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting database path: %v\n", err) + os.Exit(1) + } + + db, err = database.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err) + os.Exit(1) + } +} + +// GetDB returns the database connection (used by commands) +func GetDB() *database.DB { + return db +} + +// cleanupDatabase closes the database connection +func cleanupDatabase() { + if db != nil { + if err := db.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Error closing database: %v\n", err) + } + } +} diff --git a/cmd/show.go b/cmd/show.go new file mode 100644 index 0000000..82dd57b --- /dev/null +++ b/cmd/show.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/calculator" + "github.com/tryonlinux/dave/internal/display" + "github.com/tryonlinux/dave/internal/models" +) + +var showCmd = &cobra.Command{ + Use: "show", + Short: "Display debt table with payoff projections", + Long: `Shows all debts with their current balances, projected payoff dates, and the overall debt-free date.`, + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Get settings + settings, err := models.GetSettings(db) + if err != nil { + fmt.Printf("Error getting settings: %v\n", err) + return + } + + // Get debts sorted by current mode + debts, err := models.GetAllDebts(db, settings.SortMode) + if err != nil { + fmt.Printf("Error retrieving debts: %v\n", err) + return + } + + // Calculate projections + projections := calculator.ProjectPayoffTimeline(debts, settings.SnowballAmount) + + // Render table + output := display.RenderDebtsTable(debts, projections, settings) + fmt.Println(output) + }, +} diff --git a/cmd/snowball.go b/cmd/snowball.go new file mode 100644 index 0000000..9a98c00 --- /dev/null +++ b/cmd/snowball.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/tryonlinux/dave/internal/models" +) + +var snowballCmd = &cobra.Command{ + Use: "snowball ", + Short: "Set the extra monthly payment amount", + Long: `Set the additional amount to pay toward debts each month (on top of minimum payments).`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + amount, err := strconv.ParseFloat(args[0], 64) + if err != nil || amount < 0 { + fmt.Println("Error: Amount must be a non-negative number") + return + } + + err = models.SetSnowballAmount(db, amount) + if err != nil { + fmt.Printf("Error setting snowball amount: %v\n", err) + return + } + + fmt.Printf("Snowball amount set to $%.2f\n", amount) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f95df2f --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/tryonlinux/dave + +go 1.25.5 + +require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.41.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..173ff1f --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck= +modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/calculator/interest.go b/internal/calculator/interest.go new file mode 100644 index 0000000..78986cc --- /dev/null +++ b/internal/calculator/interest.go @@ -0,0 +1,15 @@ +package calculator + +// CalculateMonthlyInterest calculates the interest accrued for one month +func CalculateMonthlyInterest(balance, apr float64) float64 { + if apr == 0 { + return 0 + } + monthlyRate := apr / 100 / MonthsPerYear + return balance * monthlyRate +} + +// CalculateInterestPortion calculates how much of a payment goes to interest +func CalculateInterestPortion(balance, apr float64) float64 { + return CalculateMonthlyInterest(balance, apr) +} diff --git a/internal/calculator/projections.go b/internal/calculator/projections.go new file mode 100644 index 0000000..4392ac1 --- /dev/null +++ b/internal/calculator/projections.go @@ -0,0 +1,159 @@ +package calculator + +import ( + "math" + "time" + + "github.com/tryonlinux/dave/internal/models" +) + +const ( + // MaxProjectionMonths is the maximum number of months to project (50 years) + MaxProjectionMonths = 600 + // FloatingPointTolerance is the threshold for considering a balance as zero + FloatingPointTolerance = 0.01 + // MonthsPerYear is the number of months in a year + MonthsPerYear = 12 +) + +// DebtProjection contains the calculated payoff information for a debt +type DebtProjection struct { + Creditor string + MonthsToPayoff int + TotalInterest float64 + PayoffDate time.Time + Payable bool // false if payment < monthly interest +} + +// ProjectPayoffTimeline calculates payoff projections for all debts +func ProjectPayoffTimeline(debts []models.Debt, snowballAmount float64) []DebtProjection { + // Create working copies of debts + workingDebts := make([]debtState, len(debts)) + for i, debt := range debts { + workingDebts[i] = debtState{ + Debt: debt, + Balance: debt.CurrentBalance, + InterestPaid: 0, + MonthsPaid: 0, + IsPaidOff: debt.CurrentBalance <= 0, + } + } + + currentSnowball := snowballAmount + + // Simulate monthly payments + for month := 1; month <= MaxProjectionMonths; month++ { + allPaidOff := true + + // Step 1: Apply monthly interest to all active debts + for i := range workingDebts { + if !workingDebts[i].IsPaidOff { + interest := CalculateMonthlyInterest(workingDebts[i].Balance, workingDebts[i].Debt.APR) + workingDebts[i].Balance += interest + workingDebts[i].InterestPaid += interest + allPaidOff = false + } + } + + if allPaidOff { + break + } + + // Step 2: Apply minimum payments to all active debts + for i := range workingDebts { + if !workingDebts[i].IsPaidOff { + payment := math.Min(workingDebts[i].Debt.MinimumPayment, workingDebts[i].Balance) + workingDebts[i].Balance -= payment + + if workingDebts[i].Balance <= FloatingPointTolerance { + workingDebts[i].Balance = 0 + workingDebts[i].IsPaidOff = true + workingDebts[i].MonthsPaid = month + // Add freed minimum payment to snowball + currentSnowball += workingDebts[i].Debt.MinimumPayment + } + } + } + + // Step 3: Apply snowball amount to highest priority unpaid debt + if currentSnowball > 0 { + for i := range workingDebts { + if !workingDebts[i].IsPaidOff { + extraPayment := math.Min(currentSnowball, workingDebts[i].Balance) + workingDebts[i].Balance -= extraPayment + + if workingDebts[i].Balance <= FloatingPointTolerance { + workingDebts[i].Balance = 0 + workingDebts[i].IsPaidOff = true + workingDebts[i].MonthsPaid = month + // Add freed minimum payment to snowball + currentSnowball += workingDebts[i].Debt.MinimumPayment + } + break // Only apply to first unpaid debt + } + } + } + } + + // Build projection results + projections := make([]DebtProjection, len(debts)) + for i, ws := range workingDebts { + payable := true + monthsToPayoff := ws.MonthsPaid + + // Check if debt is payable (minimum payment > monthly interest) + if !ws.IsPaidOff { + monthlyInterest := CalculateMonthlyInterest(ws.Debt.CurrentBalance, ws.Debt.APR) + if ws.Debt.MinimumPayment+snowballAmount <= monthlyInterest { + payable = false + monthsToPayoff = MaxProjectionMonths + } else { + monthsToPayoff = MaxProjectionMonths + } + } + + payoffDate := time.Now().AddDate(0, monthsToPayoff, 0) + + projections[i] = DebtProjection{ + Creditor: ws.Debt.Creditor, + MonthsToPayoff: monthsToPayoff, + TotalInterest: ws.InterestPaid, + PayoffDate: payoffDate, + Payable: payable, + } + } + + return projections +} + +// CalculateDebtFreeDate returns the date when all debts will be paid off +func CalculateDebtFreeDate(projections []DebtProjection) time.Time { + maxMonths := 0 + payable := true + + for _, proj := range projections { + if !proj.Payable { + payable = false + break + } + if proj.MonthsToPayoff > maxMonths { + maxMonths = proj.MonthsToPayoff + } + } + + if !payable { + // Return a far future date if any debt is unpayable + return time.Now().AddDate(100, 0, 0) + } + + return time.Now().AddDate(0, maxMonths, 0) +} + +// debtState tracks the state of a debt during simulation +type debtState struct { + Debt models.Debt + Balance float64 + InterestPaid float64 + MonthsPaid int + IsPaidOff bool +} diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..7e45e7f --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,24 @@ +package config + +import ( + "os" + "path/filepath" +) + +// GetDatabasePath returns the path to the SQLite database file. +// Creates the ~/.dave directory if it doesn't exist. +func GetDatabasePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + daveDir := filepath.Join(homeDir, ".dave") + + // Create .dave directory if it doesn't exist (0700 = owner only for security) + if err := os.MkdirAll(daveDir, 0700); err != nil { + return "", err + } + + return filepath.Join(daveDir, "debts.db"), nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..00bd268 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,35 @@ +package database + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +// DB wraps the sql.DB connection +type DB struct { + *sql.DB +} + +// Open opens a connection to the SQLite database and initializes the schema +func Open(dbPath string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + db := &DB{sqlDB} + + // Initialize schema + if err := InitializeSchema(db); err != nil { + sqlDB.Close() + return nil, err + } + + return db, nil +} + +// Close closes the database connection +func (db *DB) Close() error { + return db.DB.Close() +} diff --git a/internal/database/schema.go b/internal/database/schema.go new file mode 100644 index 0000000..03099fe --- /dev/null +++ b/internal/database/schema.go @@ -0,0 +1,60 @@ +package database + +const schema = ` +CREATE TABLE IF NOT EXISTS debts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + creditor TEXT NOT NULL UNIQUE, + original_balance REAL NOT NULL, + current_balance REAL NOT NULL, + apr REAL NOT NULL, + minimum_payment REAL NOT NULL, + custom_order INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_debts_creditor ON debts(creditor); +CREATE INDEX IF NOT EXISTS idx_debts_custom_order ON debts(custom_order); +CREATE INDEX IF NOT EXISTS idx_debts_current_balance ON debts(current_balance); +CREATE INDEX IF NOT EXISTS idx_debts_apr ON debts(apr); + +CREATE TABLE IF NOT EXISTS payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + debt_id INTEGER NOT NULL, + amount REAL NOT NULL, + payment_date DATETIME DEFAULT CURRENT_TIMESTAMP, + balance_before REAL NOT NULL, + balance_after REAL NOT NULL, + interest_portion REAL DEFAULT 0, + principal_portion REAL, + notes TEXT, + FOREIGN KEY (debt_id) REFERENCES debts(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_payments_debt_id ON payments(debt_id); +CREATE INDEX IF NOT EXISTS idx_payments_date ON payments(payment_date); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + +const defaultSettings = ` +INSERT OR IGNORE INTO settings (key, value) VALUES ('sort_mode', 'snowball'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('snowball_amount', '0.00'); +` + +// InitializeSchema creates all tables and inserts default settings +func InitializeSchema(db *DB) error { + if _, err := db.Exec(schema); err != nil { + return err + } + + if _, err := db.Exec(defaultSettings); err != nil { + return err + } + + return nil +} diff --git a/internal/display/formatter.go b/internal/display/formatter.go new file mode 100644 index 0000000..7208083 --- /dev/null +++ b/internal/display/formatter.go @@ -0,0 +1,45 @@ +package display + +import ( + "fmt" + "time" +) + +// FormatCurrency formats a float as currency with comma separators +func FormatCurrency(amount float64) string { + if amount < 0 { + return fmt.Sprintf("-$%.2f", -amount) + } + return fmt.Sprintf("$%.2f", amount) +} + +// FormatPercent formats a float as a percentage +func FormatPercent(rate float64) string { + return fmt.Sprintf("%.2f%%", rate) +} + +// FormatDate formats a date as "Mon YYYY" (e.g., "Jan 2026") +func FormatDate(date time.Time) string { + return date.Format("Jan 2006") +} + +// FormatMonths formats months as a string, or "∞" if unpayable +func FormatMonths(months int, payable bool) string { + if !payable { + return "∞" + } + return fmt.Sprintf("%d", months) +} + +// FormatYears formats months as years and months +func FormatYears(months int) string { + years := months / 12 + remainingMonths := months % 12 + + if years == 0 { + return fmt.Sprintf("%d mo", months) + } else if remainingMonths == 0 { + return fmt.Sprintf("%d yr", years) + } + return fmt.Sprintf("%d yr %d mo", years, remainingMonths) +} diff --git a/internal/display/styles.go b/internal/display/styles.go new file mode 100644 index 0000000..ce0f74f --- /dev/null +++ b/internal/display/styles.go @@ -0,0 +1,33 @@ +package display + +import ( + "github.com/charmbracelet/lipgloss" +) + +var ( + // HeaderStyle for the debt-free date header + HeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("10")). // Green + MarginBottom(1) + + // InfoStyle for mode and snowball amount display + InfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("12")). // Blue + MarginTop(1) + + // TableHeaderStyle for table headers + TableHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("15")). // White + Align(lipgloss.Center) + + // TableCellStyle for regular table cells + TableCellStyle = lipgloss.NewStyle(). + Padding(0, 1) + + // TotalRowStyle for the summary row + TotalRowStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("14")) // Cyan +) diff --git a/internal/display/table.go b/internal/display/table.go new file mode 100644 index 0000000..9646c26 --- /dev/null +++ b/internal/display/table.go @@ -0,0 +1,156 @@ +package display + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/tryonlinux/dave/internal/calculator" + "github.com/tryonlinux/dave/internal/models" +) + +const asciiArt = ` + ██████╗ █████╗ ██╗ ██╗███████╗ + ██╔══██╗██╔══██╗██║ ██║██╔════╝ + ██║ ██║███████║██║ ██║█████╗ + ██║ ██║██╔══██║╚██╗ ██╔╝██╔══╝ + ██████╔╝██║ ██║ ╚████╔╝ ███████╗ + ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ + Debt Tracker & Payoff Calculator +` + +// RenderDebtsTable creates a formatted table of debts with projections +func RenderDebtsTable(debts []models.Debt, projections []calculator.DebtProjection, settings *models.Settings) string { + if len(debts) == 0 { + return asciiArt + "\n\nNo debts tracked. Add one with: dave add " + } + + // Start with ASCII art + var output strings.Builder + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) + output.WriteString(titleStyle.Render(asciiArt)) + output.WriteString("\n") + + // Calculate debt-free date + debtFreeDate := calculator.CalculateDebtFreeDate(projections) + allPayable := true + for _, proj := range projections { + if !proj.Payable { + allPayable = false + break + } + } + + // Calculate totals first + var totalOriginal, totalCurrent, totalPayment, totalInterest float64 + for i, debt := range debts { + totalOriginal += debt.OriginalBalance + totalCurrent += debt.CurrentBalance + totalPayment += debt.MinimumPayment + totalInterest += projections[i].TotalInterest + } + + // Build header with DEBT FREE DATE and Total Debt + var header strings.Builder + if allPayable { + header.WriteString(HeaderStyle.Render(fmt.Sprintf("DEBT FREE DATE: %s", FormatDate(debtFreeDate)))) + } else { + header.WriteString(HeaderStyle.Render("DEBT FREE DATE: ∞ (Some debts are unpayable with current payments)")) + } + header.WriteString("\n") + + // Add Total Debt in center + totalDebtLine := fmt.Sprintf("Total Debt: %s", FormatCurrency(totalCurrent)) + centeredStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Width(80) + header.WriteString(centeredStyle.Render(totalDebtLine)) + header.WriteString("\n\n") + + // Create table with new columns + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("8"))). + Headers("#", "Creditor", "Original", "Current", "Rate", "Payment", "Interest", "Payoff", "Months", "Years") + + // Add data rows + for i, debt := range debts { + proj := projections[i] + + payoffDateStr := FormatDate(proj.PayoffDate) + if !proj.Payable { + payoffDateStr = "Never" + } + + monthsStr := FormatMonths(proj.MonthsToPayoff, proj.Payable) + yearsStr := "∞" + if proj.Payable { + years := float64(proj.MonthsToPayoff) / 12.0 + yearsStr = fmt.Sprintf("%.2f", years) + } + + t.Row( + fmt.Sprintf("%d", i+1), // Order number + debt.Creditor, + FormatCurrency(debt.OriginalBalance), + FormatCurrency(debt.CurrentBalance), + FormatPercent(debt.APR), + FormatCurrency(debt.MinimumPayment), + FormatCurrency(proj.TotalInterest), + payoffDateStr, + monthsStr, + yearsStr, + ) + } + + // Calculate max months for totals row + maxMonths := 0 + allDebtsFreePayable := true + for _, proj := range projections { + if !proj.Payable { + allDebtsFreePayable = false + break + } + if proj.MonthsToPayoff > maxMonths { + maxMonths = proj.MonthsToPayoff + } + } + + totalMonthsStr := "∞" + totalYearsStr := "∞" + if allDebtsFreePayable { + totalMonthsStr = fmt.Sprintf("%d", maxMonths) + totalYearsStr = fmt.Sprintf("%.2f", float64(maxMonths)/12.0) + } + + // Add separator row (empty row with dashes) + t.Row("─", "─────────", "────────", "────────", "──────", "────────", "────────", "────────", "──────", "──────") + + // Add total row + t.Row( + "", + "TOTAL", + FormatCurrency(totalOriginal), + FormatCurrency(totalCurrent), + "", + FormatCurrency(totalPayment), + FormatCurrency(totalInterest), + "", + totalMonthsStr, + totalYearsStr, + ) + + // Style the table + styled := t.Render() + + // Build footer with mode, payments, and snowball info + totalMonthlyPayment := totalPayment + settings.SnowballAmount + var footer strings.Builder + footer.WriteString("\n") + footer.WriteString(InfoStyle.Render(fmt.Sprintf("Mode: %s | Monthly Payment: %s | Snowball Amount: %s | Total Payment: %s", + strings.ToUpper(string(settings.SortMode)), + FormatCurrency(totalPayment), + FormatCurrency(settings.SnowballAmount), + FormatCurrency(totalMonthlyPayment)))) + + return output.String() + header.String() + styled + footer.String() +} diff --git a/internal/models/debt.go b/internal/models/debt.go new file mode 100644 index 0000000..2dc1a6f --- /dev/null +++ b/internal/models/debt.go @@ -0,0 +1,196 @@ +package models + +import ( + "database/sql" + "fmt" + "time" + + "github.com/tryonlinux/dave/internal/database" +) + +// Debt represents a debt entry +type Debt struct { + ID int + Creditor string + OriginalBalance float64 + CurrentBalance float64 + APR float64 + MinimumPayment float64 + CustomOrder sql.NullInt64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// SortMode defines the sorting strategy for debts +type SortMode string + +const ( + SortModeSnowball SortMode = "snowball" + SortModeAvalanche SortMode = "avalanche" + SortModeManual SortMode = "manual" +) + +// AddDebt inserts a new debt into the database +func AddDebt(db *database.DB, creditor string, balance, apr, payment float64, mode SortMode) error { + var customOrder sql.NullInt64 + + // If manual mode, assign next custom_order + if mode == SortModeManual { + var maxOrder int + err := db.QueryRow("SELECT COALESCE(MAX(custom_order), 0) FROM debts").Scan(&maxOrder) + if err != nil { + return err + } + customOrder = sql.NullInt64{Int64: int64(maxOrder + 1), Valid: true} + } + + _, err := db.Exec(` + INSERT INTO debts (creditor, original_balance, current_balance, apr, minimum_payment, custom_order) + VALUES (?, ?, ?, ?, ?, ?)`, + creditor, balance, balance, apr, payment, customOrder) + + return err +} + +// GetAllDebts retrieves all active debts (with balance > 0) sorted by the given mode +func GetAllDebts(db *database.DB, mode SortMode) ([]Debt, error) { + var query string + + // Use explicit query selection instead of string concatenation to prevent SQL injection + switch mode { + case SortModeSnowball: + query = "SELECT id, creditor, original_balance, current_balance, apr, minimum_payment, custom_order, created_at, updated_at FROM debts WHERE current_balance > 0 ORDER BY current_balance ASC" + case SortModeAvalanche: + query = "SELECT id, creditor, original_balance, current_balance, apr, minimum_payment, custom_order, created_at, updated_at FROM debts WHERE current_balance > 0 ORDER BY apr DESC" + case SortModeManual: + query = "SELECT id, creditor, original_balance, current_balance, apr, minimum_payment, custom_order, created_at, updated_at FROM debts WHERE current_balance > 0 ORDER BY custom_order ASC" + default: + query = "SELECT id, creditor, original_balance, current_balance, apr, minimum_payment, custom_order, created_at, updated_at FROM debts WHERE current_balance > 0 ORDER BY current_balance ASC" + } + + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var debts []Debt + for rows.Next() { + var d Debt + err := rows.Scan(&d.ID, &d.Creditor, &d.OriginalBalance, &d.CurrentBalance, &d.APR, &d.MinimumPayment, &d.CustomOrder, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + debts = append(debts, d) + } + + return debts, rows.Err() +} + +// GetDebtByCreditor finds a debt by creditor name (case-insensitive) +func GetDebtByCreditor(db *database.DB, creditor string) (*Debt, error) { + var d Debt + err := db.QueryRow(` + SELECT id, creditor, original_balance, current_balance, apr, minimum_payment, custom_order, created_at, updated_at + FROM debts + WHERE LOWER(creditor) = LOWER(?)`, + creditor).Scan(&d.ID, &d.Creditor, &d.OriginalBalance, &d.CurrentBalance, &d.APR, &d.MinimumPayment, &d.CustomOrder, &d.CreatedAt, &d.UpdatedAt) + + if err != nil { + return nil, err + } + + return &d, nil +} + +// GetDebtByIndexOrName finds a debt by position (1-based index) or creditor name +// If identifier is a number, it finds the debt at that position in the sorted list +// Otherwise, it finds the debt by creditor name +func GetDebtByIndexOrName(db *database.DB, identifier string, mode SortMode) (*Debt, error) { + // Try to parse as integer (1-based index) + var index int + _, err := fmt.Sscanf(identifier, "%d", &index) + + if err == nil && index > 0 { + // It's a number - get debt by position + debts, err := GetAllDebts(db, mode) + if err != nil { + return nil, err + } + + if index > len(debts) { + return nil, fmt.Errorf("debt position %d not found (only %d debts)", index, len(debts)) + } + + return &debts[index-1], nil + } + + // Not a number - get by creditor name + return GetDebtByCreditor(db, identifier) +} + +// UpdateDebtBalance updates the current balance of a debt +func UpdateDebtBalance(db *database.DB, debtID int, newBalance float64) error { + _, err := db.Exec("UPDATE debts SET current_balance = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", newBalance, debtID) + return err +} + +// UpdateDebtAPR updates the APR of a debt +func UpdateDebtAPR(db *database.DB, creditor string, newAPR float64) error { + _, err := db.Exec("UPDATE debts SET apr = ?, updated_at = CURRENT_TIMESTAMP WHERE LOWER(creditor) = LOWER(?)", newAPR, creditor) + return err +} + +// UpdateDebtAmount updates the current balance of a debt by creditor name +func UpdateDebtAmount(db *database.DB, creditor string, newAmount float64) error { + _, err := db.Exec("UPDATE debts SET current_balance = ?, updated_at = CURRENT_TIMESTAMP WHERE LOWER(creditor) = LOWER(?)", newAmount, creditor) + return err +} + +// UpdateDebtOrder updates the custom order of a debt (manual mode only) +func UpdateDebtOrder(db *database.DB, creditor string, order int) error { + _, err := db.Exec("UPDATE debts SET custom_order = ?, updated_at = CURRENT_TIMESTAMP WHERE LOWER(creditor) = LOWER(?)", order, creditor) + return err +} + +// RemoveDebt deletes a debt by creditor name +func RemoveDebt(db *database.DB, creditor string) error { + result, err := db.Exec("DELETE FROM debts WHERE LOWER(creditor) = LOWER(?)", creditor) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return fmt.Errorf("debt '%s' not found", creditor) + } + + return nil +} + +// SetManualOrdering assigns sequential custom_order to all debts in current sorted order +func SetManualOrdering(db *database.DB, currentMode SortMode) error { + debts, err := GetAllDebts(db, currentMode) + if err != nil { + return err + } + + for i, debt := range debts { + _, err := db.Exec("UPDATE debts SET custom_order = ? WHERE id = ?", i+1, debt.ID) + if err != nil { + return err + } + } + + return nil +} + +// ClearManualOrdering sets all custom_order values to NULL +func ClearManualOrdering(db *database.DB) error { + _, err := db.Exec("UPDATE debts SET custom_order = NULL") + return err +} diff --git a/internal/models/payment.go b/internal/models/payment.go new file mode 100644 index 0000000..ef9bb1d --- /dev/null +++ b/internal/models/payment.go @@ -0,0 +1,64 @@ +package models + +import ( + "time" + + "github.com/tryonlinux/dave/internal/database" +) + +// Payment represents a payment transaction +type Payment struct { + ID int + DebtID int + Amount float64 + PaymentDate time.Time + BalanceBefore float64 + BalanceAfter float64 + InterestPortion float64 + PrincipalPortion float64 + Notes string +} + +// RecordPayment inserts a payment record into the database with current timestamp +func RecordPayment(db *database.DB, debtID int, amount, balanceBefore, balanceAfter, interestPortion float64, notes string) error { + return RecordPaymentWithDate(db, debtID, amount, balanceBefore, balanceAfter, interestPortion, notes, time.Now()) +} + +// RecordPaymentWithDate inserts a payment record into the database with a specific date +func RecordPaymentWithDate(db *database.DB, debtID int, amount, balanceBefore, balanceAfter, interestPortion float64, notes string, paymentDate time.Time) error { + principalPortion := amount - interestPortion + + _, err := db.Exec(` + INSERT INTO payments (debt_id, amount, payment_date, balance_before, balance_after, interest_portion, principal_portion, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + debtID, amount, paymentDate, balanceBefore, balanceAfter, interestPortion, principalPortion, notes) + + return err +} + +// GetPaymentsByDebt retrieves all payments for a specific debt +func GetPaymentsByDebt(db *database.DB, debtID int) ([]Payment, error) { + rows, err := db.Query(` + SELECT id, debt_id, amount, payment_date, balance_before, balance_after, interest_portion, principal_portion, COALESCE(notes, '') + FROM payments + WHERE debt_id = ? + ORDER BY payment_date DESC`, + debtID) + + if err != nil { + return nil, err + } + defer rows.Close() + + var payments []Payment + for rows.Next() { + var p Payment + err := rows.Scan(&p.ID, &p.DebtID, &p.Amount, &p.PaymentDate, &p.BalanceBefore, &p.BalanceAfter, &p.InterestPortion, &p.PrincipalPortion, &p.Notes) + if err != nil { + return nil, err + } + payments = append(payments, p) + } + + return payments, rows.Err() +} diff --git a/internal/models/settings.go b/internal/models/settings.go new file mode 100644 index 0000000..6ee2e97 --- /dev/null +++ b/internal/models/settings.go @@ -0,0 +1,50 @@ +package models + +import ( + "strconv" + + "github.com/tryonlinux/dave/internal/database" +) + +// Settings represents application settings +type Settings struct { + SortMode SortMode + SnowballAmount float64 +} + +// GetSettings retrieves current application settings +func GetSettings(db *database.DB) (*Settings, error) { + var sortMode, snowballAmountStr string + + err := db.QueryRow("SELECT value FROM settings WHERE key = 'sort_mode'").Scan(&sortMode) + if err != nil { + return nil, err + } + + err = db.QueryRow("SELECT value FROM settings WHERE key = 'snowball_amount'").Scan(&snowballAmountStr) + if err != nil { + return nil, err + } + + snowballAmount, err := strconv.ParseFloat(snowballAmountStr, 64) + if err != nil { + return nil, err + } + + return &Settings{ + SortMode: SortMode(sortMode), + SnowballAmount: snowballAmount, + }, nil +} + +// SetSortMode updates the sort mode setting +func SetSortMode(db *database.DB, mode SortMode) error { + _, err := db.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'sort_mode'", string(mode)) + return err +} + +// SetSnowballAmount updates the snowball amount setting +func SetSnowballAmount(db *database.DB, amount float64) error { + _, err := db.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'snowball_amount'", strconv.FormatFloat(amount, 'f', 2, 64)) + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6d34032 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/tryonlinux/dave/cmd" + +func main() { + cmd.Execute() +} diff --git a/setup-demo.bat b/setup-demo.bat new file mode 100644 index 0000000..2141dd2 --- /dev/null +++ b/setup-demo.bat @@ -0,0 +1,28 @@ +@echo off +REM Demo data setup script for Dave + +echo Resetting database... +echo y | dave.exe reset + +echo. +echo Adding demo debts... +dave.exe add "Credit Card" 5000 18.5 150 +dave.exe add "Car Loan" 15000 5.5 350 +dave.exe add "Student Loan" 25000 4.2 200 +dave.exe add "Personal Loan" 3500 12.9 120 + +echo. +echo Setting snowball amount... +dave.exe snowball 500 + +echo. +echo Making some sample payments... +dave.exe pay "Credit Card" 500 +dave.exe pay "Personal Loan" 300 + +echo. +echo Demo data setup complete! +echo. +echo Running dave to show the table... +echo. +dave.exe diff --git a/setup-demo.sh b/setup-demo.sh new file mode 100644 index 0000000..18957f7 --- /dev/null +++ b/setup-demo.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Demo data setup script for Dave + +echo "Resetting database..." +echo "yes" | ./dave reset + +echo "" +echo "Adding demo debts..." +./dave add "Credit Card" 5000 18.5 150 +./dave add "Car Loan" 15000 5.5 350 +./dave add "Student Loan" 25000 4.2 200 +./dave add "Personal Loan" 3500 12.9 120 + +echo "" +echo "Setting snowball amount..." +./dave snowball 500 + +echo "" +echo "Making some sample payments..." +./dave pay "Credit Card" 500 +./dave pay "Personal Loan" 300 + +echo "" +echo "Demo data setup complete!" +echo "" +echo "Running dave to show the table..." +echo "" +./dave diff --git a/tests/calculator_test.go b/tests/calculator_test.go new file mode 100644 index 0000000..78e4bd5 --- /dev/null +++ b/tests/calculator_test.go @@ -0,0 +1,292 @@ +package tests + +import ( + "math" + "testing" + "time" + + "github.com/tryonlinux/dave/internal/calculator" + "github.com/tryonlinux/dave/internal/models" +) + +func TestCalculateMonthlyInterest(t *testing.T) { + tests := []struct { + name string + balance float64 + apr float64 + expected float64 + }{ + { + name: "Standard credit card rate", + balance: 1000.00, + apr: 18.5, + expected: 15.42, // 1000 * (18.5/100/12) = 15.416... + }, + { + name: "Zero interest", + balance: 5000.00, + apr: 0.0, + expected: 0.0, + }, + { + name: "Low interest rate", + balance: 10000.00, + apr: 3.5, + expected: 29.17, // 10000 * (3.5/100/12) = 29.166... + }, + { + name: "High balance, high rate", + balance: 25000.00, + apr: 24.99, + expected: 520.63, // 25000 * (24.99/100/12) = 520.625 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculator.CalculateMonthlyInterest(tt.balance, tt.apr) + if math.Abs(result-tt.expected) > 0.01 { + t.Errorf("CalculateMonthlyInterest(%v, %v) = %v, want %v", + tt.balance, tt.apr, result, tt.expected) + } + }) + } +} + +func TestProjectPayoffTimeline_SingleDebt(t *testing.T) { + debt := models.Debt{ + ID: 1, + Creditor: "Credit Card", + OriginalBalance: 5000.00, + CurrentBalance: 5000.00, + APR: 18.5, + MinimumPayment: 150.00, + } + + debts := []models.Debt{debt} + + t.Run("No snowball amount", func(t *testing.T) { + projections := calculator.ProjectPayoffTimeline(debts, 0.0) + + if len(projections) != 1 { + t.Fatalf("Expected 1 projection, got %d", len(projections)) + } + + proj := projections[0] + + // With $150/month payment on $5000 at 18.5%, should take around 48 months + if proj.MonthsToPayoff < 40 || proj.MonthsToPayoff > 55 { + t.Errorf("Expected ~48 months to payoff, got %d", proj.MonthsToPayoff) + } + + // Should be payable + if !proj.Payable { + t.Error("Debt should be payable") + } + + // Should have significant interest (around $2000+) + if proj.TotalInterest < 1800 || proj.TotalInterest > 2300 { + t.Errorf("Expected interest around $2000, got $%.2f", proj.TotalInterest) + } + }) + + t.Run("With snowball amount", func(t *testing.T) { + projections := calculator.ProjectPayoffTimeline(debts, 500.0) + + proj := projections[0] + + // With $650/month payment ($150 + $500), should pay off much faster + if proj.MonthsToPayoff < 8 || proj.MonthsToPayoff > 12 { + t.Errorf("Expected ~9 months to payoff with snowball, got %d", proj.MonthsToPayoff) + } + + // Interest should be much lower + if proj.TotalInterest < 300 || proj.TotalInterest > 500 { + t.Errorf("Expected interest around $400, got $%.2f", proj.TotalInterest) + } + }) + + t.Run("Unpayable debt (payment < interest)", func(t *testing.T) { + unpayableDebt := models.Debt{ + ID: 1, + Creditor: "High Interest", + OriginalBalance: 10000.00, + CurrentBalance: 10000.00, + APR: 25.0, + MinimumPayment: 50.00, // Only $50/month on $10k at 25% - won't cover interest + } + + projections := calculator.ProjectPayoffTimeline([]models.Debt{unpayableDebt}, 0.0) + + proj := projections[0] + + // Should be marked as unpayable + if proj.Payable { + t.Error("Debt should be unpayable when payment < monthly interest") + } + }) +} + +func TestProjectPayoffTimeline_MultipleDebts_Snowball(t *testing.T) { + debts := []models.Debt{ + { + ID: 1, + Creditor: "Small Card", + OriginalBalance: 1000.00, + CurrentBalance: 1000.00, + APR: 18.0, + MinimumPayment: 50.00, + }, + { + ID: 2, + Creditor: "Medium Card", + OriginalBalance: 3000.00, + CurrentBalance: 3000.00, + APR: 15.0, + MinimumPayment: 100.00, + }, + { + ID: 3, + Creditor: "Large Card", + OriginalBalance: 8000.00, + CurrentBalance: 8000.00, + APR: 12.0, + MinimumPayment: 200.00, + }, + } + + snowballAmount := 400.0 + + projections := calculator.ProjectPayoffTimeline(debts, snowballAmount) + + if len(projections) != 3 { + t.Fatalf("Expected 3 projections, got %d", len(projections)) + } + + // First debt (smallest) should pay off first + if projections[0].MonthsToPayoff >= projections[1].MonthsToPayoff { + t.Error("Smallest debt should pay off before medium debt in snowball method") + } + + // Medium debt should pay off before large debt + if projections[1].MonthsToPayoff >= projections[2].MonthsToPayoff { + t.Error("Medium debt should pay off before large debt") + } + + // All debts should be payable + for i, proj := range projections { + if !proj.Payable { + t.Errorf("Debt %d should be payable", i) + } + } +} + +func TestCalculateDebtFreeDate(t *testing.T) { + now := time.Now() + + t.Run("All debts payable", func(t *testing.T) { + projections := []calculator.DebtProjection{ + {MonthsToPayoff: 12, Payable: true}, + {MonthsToPayoff: 24, Payable: true}, + {MonthsToPayoff: 36, Payable: true}, + } + + debtFreeDate := calculator.CalculateDebtFreeDate(projections) + + // Should be 36 months from now (max of all debts) + expectedDate := now.AddDate(0, 36, 0) + + // Allow 1 day tolerance + diff := debtFreeDate.Sub(expectedDate) + if math.Abs(diff.Hours()) > 24 { + t.Errorf("Expected debt free date around %v, got %v", expectedDate, debtFreeDate) + } + }) + + t.Run("One unpayable debt", func(t *testing.T) { + projections := []calculator.DebtProjection{ + {MonthsToPayoff: 12, Payable: true}, + {MonthsToPayoff: 600, Payable: false}, + } + + debtFreeDate := calculator.CalculateDebtFreeDate(projections) + + // Should be far in the future (100 years) + yearsDiff := debtFreeDate.Year() - now.Year() + if yearsDiff < 50 { + t.Error("Unpayable debt should result in far future date") + } + }) +} + +func TestProjectPayoffTimeline_ZeroInterest(t *testing.T) { + debt := models.Debt{ + ID: 1, + Creditor: "0% Promo Card", + OriginalBalance: 3000.00, + CurrentBalance: 3000.00, + APR: 0.0, + MinimumPayment: 100.00, + } + + projections := calculator.ProjectPayoffTimeline([]models.Debt{debt}, 0.0) + + proj := projections[0] + + // Should take exactly 30 months (3000 / 100) + if proj.MonthsToPayoff != 30 { + t.Errorf("Expected exactly 30 months for 0%% interest, got %d", proj.MonthsToPayoff) + } + + // Should have zero interest + if proj.TotalInterest != 0.0 { + t.Errorf("Expected zero interest, got $%.2f", proj.TotalInterest) + } + + // Should be payable + if !proj.Payable { + t.Error("0% debt with payment should be payable") + } +} + +func TestProjectPayoffTimeline_CascadingSnowball(t *testing.T) { + // Test that when first debt is paid off, its payment gets added to snowball + debts := []models.Debt{ + { + ID: 1, + Creditor: "Quick Pay", + OriginalBalance: 500.00, + CurrentBalance: 500.00, + APR: 10.0, + MinimumPayment: 100.00, + }, + { + ID: 2, + Creditor: "Slower Pay", + OriginalBalance: 5000.00, + CurrentBalance: 5000.00, + APR: 12.0, + MinimumPayment: 150.00, + }, + } + + snowballAmount := 200.0 + + projections := calculator.ProjectPayoffTimeline(debts, snowballAmount) + + // First debt should pay off very quickly (500 / (100 + 200) ≈ 2 months with interest) + if projections[0].MonthsToPayoff > 3 { + t.Errorf("First debt should pay off in ~2 months, got %d", projections[0].MonthsToPayoff) + } + + // Second debt should benefit from cascading snowball + // After first debt pays off, snowball becomes 200 + 100 = 300 + // So second debt gets 150 (minimum) + 300 (snowball) = 450/month + // This should significantly reduce payoff time compared to just 150 + 200 = 350/month + + // Without cascading: 5000 at 12% with 350/month ≈ 16-17 months + // With cascading (300 snowball after month 2): should be faster + if projections[1].MonthsToPayoff > 15 { + t.Errorf("Second debt should benefit from cascading snowball, got %d months", projections[1].MonthsToPayoff) + } +} diff --git a/tests/models_test.go b/tests/models_test.go new file mode 100644 index 0000000..8056afd --- /dev/null +++ b/tests/models_test.go @@ -0,0 +1,640 @@ +package tests + +import ( + "testing" + "time" + + "github.com/tryonlinux/dave/internal/database" + "github.com/tryonlinux/dave/internal/models" +) + +// setupTestDB creates an in-memory database for testing +func setupTestDB(t *testing.T) *database.DB { + db, err := database.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + return db +} + +func TestAddDebt(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + t.Run("Add debt in snowball mode", func(t *testing.T) { + err := models.AddDebt(db, "Test Card", 1000.0, 15.0, 50.0, models.SortModeSnowball) + if err != nil { + t.Errorf("Failed to add debt: %v", err) + } + + // Verify debt was added + debt, err := models.GetDebtByCreditor(db, "Test Card") + if err != nil { + t.Errorf("Failed to retrieve debt: %v", err) + } + + if debt.Creditor != "Test Card" { + t.Errorf("Expected creditor 'Test Card', got '%s'", debt.Creditor) + } + + if debt.CurrentBalance != 1000.0 { + t.Errorf("Expected balance 1000.0, got %.2f", debt.CurrentBalance) + } + + if debt.OriginalBalance != 1000.0 { + t.Errorf("Expected original balance 1000.0, got %.2f", debt.OriginalBalance) + } + + // In snowball/avalanche mode, custom_order should be NULL + if debt.CustomOrder.Valid { + t.Error("Custom order should be NULL in snowball mode") + } + }) + + t.Run("Add debt in manual mode", func(t *testing.T) { + err := models.AddDebt(db, "Manual Card", 2000.0, 12.0, 75.0, models.SortModeManual) + if err != nil { + t.Errorf("Failed to add debt: %v", err) + } + + debt, err := models.GetDebtByCreditor(db, "Manual Card") + if err != nil { + t.Errorf("Failed to retrieve debt: %v", err) + } + + // In manual mode, custom_order should be set + if !debt.CustomOrder.Valid { + t.Error("Custom order should be set in manual mode") + } + + if debt.CustomOrder.Int64 < 1 { // Should have a valid order + t.Errorf("Expected custom order >= 1, got %d", debt.CustomOrder.Int64) + } + }) + + t.Run("Duplicate creditor name", func(t *testing.T) { + err := models.AddDebt(db, "Test Card", 500.0, 10.0, 25.0, models.SortModeSnowball) + if err == nil { + t.Error("Expected error when adding duplicate creditor, got nil") + } + }) +} + +func TestGetAllDebts_Sorting(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Add debts with different balances and rates + models.AddDebt(db, "High Balance", 10000.0, 5.0, 200.0, models.SortModeSnowball) + models.AddDebt(db, "Low Balance", 1000.0, 15.0, 50.0, models.SortModeSnowball) + models.AddDebt(db, "Medium Balance", 5000.0, 20.0, 100.0, models.SortModeSnowball) + + t.Run("Snowball mode sorting (lowest balance first)", func(t *testing.T) { + debts, err := models.GetAllDebts(db, models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debts: %v", err) + } + + if len(debts) != 3 { + t.Fatalf("Expected 3 debts, got %d", len(debts)) + } + + // Should be sorted by balance ascending + if debts[0].Creditor != "Low Balance" { + t.Errorf("First debt should be 'Low Balance', got '%s'", debts[0].Creditor) + } + + if debts[1].Creditor != "Medium Balance" { + t.Errorf("Second debt should be 'Medium Balance', got '%s'", debts[1].Creditor) + } + + if debts[2].Creditor != "High Balance" { + t.Errorf("Third debt should be 'High Balance', got '%s'", debts[2].Creditor) + } + }) + + t.Run("Avalanche mode sorting (highest rate first)", func(t *testing.T) { + debts, err := models.GetAllDebts(db, models.SortModeAvalanche) + if err != nil { + t.Fatalf("Failed to get debts: %v", err) + } + + // Should be sorted by APR descending + if debts[0].Creditor != "Medium Balance" { // 20% APR + t.Errorf("First debt should be 'Medium Balance' (highest APR), got '%s'", debts[0].Creditor) + } + + if debts[1].Creditor != "Low Balance" { // 15% APR + t.Errorf("Second debt should be 'Low Balance', got '%s'", debts[1].Creditor) + } + + if debts[2].Creditor != "High Balance" { // 5% APR + t.Errorf("Third debt should be 'High Balance' (lowest APR), got '%s'", debts[2].Creditor) + } + }) +} + +func TestUpdateDebtBalance(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + models.AddDebt(db, "Test Card", 1000.0, 15.0, 50.0, models.SortModeSnowball) + + debt, _ := models.GetDebtByCreditor(db, "Test Card") + + err := models.UpdateDebtBalance(db, debt.ID, 750.0) + if err != nil { + t.Errorf("Failed to update balance: %v", err) + } + + updated, err := models.GetDebtByCreditor(db, "Test Card") + if err != nil { + t.Errorf("Failed to retrieve updated debt: %v", err) + } + + if updated.CurrentBalance != 750.0 { + t.Errorf("Expected balance 750.0, got %.2f", updated.CurrentBalance) + } + + // Original balance should remain unchanged + if updated.OriginalBalance != 1000.0 { + t.Errorf("Original balance should remain 1000.0, got %.2f", updated.OriginalBalance) + } +} + +func TestRemoveDebt(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + models.AddDebt(db, "To Remove", 1000.0, 15.0, 50.0, models.SortModeSnowball) + + err := models.RemoveDebt(db, "To Remove") + if err != nil { + t.Errorf("Failed to remove debt: %v", err) + } + + // Should not be able to retrieve removed debt + _, err = models.GetDebtByCreditor(db, "To Remove") + if err == nil { + t.Error("Expected error when retrieving removed debt, got nil") + } +} + +func TestRemoveDebt_NotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + err := models.RemoveDebt(db, "Nonexistent") + if err == nil { + t.Error("Expected error when removing nonexistent debt, got nil") + } +} + +func TestUpdateDebtAPR(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + models.AddDebt(db, "Rate Test", 1000.0, 15.0, 50.0, models.SortModeSnowball) + + err := models.UpdateDebtAPR(db, "Rate Test", 12.5) + if err != nil { + t.Errorf("Failed to update APR: %v", err) + } + + debt, _ := models.GetDebtByCreditor(db, "Rate Test") + + if debt.APR != 12.5 { + t.Errorf("Expected APR 12.5, got %.2f", debt.APR) + } +} + +func TestSetManualOrdering(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Add debts in snowball mode + models.AddDebt(db, "First", 3000.0, 15.0, 100.0, models.SortModeSnowball) + models.AddDebt(db, "Second", 1000.0, 12.0, 50.0, models.SortModeSnowball) + models.AddDebt(db, "Third", 2000.0, 18.0, 75.0, models.SortModeSnowball) + + // In snowball mode, they're sorted by balance: Second, Third, First + err := models.SetManualOrdering(db, models.SortModeSnowball) + if err != nil { + t.Errorf("Failed to set manual ordering: %v", err) + } + + // Verify custom_order was set for all debts + debts, _ := models.GetAllDebts(db, models.SortModeManual) + + for i, debt := range debts { + if !debt.CustomOrder.Valid { + t.Errorf("Debt %s should have custom_order set", debt.Creditor) + } + + expectedOrder := int64(i + 1) + if debt.CustomOrder.Int64 != expectedOrder { + t.Errorf("Debt %s: expected order %d, got %d", debt.Creditor, expectedOrder, debt.CustomOrder.Int64) + } + } +} + +func TestClearManualOrdering(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Add debts in manual mode + models.AddDebt(db, "First", 1000.0, 15.0, 50.0, models.SortModeManual) + models.AddDebt(db, "Second", 2000.0, 12.0, 75.0, models.SortModeManual) + + // Clear ordering + err := models.ClearManualOrdering(db) + if err != nil { + t.Errorf("Failed to clear manual ordering: %v", err) + } + + // Verify custom_order is NULL for all debts + debts, _ := models.GetAllDebts(db, models.SortModeSnowball) + + for _, debt := range debts { + if debt.CustomOrder.Valid { + t.Errorf("Debt %s should have NULL custom_order after clearing", debt.Creditor) + } + } +} + +func TestSettings(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + t.Run("Get default settings", func(t *testing.T) { + settings, err := models.GetSettings(db) + if err != nil { + t.Fatalf("Failed to get settings: %v", err) + } + + if settings.SortMode != models.SortModeSnowball { + t.Errorf("Default sort mode should be snowball, got %s", settings.SortMode) + } + + if settings.SnowballAmount != 0.0 { + t.Errorf("Default snowball amount should be 0, got %.2f", settings.SnowballAmount) + } + }) + + t.Run("Set sort mode", func(t *testing.T) { + err := models.SetSortMode(db, models.SortModeAvalanche) + if err != nil { + t.Errorf("Failed to set sort mode: %v", err) + } + + settings, _ := models.GetSettings(db) + + if settings.SortMode != models.SortModeAvalanche { + t.Errorf("Expected avalanche mode, got %s", settings.SortMode) + } + }) + + t.Run("Set snowball amount", func(t *testing.T) { + err := models.SetSnowballAmount(db, 500.0) + if err != nil { + t.Errorf("Failed to set snowball amount: %v", err) + } + + settings, _ := models.GetSettings(db) + + if settings.SnowballAmount != 500.0 { + t.Errorf("Expected snowball amount 500.0, got %.2f", settings.SnowballAmount) + } + }) +} + +func TestRecordPayment(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + models.AddDebt(db, "Payment Test", 1000.0, 15.0, 50.0, models.SortModeSnowball) + debt, _ := models.GetDebtByCreditor(db, "Payment Test") + + t.Run("Record payment with current date", func(t *testing.T) { + err := models.RecordPayment(db, debt.ID, 200.0, 1000.0, 800.0, 12.5, "Test payment") + if err != nil { + t.Errorf("Failed to record payment: %v", err) + } + + payments, err := models.GetPaymentsByDebt(db, debt.ID) + if err != nil { + t.Errorf("Failed to get payments: %v", err) + } + + if len(payments) != 1 { + t.Fatalf("Expected 1 payment, got %d", len(payments)) + } + + payment := payments[0] + + if payment.Amount != 200.0 { + t.Errorf("Expected amount 200.0, got %.2f", payment.Amount) + } + + if payment.BalanceBefore != 1000.0 { + t.Errorf("Expected balance before 1000.0, got %.2f", payment.BalanceBefore) + } + + if payment.BalanceAfter != 800.0 { + t.Errorf("Expected balance after 800.0, got %.2f", payment.BalanceAfter) + } + + if payment.InterestPortion != 12.5 { + t.Errorf("Expected interest portion 12.5, got %.2f", payment.InterestPortion) + } + + expectedPrincipal := 200.0 - 12.5 + if payment.PrincipalPortion != expectedPrincipal { + t.Errorf("Expected principal portion %.2f, got %.2f", expectedPrincipal, payment.PrincipalPortion) + } + }) + + t.Run("Record payment with specific date", func(t *testing.T) { + specificDate := time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC) + + err := models.RecordPaymentWithDate(db, debt.ID, 100.0, 800.0, 700.0, 10.0, "Backdated payment", specificDate) + if err != nil { + t.Errorf("Failed to record payment with date: %v", err) + } + + payments, _ := models.GetPaymentsByDebt(db, debt.ID) + + // Should have 2 payments now + if len(payments) != 2 { + t.Fatalf("Expected 2 payments, got %d", len(payments)) + } + + // Payments are ordered by date DESC, so backdated one might be second + found := false + for _, p := range payments { + if p.Amount == 100.0 { + found = true + // Check date (allowing for timezone differences) + if p.PaymentDate.Year() != 2024 || p.PaymentDate.Month() != 6 || p.PaymentDate.Day() != 15 { + t.Errorf("Expected date 2024-06-15, got %v", p.PaymentDate) + } + } + } + + if !found { + t.Error("Backdated payment not found") + } + }) +} + +func TestGetDebtByCreditor_CaseInsensitive(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + models.AddDebt(db, "Test Card", 1000.0, 15.0, 50.0, models.SortModeSnowball) + + // Should find with different case + debt, err := models.GetDebtByCreditor(db, "test card") + if err != nil { + t.Errorf("Failed to find debt with lowercase: %v", err) + } + + if debt.Creditor != "Test Card" { + t.Errorf("Expected 'Test Card', got '%s'", debt.Creditor) + } + + // Should find with uppercase + debt, err = models.GetDebtByCreditor(db, "TEST CARD") + if err != nil { + t.Errorf("Failed to find debt with uppercase: %v", err) + } + + if debt.Creditor != "Test Card" { + t.Errorf("Expected 'Test Card', got '%s'", debt.Creditor) + } +} + +func TestGetDebtByIndexOrName(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Add debts in snowball mode + models.AddDebt(db, "Small Debt", 1000.0, 15.0, 50.0, models.SortModeSnowball) + models.AddDebt(db, "Medium Debt", 5000.0, 12.0, 150.0, models.SortModeSnowball) + models.AddDebt(db, "Large Debt", 10000.0, 8.0, 300.0, models.SortModeSnowball) + + t.Run("Get by index - snowball mode", func(t *testing.T) { + // In snowball mode, debts are sorted by balance ascending + // Position 1 should be Small Debt (1000) + debt, err := models.GetDebtByIndexOrName(db, "1", models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debt by index: %v", err) + } + + if debt.Creditor != "Small Debt" { + t.Errorf("Expected 'Small Debt', got '%s'", debt.Creditor) + } + + // Position 2 should be Medium Debt (5000) + debt, err = models.GetDebtByIndexOrName(db, "2", models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debt by index: %v", err) + } + + if debt.Creditor != "Medium Debt" { + t.Errorf("Expected 'Medium Debt', got '%s'", debt.Creditor) + } + + // Position 3 should be Large Debt (10000) + debt, err = models.GetDebtByIndexOrName(db, "3", models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debt by index: %v", err) + } + + if debt.Creditor != "Large Debt" { + t.Errorf("Expected 'Large Debt', got '%s'", debt.Creditor) + } + }) + + t.Run("Get by index - avalanche mode", func(t *testing.T) { + // In avalanche mode, debts are sorted by APR descending + // Position 1 should be Small Debt (15%) + debt, err := models.GetDebtByIndexOrName(db, "1", models.SortModeAvalanche) + if err != nil { + t.Fatalf("Failed to get debt by index: %v", err) + } + + if debt.Creditor != "Small Debt" { + t.Errorf("Expected 'Small Debt' (highest APR), got '%s'", debt.Creditor) + } + + // Position 2 should be Medium Debt (12%) + debt, err = models.GetDebtByIndexOrName(db, "2", models.SortModeAvalanche) + if err != nil { + t.Fatalf("Failed to get debt by index: %v", err) + } + + if debt.Creditor != "Medium Debt" { + t.Errorf("Expected 'Medium Debt', got '%s'", debt.Creditor) + } + }) + + t.Run("Get by name", func(t *testing.T) { + // Should still work by name + debt, err := models.GetDebtByIndexOrName(db, "Medium Debt", models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debt by name: %v", err) + } + + if debt.Creditor != "Medium Debt" { + t.Errorf("Expected 'Medium Debt', got '%s'", debt.Creditor) + } + + // Should be case-insensitive + debt, err = models.GetDebtByIndexOrName(db, "LARGE DEBT", models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debt by uppercase name: %v", err) + } + + if debt.Creditor != "Large Debt" { + t.Errorf("Expected 'Large Debt', got '%s'", debt.Creditor) + } + }) + + t.Run("Invalid index", func(t *testing.T) { + // Index 0 should fail + _, err := models.GetDebtByIndexOrName(db, "0", models.SortModeSnowball) + if err == nil { + t.Error("Expected error for index 0, got nil") + } + + // Index out of range should fail + _, err = models.GetDebtByIndexOrName(db, "10", models.SortModeSnowball) + if err == nil { + t.Error("Expected error for out of range index, got nil") + } + }) + + t.Run("Nonexistent name", func(t *testing.T) { + _, err := models.GetDebtByIndexOrName(db, "Nonexistent", models.SortModeSnowball) + if err == nil { + t.Error("Expected error for nonexistent debt, got nil") + } + }) +} + +func TestGetAllDebts_HidesPaidDebts(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Add debts + models.AddDebt(db, "Active Debt", 1000.0, 15.0, 50.0, models.SortModeSnowball) + models.AddDebt(db, "Paid Debt", 500.0, 12.0, 25.0, models.SortModeSnowball) + + // Pay off one debt + models.UpdateDebtAmount(db, "Paid Debt", 0.0) + + // Get all debts + debts, err := models.GetAllDebts(db, models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to get debts: %v", err) + } + + // Should only return active debts (balance > 0) + if len(debts) != 1 { + t.Errorf("Expected 1 active debt, got %d", len(debts)) + } + + if len(debts) > 0 && debts[0].Creditor != "Active Debt" { + t.Errorf("Expected 'Active Debt', got '%s'", debts[0].Creditor) + } +} + +func TestResetDatabase(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Add some debts + models.AddDebt(db, "Credit Card", 5000.0, 18.5, 150.0, models.SortModeSnowball) + models.AddDebt(db, "Car Loan", 15000.0, 5.5, 350.0, models.SortModeSnowball) + models.AddDebt(db, "Student Loan", 25000.0, 4.2, 200.0, models.SortModeSnowball) + + // Make some payments + debt1, _ := models.GetDebtByCreditor(db, "Credit Card") + debt2, _ := models.GetDebtByCreditor(db, "Car Loan") + + models.RecordPayment(db, debt1.ID, 200.0, 5000.0, 4800.0, 77.08, "Payment 1") + models.RecordPayment(db, debt2.ID, 500.0, 15000.0, 14500.0, 68.75, "Payment 2") + + // Change settings + models.SetSortMode(db, models.SortModeAvalanche) + models.SetSnowballAmount(db, 500.0) + + // Verify data exists before reset + debts, _ := models.GetAllDebts(db, models.SortModeAvalanche) + if len(debts) != 3 { + t.Errorf("Expected 3 debts before reset, got %d", len(debts)) + } + + payments1, _ := models.GetPaymentsByDebt(db, debt1.ID) + if len(payments1) != 1 { + t.Errorf("Expected 1 payment for debt1 before reset, got %d", len(payments1)) + } + + settings, _ := models.GetSettings(db) + if settings.SortMode != models.SortModeAvalanche { + t.Errorf("Expected avalanche mode before reset, got %s", settings.SortMode) + } + + if settings.SnowballAmount != 500.0 { + t.Errorf("Expected snowball amount 500.0 before reset, got %.2f", settings.SnowballAmount) + } + + // Perform reset (same operations as reset command) + _, err := db.Exec("DELETE FROM payments") + if err != nil { + t.Fatalf("Failed to delete payments: %v", err) + } + + _, err = db.Exec("DELETE FROM debts") + if err != nil { + t.Fatalf("Failed to delete debts: %v", err) + } + + err = models.SetSortMode(db, models.SortModeSnowball) + if err != nil { + t.Fatalf("Failed to reset sort mode: %v", err) + } + + err = models.SetSnowballAmount(db, 0.0) + if err != nil { + t.Fatalf("Failed to reset snowball amount: %v", err) + } + + // Verify everything is cleared + debtsAfter, _ := models.GetAllDebts(db, models.SortModeSnowball) + if len(debtsAfter) != 0 { + t.Errorf("Expected 0 debts after reset, got %d", len(debtsAfter)) + } + + // Verify payments are deleted (checking both debts) + paymentsAfter1, _ := models.GetPaymentsByDebt(db, debt1.ID) + if len(paymentsAfter1) != 0 { + t.Errorf("Expected 0 payments for debt1 after reset, got %d", len(paymentsAfter1)) + } + + paymentsAfter2, _ := models.GetPaymentsByDebt(db, debt2.ID) + if len(paymentsAfter2) != 0 { + t.Errorf("Expected 0 payments for debt2 after reset, got %d", len(paymentsAfter2)) + } + + // Verify settings are reset to defaults + settingsAfter, _ := models.GetSettings(db) + if settingsAfter.SortMode != models.SortModeSnowball { + t.Errorf("Expected snowball mode after reset, got %s", settingsAfter.SortMode) + } + + if settingsAfter.SnowballAmount != 0.0 { + t.Errorf("Expected snowball amount 0.0 after reset, got %.2f", settingsAfter.SnowballAmount) + } +}