Full Code of bscott/subtrackr for AI

main 6514c8b26861 cached
95 files
704.8 KB
188.7k tokens
432 symbols
1 requests
Download .txt
Showing preview only (741K chars total). Download the full file or copy to clipboard to get everything.
Repository: bscott/subtrackr
Branch: main
Commit: 6514c8b26861
Files: 95
Total size: 704.8 KB

Directory structure:
gitextract_9u87fcx7/

├── .beads/
│   ├── interactions.jsonl
│   └── issues.jsonl
├── .claude/
│   └── commands/
│       └── release.md
├── .dockerignore
├── .gitattributes
├── .github/
│   └── workflows/
│       ├── claude-code-review.yml
│       ├── claude.yml
│       ├── docker-publish.yml
│       └── test-build.yml
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── Dockerfile
├── LICENSE
├── MIGRATION_v0.3.0.md
├── Makefile
├── PLAN-login-settings.md
├── README.md
├── cmd/
│   ├── mcp/
│   │   └── main.go
│   └── migrate-dates/
│       └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── database/
│   │   ├── database.go
│   │   └── migrations.go
│   ├── handlers/
│   │   ├── auth.go
│   │   ├── category.go
│   │   ├── settings.go
│   │   ├── subscription.go
│   │   ├── subscription_test.go
│   │   └── url.go
│   ├── middleware/
│   │   └── auth.go
│   ├── models/
│   │   ├── category.go
│   │   ├── date_migration_audit.go
│   │   ├── date_migration_audit_test.go
│   │   ├── exchange_rate.go
│   │   ├── exchange_rate_test.go
│   │   ├── settings.go
│   │   ├── subscription.go
│   │   └── subscription_test.go
│   ├── repository/
│   │   ├── category.go
│   │   ├── exchange_rate.go
│   │   ├── settings.go
│   │   └── subscription.go
│   ├── service/
│   │   ├── category.go
│   │   ├── currency.go
│   │   ├── currency_integration_test.go
│   │   ├── currency_test.go
│   │   ├── email.go
│   │   ├── logo.go
│   │   ├── pushover.go
│   │   ├── pushover_test.go
│   │   ├── renewal_reminder_test.go
│   │   ├── session.go
│   │   ├── settings.go
│   │   ├── settings_test.go
│   │   ├── subscription.go
│   │   ├── webhook.go
│   │   └── webhook_test.go
│   └── version/
│       └── version.go
├── package.json
├── playwright.config.js
├── templates/
│   ├── analytics.html
│   ├── api-keys-list.html
│   ├── auth-message.html
│   ├── calendar.html
│   ├── categories-list.html
│   ├── dashboard.html
│   ├── error.html
│   ├── forgot-password-error.html
│   ├── forgot-password-success.html
│   ├── forgot-password.html
│   ├── form-errors.html
│   ├── login-error.html
│   ├── login.html
│   ├── reset-password-error.html
│   ├── reset-password-success.html
│   ├── reset-password.html
│   ├── settings.html
│   ├── smtp-message.html
│   ├── subscription-form.html
│   ├── subscription-list.html
│   └── subscriptions.html
├── test-api.sh
├── tests/
│   ├── example.spec.js
│   └── subscription-crud.spec.js
└── web/
    └── static/
        ├── category-management.js
        ├── css/
        │   └── themes.css
        ├── js/
        │   ├── darkmode.js
        │   ├── mobile-menu.js
        │   ├── sorting.js
        │   ├── theme-init.js
        │   └── themes.js
        └── manifest.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .beads/interactions.jsonl
================================================


================================================
FILE: .beads/issues.jsonl
================================================
{"id":"subtrackr-xyz-1fb","title":"Implement cancellation date email notifications (#88)","description":"Add email notification option for X days before cancellation date. User wants reminders to cancel subscriptions before renewal. Fits with Pushover notifications already added in v0.5.5.","status":"closed","priority":2,"issue_type":"feature","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:32:20.604827-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:42:20.912338-08:00","closed_at":"2026-02-01T18:42:20.912338-08:00","close_reason":"Issue #88 implementation complete: cancellation date notifications fully implemented"}
{"id":"subtrackr-xyz-255","title":"Add cancellation reminder fields to models","description":"Add CancellationReminders and CancellationReminderDays to NotificationSettings. Add LastCancellationReminderSent and LastCancellationReminderDate to Subscription model. Files: internal/models/settings.go, internal/models/subscription.go","status":"closed","priority":2,"issue_type":"task","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:33:40.632633-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:42:20.925084-08:00","closed_at":"2026-02-01T18:42:20.925084-08:00","close_reason":"Issue #88 implementation complete: cancellation date notifications fully implemented"}
{"id":"subtrackr-xyz-3gh","title":"Fix Tab and PWA icon missing (#84)","description":"Tab favicon and PWA icon are not displaying in Firefox and Safari. Need to configure proper favicon/manifest icons.","status":"closed","priority":2,"issue_type":"bug","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-01-22T18:21:09.631203-08:00","created_by":"Brian Scott","updated_at":"2026-01-22T18:26:02.363767-08:00","closed_at":"2026-01-22T18:26:02.363767-08:00","close_reason":"Implemented in v0.5.3"}
{"id":"subtrackr-xyz-46o","title":"Add CNY currency support (#95)","status":"closed","priority":2,"issue_type":"feature","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-11T11:36:30.831651-08:00","created_by":"Brian Scott","updated_at":"2026-02-11T11:38:35.298071-08:00","closed_at":"2026-02-11T11:38:35.298071-08:00","close_reason":"Added CNY currency to SupportedCurrencies, GetCurrencySymbol, and settings template"}
{"id":"subtrackr-xyz-4ma","title":"Add repository method for upcoming cancellations","description":"Add GetUpcomingCancellations(days int) method to query subscriptions with cancellation dates approaching. File: internal/repository/subscription.go","status":"closed","priority":2,"issue_type":"task","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:33:40.768444-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:42:20.95075-08:00","closed_at":"2026-02-01T18:42:20.95075-08:00","close_reason":"Issue #88 implementation complete: cancellation date notifications fully implemented"}
{"id":"subtrackr-xyz-5ls","title":"Add cancellation reminder service methods","description":"Add GetSubscriptionsNeedingCancellationReminders(), SendCancellationReminder() to subscription, email, and pushover services. Files: internal/service/subscription.go, email.go, pushover.go","status":"closed","priority":2,"issue_type":"task","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:33:40.827689-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:40:33.13865-08:00","closed_at":"2026-02-01T18:40:33.13865-08:00","close_reason":"Added SendCancellationReminder methods to email and pushover services","dependencies":[{"issue_id":"subtrackr-xyz-5ls","depends_on_id":"subtrackr-xyz-255","type":"blocks","created_at":"2026-02-01T18:33:58.079731-08:00","created_by":"Brian Scott"},{"issue_id":"subtrackr-xyz-5ls","depends_on_id":"subtrackr-xyz-4ma","type":"blocks","created_at":"2026-02-01T18:33:58.148998-08:00","created_by":"Brian Scott"}]}
{"id":"subtrackr-xyz-8gh","title":"Add cancellation reminder settings UI","description":"Add settings handlers and UI for cancellation reminder preferences. Files: internal/handlers/settings.go, templates/settings.html","status":"closed","priority":2,"issue_type":"task","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:33:40.941374-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:42:16.382596-08:00","closed_at":"2026-02-01T18:42:16.382596-08:00","close_reason":"Added cancellation reminder settings UI to handlers and template","dependencies":[{"issue_id":"subtrackr-xyz-8gh","depends_on_id":"subtrackr-xyz-uzq","type":"blocks","created_at":"2026-02-01T18:33:58.280161-08:00","created_by":"Brian Scott"}]}
{"id":"subtrackr-xyz-cqi","title":"Restore Backup feature (#107)","status":"closed","priority":1,"issue_type":"feature","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-04-15T18:58:37.02157-07:00","created_by":"Brian Scott","updated_at":"2026-04-15T19:14:38.025112-07:00","closed_at":"2026-04-15T19:14:38.025112-07:00","close_reason":"Implemented restore backup feature with replace/merge modes, UI in settings page"}
{"id":"subtrackr-xyz-lne","title":"Add SSE/HTTP transport for MCP server","description":"Currently the MCP server only supports stdio transport, requiring the AI client to spawn the process as a subprocess. Add SSE/HTTP transport option so the MCP server can run as an endpoint on the web server (e.g. /mcp), enabling remote AI clients to connect without needing local access to the binary or database. This would make Docker deployments much simpler for MCP integration.","status":"open","priority":4,"issue_type":"feature","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-11T16:05:44.116758-08:00","created_by":"Brian Scott","updated_at":"2026-02-11T16:05:53.016576-08:00"}
{"id":"subtrackr-xyz-lw5","title":"Add database migration for cancellation tracking","description":"Create migration to add last_cancellation_reminder_sent and last_cancellation_reminder_date columns to subscriptions table. File: internal/database/migrations.go","status":"closed","priority":2,"issue_type":"task","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:33:40.709756-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:42:20.938383-08:00","closed_at":"2026-02-01T18:42:20.938383-08:00","close_reason":"Issue #88 implementation complete: cancellation date notifications fully implemented","dependencies":[{"issue_id":"subtrackr-xyz-lw5","depends_on_id":"subtrackr-xyz-255","type":"blocks","created_at":"2026-02-01T18:33:57.997065-08:00","created_by":"Brian Scott"}]}
{"id":"subtrackr-xyz-tqp","title":"Remember sorting preference (#85)","description":"Persist user's subscription list sort preference to localStorage so it remembers their choice between sessions","status":"closed","priority":2,"issue_type":"feature","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-01-22T18:21:09.459913-08:00","created_by":"Brian Scott","updated_at":"2026-01-22T18:26:02.362337-08:00","closed_at":"2026-01-22T18:26:02.362337-08:00","close_reason":"Implemented in v0.5.3"}
{"id":"subtrackr-xyz-uzq","title":"Add cancellation reminder scheduler","description":"Add checkAndSendCancellationReminders() function and integrate with daily scheduler. File: cmd/server/main.go","status":"closed","priority":2,"issue_type":"task","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-02-01T18:33:40.884483-08:00","created_by":"Brian Scott","updated_at":"2026-02-01T18:41:11.679802-08:00","closed_at":"2026-02-01T18:41:11.679802-08:00","close_reason":"Added cancellation reminder scheduler and checker functions to main.go","dependencies":[{"issue_id":"subtrackr-xyz-uzq","depends_on_id":"subtrackr-xyz-5ls","type":"blocks","created_at":"2026-02-01T18:33:58.214037-08:00","created_by":"Brian Scott"}]}
{"id":"subtrackr-xyz-z1c","title":"Custom schedule intervals (#77, #75)","status":"closed","priority":2,"issue_type":"feature","owner":"191290+bscott@users.noreply.github.com","created_at":"2026-04-15T19:17:23.123268-07:00","created_by":"Brian Scott","updated_at":"2026-04-15T19:53:54.309102-07:00","closed_at":"2026-04-15T19:53:54.309102-07:00","close_reason":"Added custom schedule intervals with single dropdown UI - supports multi-year, biweekly, etc."}


================================================
FILE: .claude/commands/release.md
================================================
# Release $ARGUMENTS

Execute the SubTrackr release workflow for version $ARGUMENTS.

## Pre-flight

1. Verify you're on the correct branch: `git branch --show-current` should be `$ARGUMENTS`
2. If not, create and checkout: `git checkout -b $ARGUMENTS`
3. Run `gh release list --limit 1` to confirm the previous version

## Track Work

Create beads issues for each work item in this release:
```bash
bd create --title="Description (#GitHub-issue)" --type=feature --priority=2
```

## Build & Test

1. Run `go build ./cmd/server` to verify compilation
2. Run `gofmt -l .` to check formatting — fix any issues with `gofmt -w`
3. Run `go vet ./...` to check for issues
4. Run `go test ./...` to verify all tests pass

## Create Draft Release

```bash
gh release create $ARGUMENTS --draft --title "$ARGUMENTS - Title" --notes "release notes here"
```

Write meaningful release notes covering what's new, bug fixes, and technical changes.

## Commit & Push

1. Stage changed files: `git add <specific files>`
2. Commit with conventional format — NO AI attribution in commit messages:
   ```
   git commit -m "$ARGUMENTS - Release Title

   - Change 1
   - Change 2"
   ```
3. Push: `git push -u origin $ARGUMENTS`

## Create Pull Request

```bash
gh pr create --title "$ARGUMENTS - Title" --body "summary and test plan, Closes #issues"
```

## Comment on Issues

Notify issue reporters:
```bash
gh issue comment <number> --body "Fixed in PR #XX. Description."
```

## After Merge (user tells you to publish)

```bash
gh release edit $ARGUMENTS --draft=false
gh release view $ARGUMENTS
```

The published tag triggers the Docker build workflow automatically.


================================================
FILE: .dockerignore
================================================
# Git files
.git/
.gitignore
.github/

# Documentation
*.md
docs/
LICENSE
screenshots/

# Development files
docker-compose.yml
.dockerignore
Dockerfile
*.log
.env*

# Build artifacts
subtrackr
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
dist/

# Test files
*_test.go
testdata/
coverage.*

# IDE files
.vscode/
.idea/
*.swp
*.swo
*~

# OS files
Thumbs.db
.DS_Store

# Data directory
data/
*.db
*.sqlite
*.sqlite3

# Temporary files
*.tmp
*.temp
tmp/

# CI/CD
.github/

# Legacy files (no longer used)
node_modules/
src/
public/
package*.json
tsconfig.json
tailwind.config*
postcss.config*
rebuild.sh
remove-configs.sh

# Media files
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.ico

================================================
FILE: .gitattributes
================================================

# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads


================================================
FILE: .github/workflows/claude-code-review.yml
================================================
name: Claude Code Review

on:
  pull_request:
    types: [opened, synchronize, ready_for_review, reopened]
    # Optional: Only run on specific file changes
    # paths:
    #   - "src/**/*.ts"
    #   - "src/**/*.tsx"
    #   - "src/**/*.js"
    #   - "src/**/*.jsx"

jobs:
  claude-review:
    # Optional: Filter by PR author
    # if: |
    #   github.event.pull_request.user.login == 'external-contributor' ||
    #   github.event.pull_request.user.login == 'new-developer' ||
    #   github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
      issues: read
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Run Claude Code Review
        id: claude-review
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
          plugins: 'code-review@claude-code-plugins'
          prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
          # or https://code.claude.com/docs/en/cli-reference for available options



================================================
FILE: .github/workflows/claude.yml
================================================
name: Claude Code

on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  issues:
    types: [opened, assigned]
  pull_request_review:
    types: [submitted]

jobs:
  claude:
    if: |
      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
      issues: read
      id-token: write
      actions: read # Required for Claude to read CI results on PRs
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Run Claude Code
        id: claude
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

          # This is an optional setting that allows Claude to read CI results on PRs
          additional_permissions: |
            actions: read

          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
          # prompt: 'Update the pull request description to include a summary of changes.'

          # Optional: Add claude_args to customize behavior and configuration
          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
          # or https://code.claude.com/docs/en/cli-reference for available options
          # claude_args: '--allowed-tools Bash(gh pr:*)'



================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Build and Publish Docker Image

on:
  push:
    tags: [ 'v*' ]
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}

      - name: Extract version info
        id: version
        run: |
          if [[ "${{ github.ref }}" == refs/tags/* ]]; then
            GIT_TAG="${{ github.ref_name }}"
          else
            GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")
          fi
          GIT_COMMIT=$(git rev-parse --short HEAD)
          echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
          echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILDKIT_INLINE_CACHE=1
            GIT_TAG=${{ steps.version.outputs.tag }}
            GIT_COMMIT=${{ steps.version.outputs.commit }}

================================================
FILE: .github/workflows/test-build.yml
================================================
name: Test Build

on:
  pull_request:
    branches: [ main ]
    paths:
      - '**.go'
      - 'go.mod'
      - 'go.sum'
      - 'Dockerfile'
      - 'templates/**'
      - 'web/**'
      - '.github/workflows/**'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test-build:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.21'

      - name: Cache Go modules
        uses: actions/cache@v4
        with:
          path: |
            ~/go/pkg/mod
            ~/.cache/go-build
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-go-

      - name: Download dependencies
        run: go mod download

      - name: Verify dependencies
        run: go mod verify

      - name: Build application
        run: go build -v -o subtrackr cmd/server/main.go

      - name: Run tests
        run: go test -v ./...

      - name: Extract version info
        id: version
        run: |
          if [[ "${{ github.ref }}" == refs/tags/* ]]; then
            GIT_TAG="${{ github.ref_name }}"
          else
            GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")
          fi
          GIT_COMMIT=$(git rev-parse --short HEAD)
          echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
          echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Test Docker build
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64
          push: false
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILDKIT_INLINE_CACHE=1
            GIT_TAG=${{ steps.version.outputs.tag }}
            GIT_COMMIT=${{ steps.version.outputs.commit }}

================================================
FILE: .gitignore
================================================
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
subtrackr
main

# Beads (keep issues.jsonl and interactions.jsonl for syncing)
.beads/beads.db
.beads/config.yaml
.beads/metadata.json
.beads/README.md
.beads/.gitignore
.beads/export-state/

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work

# Database files
data/
*.db
*.db-shm
*.db-wal

# Environment variables
.env
.env.local
.env.production

# IDE files
.idea/
.vscode/
*.swp
*.swo
*~

# Plans (not committed yet)
plans/

# OS files
.DS_Store
Thumbs.db

# Log files
*.log

# Temporary files
tmp/
temp/

# Build directories
dist/
build/

# Docker volumes (if running locally)
docker-data/

# Project specific
product-spec.md
CleanShot*.png
.playwright-mcp/
node_modules/
server.log
server
*.db
data/
subtrackr

# Release notes (draft files, not committed)
RELEASE_NOTES*.md


================================================
FILE: AGENTS.md
================================================
# SubTrackr - Agent Documentation

## Project Overview

SubTrackr is a self-hosted subscription management application built with Go and HTMX. It helps users track subscriptions, visualize spending, and get renewal reminders.

## Architecture

### Tech Stack
- **Backend**: Go 1.21+ with Gin web framework
- **Database**: SQLite (GORM)
- **Frontend**: HTMX + Tailwind CSS
- **Deployment**: Docker & Docker Compose

### Project Structure

```
subtrackr-xyz/
├── cmd/
│   ├── server/          # Main server entry point
│   └── migrate-dates/   # Date migration utility
├── internal/
│   ├── config/          # Configuration management
│   ├── database/        # Database initialization and migrations
│   ├── handlers/        # HTTP request handlers (Gin handlers)
│   ├── middleware/      # HTTP middleware (auth, etc.)
│   ├── models/          # Data models (GORM models)
│   ├── repository/      # Data access layer
│   ├── service/         # Business logic layer
│   └── version/         # Version information
├── templates/           # HTML templates (HTMX)
├── web/static/          # Static assets (JS, CSS, images)
├── tests/               # Playwright E2E tests
└── data/                # SQLite database (gitignored)
```

### Key Components

#### 1. Server Entry Point (`cmd/server/main.go`)
- Initializes database, repositories, services, and handlers
- Sets up Gin router with templates
- Configures routes (web and API)
- Starts HTTP server

#### 2. Handlers (`internal/handlers/`)
- **subscription.go**: CRUD operations for subscriptions
- **settings.go**: SMTP config, Pushover config, notifications, API keys, currency, dark mode
- **category.go**: Category management

#### 3. Services (`internal/service/`)
- Business logic layer
- **subscription.go**: Subscription operations
- **settings.go**: Settings management
- **category.go**: Category operations
- **currency.go**: Currency conversion (Fixer.io integration)
- **email.go**: Email notification service (SMTP)
- **pushover.go**: Pushover notification service

#### 4. Models (`internal/models/`)
- GORM models:
  - `Subscription`: Main subscription entity
  - `Category`: Subscription categories
  - `Settings`: Application settings (key-value store)
  - `SMTPConfig`: Email configuration
  - `PushoverConfig`: Pushover notification configuration
  - `APIKey`: API authentication keys
  - `ExchangeRate`: Currency exchange rates

#### 5. Repository (`internal/repository/`)
- Data access layer using GORM
- Abstracts database operations

### Routing Structure

#### Web Routes (HTMX)
- `/` - Dashboard
- `/dashboard` - Dashboard
- `/subscriptions` - Subscription list
- `/analytics` - Analytics view
- `/settings` - Settings page
- `/form/subscription` - Subscription form modal

#### API Routes (HTMX)
- `/api/subscriptions` - Subscription CRUD
- `/api/stats` - Statistics
- `/api/export/*` - Data export
- `/api/settings/*` - Settings management
- `/api/categories` - Category management

#### Public API Routes (Require API Key)
- `/api/v1/subscriptions` - Subscription CRUD
- `/api/v1/stats` - Statistics
- `/api/v1/export/*` - Data export

### Database Schema

#### Subscriptions
- ID, Name, Cost, OriginalCurrency
- Schedule: Monthly, Annual, Weekly, Daily
- Status: Active, Cancelled, Paused, Trial
- CategoryID (foreign key)
- Dates: StartDate, RenewalDate, CancellationDate
- Additional: PaymentMethod, Account, URL, Notes, Usage

#### Categories
- ID, Name
- CreatedAt, UpdatedAt

#### Settings
- Key-value store for application settings
- Keys: `smtp_config`, `renewal_reminders`, `currency`, etc.

### Key Features

1. **Subscription Management**
   - CRUD operations
   - Multiple schedules (Monthly, Annual, Weekly, Daily)
   - Categories
   - Multi-currency support

2. **Email Notifications**
   - SMTP configuration with TLS/SSL support
   - STARTTLS for ports 2525, 8025, 587, 25, 80
   - Implicit TLS for ports 465, 8465, 443
   - Renewal reminders
   - High cost alerts

3. **Pushover Notifications**
   - Pushover API integration for mobile push notifications
   - User Key and Application Token configuration
   - Renewal reminders (same settings as email)
   - High cost alerts (same threshold as email)
   - Works alongside email notifications

4. **Currency Support**
   - USD, EUR, GBP, JPY, RUB, SEK, PLN, INR, CHF, BRL, COP, BDT
   - Optional Fixer.io integration for real-time rates
   - Automatic conversion display
   - BDT (Bangladeshi Taka) with ৳ symbol

5. **API Access**
   - API key authentication
   - RESTful endpoints
   - JSON responses

5. **Data Management**
   - CSV/JSON export
   - Backup functionality
   - Clear all data option

### Development Guidelines

#### Code Style
- Follow Go standard formatting (`go fmt`)
- Use meaningful variable and function names
- Add comments for exported functions
- Keep functions focused and small

#### Error Handling
- Return errors from functions, don't panic
- Log errors appropriately
- Provide user-friendly error messages in handlers

#### Testing
- Unit tests in `*_test.go` files
- E2E tests in `tests/` using Playwright
- Test API endpoints with `test-api.sh`

#### Database Migrations
- Migrations in `internal/database/migrations.go`
- Use GORM AutoMigrate for schema changes
- Test migrations on sample data

#### Frontend
- Use HTMX for dynamic updates
- Tailwind CSS for styling
- Dark mode support via class-based switching
- Mobile-responsive design

### Recent Changes

#### v0.5.3 - Sort Persistence and PWA Support
- Remember sorting preference (#85) - localStorage persistence
- Fix Tab and PWA icon missing (#84) - favicon, apple-touch-icon, manifest.json
- Input validation for sort parameters
- PWA meta tags on all HTML templates

#### v0.5.2 - Currency Improvements
- Enhanced currency support and conversion display

#### v0.5.1 - Dark Classic Theme and Calendar Fixes
- Dark classic theme option
- Calendar view improvements

#### v0.5.0 - Optional Login Support
- Optional authentication system
- Beautiful theme options

### Release Workflow

This project uses versioned branches for releases. See `CLAUDE.md` for the complete workflow.

**Quick Reference:**
1. Create versioned branch: `git checkout -b vX.Y.Z`
2. Track work with beads: `bd create`, `bd update`, `bd close`
3. Create draft release: `gh release create vX.Y.Z --draft`
4. Run code review agent before committing
5. Commit, push, create PR: `gh pr create`
6. Comment on GitHub issues: `gh issue comment`
7. Monitor CI: `gh run watch`
8. Merge PR: `gh pr merge --merge --delete-branch`
9. Publish release: `gh release edit vX.Y.Z --draft=false`

### Common Tasks

#### Adding a New Feature
1. Create/update model in `internal/models/`
2. Add repository methods in `internal/repository/`
3. Add service logic in `internal/service/`
4. Create handler in `internal/handlers/`
5. Add routes in `cmd/server/main.go`
6. Update templates if needed
7. Add tests

#### Adding a New Schedule Type
1. Update `Subscription.Schedule` validation in `internal/models/subscription.go`
2. Update `AnnualCost()` and `MonthlyCost()` methods
3. Update frontend templates to include new option
4. Update date calculation logic if needed

#### Adding a New Currency
1. Add currency code to `SupportedCurrencies` in `internal/service/currency.go`
2. Add currency symbol mapping in `GetCurrencySymbol()` in `internal/service/settings.go`
3. Add currency option to currency selection in `templates/settings.html`
4. Update exchange rate handling if using Fixer.io

#### Adding a New Notification Method
1. Create notification config model in `internal/models/settings.go`
2. Create notification service in `internal/service/` (e.g., `pushover.go`)
3. Add config save/get methods to `SettingsService`
4. Add handlers in `internal/handlers/settings.go`
5. Add UI in `templates/settings.html`
6. Update subscription handler to send notifications
7. Update renewal reminder scheduler in `cmd/server/main.go`

### Environment Variables

- `PORT` - Server port (default: 8080)
- `DATABASE_PATH` - SQLite database path (default: ./data/subtrackr.db)
- `GIN_MODE` - Gin mode: debug/release (default: debug)
- `FIXER_API_KEY` - Fixer.io API key for currency conversion (optional)

### Building and Running

```bash
# Development
go run cmd/server/main.go

# Build
go build -o subtrackr cmd/server/main.go

# Docker
docker-compose up -d --build
```

### Testing

```bash
# Run Go tests
go test ./...

# Run E2E tests
npm test

# Test API
./test-api.sh
```


## Landing the Plane (Session Completion)

**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.

**MANDATORY WORKFLOW:**

1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
   ```bash
   git pull --rebase
   bd sync
   git push
   git status  # MUST show "up to date with origin"
   ```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session

**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds

<!-- BEGIN BEADS INTEGRATION -->
## Issue Tracking with bd (beads)

**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.

### Why bd?

- Dependency-aware: Track blockers and relationships between issues
- Git-friendly: Auto-syncs to JSONL for version control
- Agent-optimized: JSON output, ready work detection, discovered-from links
- Prevents duplicate tracking systems and confusion

### Quick Start

**Check for ready work:**

```bash
bd ready --json
```

**Create new issues:**

```bash
bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json
bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json
```

**Claim and update:**

```bash
bd update bd-42 --status in_progress --json
bd update bd-42 --priority 1 --json
```

**Complete work:**

```bash
bd close bd-42 --reason "Completed" --json
```

### Issue Types

- `bug` - Something broken
- `feature` - New functionality
- `task` - Work item (tests, docs, refactoring)
- `epic` - Large feature with subtasks
- `chore` - Maintenance (dependencies, tooling)

### Priorities

- `0` - Critical (security, data loss, broken builds)
- `1` - High (major features, important bugs)
- `2` - Medium (default, nice-to-have)
- `3` - Low (polish, optimization)
- `4` - Backlog (future ideas)

### Workflow for AI Agents

1. **Check ready work**: `bd ready` shows unblocked issues
2. **Claim your task**: `bd update <id> --status in_progress`
3. **Work on it**: Implement, test, document
4. **Discover new work?** Create linked issue:
   - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:<parent-id>`
5. **Complete**: `bd close <id> --reason "Done"`

### Auto-Sync

bd automatically syncs with git:

- Exports to `.beads/issues.jsonl` after changes (5s debounce)
- Imports from JSONL when newer (e.g., after `git pull`)
- No manual export/import needed!

### Important Rules

- ✅ Use bd for ALL task tracking
- ✅ Always use `--json` flag for programmatic use
- ✅ Link discovered work with `discovered-from` dependencies
- ✅ Check `bd ready` before asking "what should I work on?"
- ❌ Do NOT create markdown TODO lists
- ❌ Do NOT use external issue trackers
- ❌ Do NOT duplicate tracking systems

For more details, see README.md and docs/QUICKSTART.md.

<!-- END BEADS INTEGRATION -->


================================================
FILE: CLAUDE.md
================================================
# SubTrackr - Claude Code Instructions

## Release Workflow

This project uses versioned branches for releases. Follow this workflow when working on new features or bug fixes.

### 1. Create a Versioned Branch

```bash
# Check current version
gh release list --limit 1

# Create and checkout versioned branch
git checkout -b v0.X.Y
```

### 2. Track Work with Beads

```bash
# Create beads issues for work items
bd create --title="Feature description (#GitHub-issue)" --type=feature --priority=2

# Update status when starting work
bd update <issue-id> --status=in_progress

# Close when complete
bd close <issue-id> --reason="Implemented in vX.Y.Z"
```

### 3. Create Draft Release Before Committing

```bash
# Create draft release with release notes
gh release create vX.Y.Z --draft --title "vX.Y.Z - Release Title" --notes "$(cat <<'EOF'
## What's New

### Feature Name (#issue)
- Description of changes

## Technical Changes
- List of technical changes
EOF
)"
```

### 4. Code Review

Before committing, run the code review agent:
- Check for code quality issues
- Verify security concerns
- Ensure best practices

### 5. Commit and Push

```bash
# Stage changes
git add <files>

# Commit with descriptive message
git commit -m "vX.Y.Z - Release Title

- Change 1
- Change 2"

# Push branch
git push -u origin vX.Y.Z
```

### 6. Create Pull Request

```bash
gh pr create --title "vX.Y.Z - Release Title" --body "$(cat <<'EOF'
## Summary
- Change summary

## Test Plan
- [ ] Test item 1
- [ ] Test item 2

Closes #issue1
Closes #issue2
EOF
)"
```

### 7. Comment on GitHub Issues

```bash
# Notify issue reporters
gh issue comment <issue-number> --body "Fixed in PR #XX. Description of fix."
```

### 8. Monitor CI and Merge

```bash
# Watch GitHub Actions
gh run watch <run-id> --exit-status

# Merge when CI passes
gh pr merge <pr-number> --merge --delete-branch

# Switch to main
git checkout main && git pull
```

### 9. Publish Release

```bash
# Publish the draft release
gh release edit vX.Y.Z --draft=false

# Verify
gh release view vX.Y.Z
```

## Beads Integration

This project uses beads for local issue tracking across sessions.

### Files
- `.beads/issues.jsonl` - Issue data (committed)
- `.beads/interactions.jsonl` - Audit log (committed)
- `.beads/beads.db` - Local cache (gitignored)

### Commands
- `bd ready` - Find available work
- `bd create` - Create new issue
- `bd update` - Update issue status
- `bd close` - Close completed issues
- `bd sync --from-main` - Sync from main branch

## Git Commit Guidelines

- Do not include AI attribution in commit messages
- Use conventional commit format
- Keep messages clear and descriptive
- Reference GitHub issue numbers where applicable


================================================
FILE: Dockerfile
================================================
# Build stage
FROM golang:1.24 AS builder

# Install build dependencies
RUN apt-get update && apt-get install -y \
    gcc \
    libc6-dev \
    libsqlite3-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy go mod files first for better caching
COPY go.mod go.sum ./
RUN go mod download && go mod verify

# Copy only necessary source directories
COPY cmd/ ./cmd/
COPY internal/ ./internal/

# Build arguments for version info (should be provided by CI/CD)
ARG GIT_TAG=dev
ARG GIT_COMMIT=unknown

# Build the application with optimizations and version info
# Use build args directly - no need for .git directory
RUN CGO_ENABLED=1 GOOS=linux go build \
    -ldflags="-w -s -X 'subtrackr/internal/version.Version=${GIT_TAG}' -X 'subtrackr/internal/version.GitCommit=${GIT_COMMIT}'" \
    -o subtrackr ./cmd/server

# Build the MCP server binary
RUN CGO_ENABLED=1 GOOS=linux go build \
    -ldflags="-w -s -X 'subtrackr/internal/version.Version=${GIT_TAG}' -X 'subtrackr/internal/version.GitCommit=${GIT_COMMIT}'" \
    -o subtrackr-mcp ./cmd/mcp

# Final stage
FROM debian:bookworm-slim

# Install runtime dependencies in a single layer
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    curl \
    sqlite3 \
    tzdata \
    && rm -rf /var/lib/apt/lists/* \
    && mkdir -p /app/data

WORKDIR /app

# Copy the binaries from builder
COPY --from=builder /app/subtrackr .
COPY --from=builder /app/subtrackr-mcp .

# Copy templates and static assets
COPY templates/ ./templates/
COPY web/ ./web/

# Expose port
EXPOSE 8080

# Set environment variables
ENV GIN_MODE=release
ENV DATABASE_PATH=/app/data/subtrackr.db

# Healthcheck to verify the application is running and database is accessible
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8080/healthz || exit 1

# Run the application
CMD ["./subtrackr"]

================================================
FILE: LICENSE
================================================
                    GNU AFFERO GENERAL PUBLIC LICENSE
                       Version 3, 19 November 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.

  A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate.  Many developers of free software are heartened and
encouraged by the resulting cooperation.  However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.

  The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community.  It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server.  Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.

  An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals.  This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU Affero General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Remote Network Interaction; Use with the GNU General Public License.

  Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software.  This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time.  Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published
    by the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

  If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source.  For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code.  There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.


================================================
FILE: MIGRATION_v0.3.0.md
================================================
# Migration Guide for SubTrackr v0.3.0

## Overview

SubTrackr v0.3.0 introduces a new dynamic categories system that replaces the previous hardcoded category strings with a flexible database-driven approach. This guide will help you migrate your existing installation to v0.3.0.

## What's New

- **Dynamic Categories**: Categories are now stored in a separate database table
- **Category Management UI**: Add, edit, and delete categories from the Settings page
- **Foreign Key Relationships**: Subscriptions now reference categories by ID
- **Additional Schedule Options**: Support for Weekly and Daily subscription schedules

## Migration Steps

### 1. Backup Your Data

Before upgrading, make sure to backup your existing data:

```bash
# From the SubTrackr settings page, use the "Create Backup" button
# Or use the API:
curl -H "Authorization: Bearer YOUR_API_KEY" \
  http://localhost:8080/api/backup > subtrackr_backup.json
```

### 2. Update to v0.3.0

```bash
# Pull the latest changes
git pull origin v0.3.0

# Or download the v0.3.0 release
```

### 3. Restart SubTrackr

When you restart SubTrackr after updating:

1. The database schema will automatically migrate
2. Default categories will be created: Entertainment, Productivity, Storage, Software, Fitness, Education, Food, Travel, Business, Other
3. Existing subscriptions will be mapped to the new category system

### 4. Verify Migration

After restarting:

1. Check that all your subscriptions are still visible
2. Verify that categories have been properly assigned
3. Visit Settings → Categories to manage your categories

## API Changes

If you're using the SubTrackr API, note these changes:

### Creating/Updating Subscriptions

**Before (v0.2.x):**
```json
{
  "name": "Netflix",
  "cost": 15.99,
  "schedule": "Monthly",
  "status": "Active",
  "category": "Entertainment"
}
```

**After (v0.3.0):**
```json
{
  "name": "Netflix",
  "cost": 15.99,
  "schedule": "Monthly",
  "status": "Active",
  "category_id": 1
}
```

### Getting Category IDs

To get the list of available categories and their IDs:

```bash
curl http://localhost:8080/api/categories
```

## New Features

### Schedule Options

v0.3.0 adds support for Weekly and Daily schedules in addition to Monthly and Annual:

- **Weekly**: Billed every 7 days
- **Daily**: Billed every day

### Category Management

- Add custom categories for better organization
- Edit category names
- Delete unused categories (only if no subscriptions are using them)

## Troubleshooting

### Issue: Categories not showing after upgrade

**Solution**: The categories should be automatically created on first run. If not, manually create them in Settings → Categories.

### Issue: API calls failing with category errors

**Solution**: Update your API calls to use `category_id` instead of `category`. Get the category IDs from the `/api/categories` endpoint.

### Issue: Cannot delete a category

**Solution**: Categories with active subscriptions cannot be deleted. First reassign or delete the subscriptions using that category.

## Need Help?

If you encounter any issues during migration:

1. Check the server logs for error messages
2. Restore from your backup if needed
3. Report issues at: https://github.com/bscott/subtrackr/issues

================================================
FILE: Makefile
================================================
# Variables
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS := -X 'subtrackr/internal/version.GitCommit=$(GIT_COMMIT)' -X 'subtrackr/internal/version.Version=$(GIT_TAG)'

# Default target
.PHONY: all
all: build

# Build the application
.PHONY: build
build:
	go build -ldflags "$(LDFLAGS)" -o subtrackr cmd/server/main.go

# Run the application
.PHONY: run
run: build
	./subtrackr

# Clean build artifacts
.PHONY: clean
clean:
	rm -f subtrackr

# Development mode with live reload (requires air)
.PHONY: dev
dev:
	air

# Run tests
.PHONY: test
test:
	go test ./...

# Run go vet
.PHONY: vet
vet:
	go vet ./...

# Run go fmt
.PHONY: fmt
fmt:
	go fmt ./...

# Build for multiple platforms
.PHONY: build-all
build-all:
	GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/subtrackr-darwin-amd64 cmd/server/main.go
	GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/subtrackr-darwin-arm64 cmd/server/main.go
	GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/subtrackr-linux-amd64 cmd/server/main.go
	GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/subtrackr-linux-arm64 cmd/server/main.go
	GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/subtrackr-windows-amd64.exe cmd/server/main.go

.PHONY: help
help:
	@echo "Available targets:"
	@echo "  make build    - Build the application with git commit SHA"
	@echo "  make run      - Build and run the application"
	@echo "  make clean    - Remove build artifacts"
	@echo "  make test     - Run tests"
	@echo "  make vet      - Run go vet"
	@echo "  make fmt      - Format code"
	@echo "  make build-all - Build for multiple platforms"
	@echo "  make help     - Show this help message"

================================================
FILE: PLAN-login-settings.md
================================================
# Plan: Optional Login Support in Settings

## Overview

Add optional authentication to SubTrackr that can be enabled/disabled from the Settings menu. This must be backward-compatible with existing single-user installations.

---

## Confirmed Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Login toggle location | Settings page | All config in one place |
| Default state | **OFF** | No breaking changes for existing/new installs |
| Scope | Single-user auth | Self-hosted personal tool, no multi-user needed |

---

## Current State Analysis

### What Exists
- **No authentication**: App assumes single user, all routes public
- **API Key auth**: Already exists for `/api/v1/*` routes (external access)
- **Settings infrastructure**: Key-value store in SQLite, well-structured service layer
- **Repository pattern**: Clean separation of concerns ready for extension

### Key Files to Modify
- `internal/handlers/settings.go` - Add login settings handlers
- `internal/service/settings.go` - Add auth settings management
- `internal/middleware/auth.go` - Extend with session-based auth
- `internal/database/migrations.go` - Add user table migration
- `internal/models/` - Add User model
- `templates/settings.html` - Add login configuration section
- `cmd/server/main.go` - Conditional middleware application

---

## Design Decisions

### 1. Authentication Model: **Optional Single-User Auth**

**Rationale**: SubTrackr is designed as a self-hosted personal tool. Multi-user support adds complexity without clear benefit.

**Approach**:
- Single admin account (username + password)
- No user registration - admin sets credentials in settings
- Session-based auth using secure cookies
- Login can be enabled/disabled at any time

### 2. Settings-Based Toggle

**New Settings Keys**:
```
auth_enabled            (bool)   - Master toggle for login requirement
auth_username           (string) - Admin username
auth_password_hash      (string) - bcrypt hash of password
auth_session_secret     (string) - Secret for signing session cookies
auth_reset_token        (string) - Temporary password reset token (cleared after use)
auth_reset_token_expiry (string) - Reset token expiration timestamp
```

### 3. State Diagram

```
┌─────────────────────────────────────────────────────────────┐
│                    INSTALLATION STATES                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  [Existing Install]              [New Install]               │
│        │                              │                      │
│        ▼                              ▼                      │
│  auth_enabled = false           auth_enabled = false         │
│  (no credentials set)           (no credentials set)         │
│        │                              │                      │
│        │  User enables auth           │                      │
│        │  in Settings                 │                      │
│        ▼                              ▼                      │
│  ┌──────────────┐              ┌──────────────┐             │
│  │ Setup Mode   │              │ Setup Mode   │             │
│  │ - Set user   │              │ - Set user   │             │
│  │ - Set pass   │              │ - Set pass   │             │
│  └──────────────┘              └──────────────┘             │
│        │                              │                      │
│        ▼                              ▼                      │
│  auth_enabled = true            auth_enabled = true          │
│  (credentials set)              (credentials set)            │
│        │                              │                      │
│        ▼                              ▼                      │
│  All routes protected           All routes protected         │
│  Login page required            Login page required          │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```

---

## Impact on Existing Installations

### Zero Breaking Changes Guarantee

| Scenario | Current Behavior | After Update |
|----------|------------------|--------------|
| Fresh install | No auth | No auth (unchanged) |
| Existing install | No auth | No auth (unchanged) |
| User enables auth | N/A | Prompted to set credentials |
| User disables auth | N/A | Returns to open access |

### Migration Strategy

1. **No automatic migration** - auth stays disabled by default
2. **No forced password creation** - user must opt-in
3. **Settings page accessible** - even without auth, settings remain accessible to allow setup
4. **Graceful fallback** - if session expires, redirect to login (not error)

---

## Implementation Approach (Confirmed: Settings-First)

**Flow**:
1. Add "Security" section to Settings page
2. Toggle "Require Login" is **OFF by default**
3. **Prerequisite check**: SMTP must be configured before login can be enabled
4. When user enables toggle, form expands to set username/password
5. After credentials saved, auth middleware activates
6. User must login on next page navigation

**SMTP Prerequisite Requirement**:
- Login toggle is disabled/grayed out until SMTP is configured and tested
- Shows message: "Configure email settings above to enable password recovery"
- This ensures users always have a "Forgot Password" recovery path
- Prevents lockout scenarios where user has no way to reset password

**Benefits**:
- All configuration in one place (no env vars required)
- No separate setup wizard needed
- Easy to disable if locked out (just toggle off)
- Zero impact on existing installations until user opts in
- **Password recovery always available** via email

**Optional: Environment Variable Override** (for advanced users)

For Docker deployments where UI access isn't preferred:
```
AUTH_ENABLED=true|false     # Override toggle (optional)
AUTH_USERNAME=admin         # Only used with AUTH_ENABLED=true
AUTH_PASSWORD=securepass    # Hashed on first server start
```
When env vars are set, Settings UI shows read-only status.

---

## Security Considerations

### Password Storage
- **bcrypt** with cost factor 12+
- Never store plain text passwords
- Environment variable passwords hashed on first server start

### Session Management
- **Secure cookies** with HttpOnly, SameSite=Strict
- Session timeout: 24 hours (configurable)
- Session secret auto-generated if not provided
- CSRF protection via SameSite cookies + HTMX headers

### Protected Routes (when auth enabled)
```
Protected:
  /                    - Dashboard
  /subscriptions       - Subscription list
  /analytics           - Analytics
  /calendar            - Calendar
  /api/subscriptions/* - Internal API
  /api/settings/*      - Settings API (except login)

Unprotected:
  /login               - Login page
  /api/auth/login      - Login endpoint
  /api/v1/*            - External API (uses API keys)
  /static/*            - Static assets
```

### Lockout Recovery

**Problem**: User forgets password, locked out of app

**Solutions** (in order of preference):
1. **Forgot Password email** (primary): Click "Forgot Password" on login page, receive reset link via SMTP
2. **CLI reset command** (Docker-friendly): Run container with `--reset-password` flag
3. **Environment override**: Set `AUTH_PASSWORD=newpassword` and restart
4. **Database direct edit**: Delete `auth_password_hash` row from settings table
5. **Data directory backup/restore**: Restore from backup without auth

**Note**: SMTP is required before enabling login, ensuring option #1 is always available.

---

## CLI Password Reset Option

For Docker deployments where direct database access is inconvenient, provide a CLI flag to reset credentials.

### Usage

```bash
# Reset password interactively (prompts for new password)
docker exec -it subtrackr /app/subtrackr --reset-password

# Reset password non-interactively (for scripts)
docker exec -it subtrackr /app/subtrackr --reset-password --new-password "newsecurepass"

# Disable authentication entirely (removes all auth settings)
docker exec -it subtrackr /app/subtrackr --disable-auth
```

### Docker Compose Example

```yaml
# One-time password reset (run separately, not in main compose)
docker compose run --rm subtrackr --reset-password
```

### Implementation Details

**New CLI flags** (in `cmd/server/main.go`):
```
--reset-password       Resets admin password (interactive or with --new-password)
--new-password <pass>  New password (used with --reset-password, skips prompt)
--disable-auth         Disables authentication, removes credentials
```

**Behavior**:
1. Parse flags before starting HTTP server
2. If reset flag present:
   - Connect to database
   - Prompt for new password (or use --new-password value)
   - Hash with bcrypt and update `auth_password_hash` setting
   - Print success message and exit (don't start server)
3. If disable-auth flag present:
   - Delete all `auth_*` settings from database
   - Print confirmation and exit

**Security considerations**:
- `--new-password` in process list is visible; recommend interactive mode when possible
- These flags only work with direct container access (not exposed via API)
- Log password reset events for audit trail

---

## Database Changes

### No New Tables Required

Using existing `settings` table for auth configuration:

```sql
-- New settings rows (only created when auth enabled)
INSERT INTO settings (key, value) VALUES
  ('auth_enabled', 'true'),
  ('auth_username', 'admin'),
  ('auth_password_hash', '$2a$12$...'),  -- bcrypt hash
  ('auth_session_secret', 'random-64-char-string');
```

**Why not a users table?**
- Single-user design doesn't need it
- Simpler migration path
- Settings table already handles typed values well
- Avoids foreign key complexity

---

## UI/UX Design

### Settings Page Addition

```
┌─────────────────────────────────────────────────────────────┐
│ Settings                                                     │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│ ▼ Data Management                                           │
│   [Export] [Backup] [Clear Data]                            │
│                                                              │
│ ▼ Email Notifications                                       │
│   [...existing SMTP settings...]                            │
│                                                              │
│ ▼ Security  ← NEW SECTION                                   │
│   ┌─────────────────────────────────────────────────────┐  │
│   │ Require Login                          [Toggle OFF] │  │
│   │                                                      │  │
│   │ ┌─ When enabled: ────────────────────────────────┐  │  │
│   │ │ Username: [________________]                    │  │  │
│   │ │ Password: [________________]                    │  │  │
│   │ │ Confirm:  [________________]                    │  │  │
│   │ │                                                 │  │  │
│   │ │ Session Timeout: [24] hours                     │  │  │
│   │ │                                                 │  │  │
│   │ │ [Save Credentials]                              │  │  │
│   │ └─────────────────────────────────────────────────┘  │  │
│   │                                                      │  │
│   │ ⓘ When login is required, you'll need to sign in    │  │
│   │   to access SubTrackr. API keys still work for      │  │
│   │   external integrations.                            │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
│ ▼ Appearance                                                │
│   Dark Mode [Toggle]                                        │
│                                                              │
│ ▼ Currency                                                  │
│   [...currency options...]                                  │
│                                                              │
│ ▼ API Keys                                                  │
│   [...existing API key management...]                       │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```

### Login Page Design

```
┌─────────────────────────────────────────────────────────────┐
│                                                              │
│                      SubTrackr Logo                          │
│                                                              │
│              ┌──────────────────────────┐                   │
│              │ Username                 │                   │
│              │ [____________________]   │                   │
│              │                          │                   │
│              │ Password                 │                   │
│              │ [____________________]   │                   │
│              │                          │                   │
│              │ [ ] Remember me          │                   │
│              │                          │                   │
│              │      [  Sign In  ]       │                   │
│              │                          │                   │
│              │    [Forgot Password?]    │                   │
│              └──────────────────────────┘                   │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```

### Forgot Password Page Design

```
┌─────────────────────────────────────────────────────────────┐
│                                                              │
│                      SubTrackr Logo                          │
│                                                              │
│              ┌──────────────────────────┐                   │
│              │ Reset Your Password      │                   │
│              │                          │                   │
│              │ A reset link will be     │                   │
│              │ sent to your configured  │                   │
│              │ email address.           │                   │
│              │                          │                   │
│              │   [  Send Reset Link  ]  │                   │
│              │                          │                   │
│              │   [Back to Login]        │                   │
│              └──────────────────────────┘                   │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```

---

## Implementation Steps

### Phase 1: Backend Foundation
1. Add bcrypt dependency for password hashing
2. Create auth settings methods in SettingsService
3. Implement session management (cookie-based)
4. Create login/logout handlers
5. Create auth middleware that checks session

### Phase 2: Settings UI
6. Add Security section to settings.html
7. Implement credential form with HTMX
8. Add toggle state management
9. Handle auth enable/disable flow

### Phase 3: Login Page & Password Reset
10. Create login.html template
11. Implement login form with HTMX
12. Add error handling (wrong password, etc.)
13. Add redirect after login
14. Create forgot-password.html template
15. Implement password reset email sending (uses existing EmailService)
16. Create reset-password.html template for setting new password
17. Handle reset token generation, validation, and expiration

### Phase 4: Route Protection
18. Apply auth middleware conditionally
19. Handle redirect to login for protected routes
20. Ensure API keys still work independently

### Phase 5: CLI Recovery Tools
21. Add `--reset-password` flag to main.go
22. Add `--new-password` flag for non-interactive reset
23. Add `--disable-auth` flag to remove all auth settings
24. Implement interactive password prompt (when no --new-password)

### Phase 6: Testing & Edge Cases
25. Test existing installations (no regression)
26. Test enable/disable flow
27. Test password reset flow via email
28. Test CLI password reset (interactive and non-interactive)
29. Test lockout recovery scenarios
30. Test session timeout
31. Update documentation

---

## Open Questions

1. **Session storage**: In-memory (simple, lost on restart) vs SQLite (persistent)?
   - Recommendation: In-memory with "Remember me" extending cookie life

2. **Multiple failed login attempts**: Rate limiting?
   - Recommendation: Simple delay after 5 failed attempts

3. **Password requirements**: Minimum complexity?
   - Recommendation: Minimum 8 characters, no complexity rules (user's choice)

4. **HTTPS requirement**: Should auth require HTTPS?
   - Recommendation: Warn but allow HTTP (self-hosted often behind reverse proxy)

---

## Risk Assessment

| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| User locked out | Medium | High | Env var override, clear docs |
| Session hijacking | Low | Medium | Secure cookies, HTTPS warning |
| Brute force attack | Low | Medium | Rate limiting after failures |
| Regression in existing installs | Low | High | Comprehensive testing |
| Complexity creep | Medium | Medium | Keep single-user, no roles |

---

## Success Criteria

- [ ] Existing installations work unchanged after update
- [ ] Auth can be enabled from Settings with zero config files
- [ ] Login page is functional and styled consistently
- [ ] Sessions persist across server restarts (Remember me)
- [ ] Lockout recovery is documented and tested
- [ ] API keys continue working independently
- [ ] No performance impact when auth is disabled


================================================
FILE: README.md
================================================
# SubTrackr

A self-hosted subscription management application built with Go and HTMX. Track your subscriptions, visualize spending, and get renewal reminders.

![SubTrackr Dashboard](dashboard-screenshot.png)

![SubTrackr Calendar View](calendar-screenshot.png)

![SubTrackr Mobile View](mobile-screenshot.png)

## 🎨 Themes

Personalize your SubTrackr experience with 5 beautiful themes:

<table>
  <tr>
    <td align="center">
      <img src="screenshots/christmas.png" alt="Christmas Theme" width="600"/><br/>
      <b>Christmas 🎄</b><br/>
      Festive and jolly! (with snowfall animation)
    </td>
  </tr>
  <tr>
    <td align="center">
      <img src="screenshots/ocean.png" alt="Ocean Theme" width="600"/><br/>
      <b>Ocean</b><br/>
      Cool and refreshing
    </td>
  </tr>
  <tr>
    <td align="center">
      <img src="screenshots/login.png" alt="Login Page" width="600"/><br/>
      <b>Optional Authentication</b><br/>
      Secure your data with optional login support
    </td>
  </tr>
</table>

**Available themes:** Default (Light), Dark, Christmas 🎄, Midnight (Purple), Ocean (Cyan)

Themes persist across all pages and are saved per user. Change themes anytime from Settings → Appearance.

![Version](https://img.shields.io/github/v/release/bscott/subtrackr?logo=github&label=version)
![Go Version](https://img.shields.io/badge/go-%3E%3D1.21-00ADD8)
![License](https://img.shields.io/badge/license-AGPL--3.0-green)

## 🚀 Features

- 📊 **Dashboard Overview**: Real-time stats showing monthly/annual spending
- 💰 **Subscription Management**: Track all your subscriptions in one place with logos
- 📅 **Calendar View**: Visual calendar showing all subscription renewal dates with iCal export and subscription URL
- 📈 **Analytics**: Visualize spending by category and track savings
- 🔔 **Email Notifications**: Get reminders before subscriptions renew
- 📱 **Pushover Notifications**: Receive push notifications on your mobile device
- 📤 **Data Export**: Export your data as CSV, JSON, or iCal format
- 🎨 **Beautiful Themes**: 5 stunning themes including a festive Christmas theme with snowfall animation
- 🌍 **Multi-Currency Support**: Support for USD, EUR, GBP, JPY, RUB, SEK, PLN, INR, CHF, BRL, COP, BDT, and CNY (with optional real-time conversion)
- 🤖 **MCP Server**: AI integration via Model Context Protocol for Claude and other AI assistants
- 🐳 **Docker Ready**: Easy deployment with Docker
- 🔒 **Self-Hosted**: Your data stays on your server
- 📱 **Mobile Responsive**: Optimized mobile experience with hamburger menu navigation

## 🏗️ Tech Stack

- **Backend**: Go with Gin framework
- **Database**: SQLite (no external database needed!)
- **Frontend**: HTMX + Tailwind CSS
- **Deployment**: Docker & Docker Compose

## 🚀 Quick Start

SubTrackr is available as a multi-platform Docker image supporting both AMD64 and ARM64 architectures (including Apple Silicon).

**Note:** SubTrackr works fully out-of-the-box with no external dependencies. The Fixer.io API key is completely optional for currency conversion features.

### Option 1: Docker Compose (Recommended)

1. **Create docker-compose.yml**:

```yaml
version: '3.8'

services:
  subtrackr:
    image: ghcr.io/bscott/subtrackr:latest
    container_name: subtrackr
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app/data
    environment:
      - GIN_MODE=release
      - DATABASE_PATH=/app/data/subtrackr.db
      # Optional: Enable automatic currency conversion (requires Fixer.io API key)
      # - FIXER_API_KEY=your_fixer_api_key_here
    restart: unless-stopped
```

2. **Start the container**:

```bash
docker-compose up -d
```

3. **Access SubTrackr**: Open http://localhost:8080

### Option 2: Docker Run

```bash
docker run -d \
  --name subtrackr \
  -p 8080:8080 \
  -v $(pwd)/data:/app/data \
  -e GIN_MODE=release \
  ghcr.io/bscott/subtrackr:latest

# Optional: With currency conversion enabled
docker run -d \
  --name subtrackr \
  -p 8080:8080 \
  -v $(pwd)/data:/app/data \
  -e GIN_MODE=release \
  -e FIXER_API_KEY=your_fixer_api_key_here \
  ghcr.io/bscott/subtrackr:latest
```

### Option 3: Build from Source

1. **Clone the repository**:
```bash
git clone https://github.com/bscott/subtrackr.git
cd subtrackr
```

2. **Build and run with Docker Compose**:
```bash
docker-compose up -d --build
```

## 🐳 Deployment Guides

### Portainer

1. **Stack Deployment**:
   - Go to Stacks → Add Stack
   - Name: `subtrackr`
   - Paste the docker-compose.yml content
   - Deploy the stack

2. **Environment Variables** (optional):
   ```
   PORT=8080
   DATABASE_PATH=/app/data/subtrackr.db
   GIN_MODE=release
   ```

3. **Volumes**:
   - Create a volume named `subtrackr-data`
   - Mount to `/app/data` in the container

### Proxmox LXC Container

1. **Create LXC Container**:
   ```bash
   # Create container (Ubuntu 22.04)
   pct create 200 local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.gz \
     --hostname subtrackr \
     --memory 512 \
     --cores 1 \
     --net0 name=eth0,bridge=vmbr0,ip=dhcp \
     --storage local-lvm \
     --rootfs local-lvm:8
   ```

2. **Install Docker in LXC**:
   ```bash
   pct start 200
   pct enter 200
   
   # Update and install Docker
   apt update && apt upgrade -y
   curl -fsSL https://get.docker.com | sh
   ```

3. **Deploy SubTrackr**:
   ```bash
   mkdir -p /opt/subtrackr
   cd /opt/subtrackr
   
   # Create docker-compose.yml
   nano docker-compose.yml
   # Paste the docker-compose content
   
   docker-compose up -d
   ```

### Unraid

1. **Community Applications**:
   - Search for "SubTrackr" in CA
   - Configure paths and ports
   - Apply

2. **Manual Docker Template**:
   - Repository: `ghcr.io/bscott/subtrackr:latest`
   - Port: `8080:8080`
   - Path: `/app/data` → `/mnt/user/appdata/subtrackr`

### Synology NAS

1. **Using Docker Package**:
   - Open Docker package
   - Registry → Search "subtrackr"
   - Download latest image
   - Create container with port 8080 and volume mapping

2. **Using Container Manager** (DSM 7.2+):
   - Project → Create
   - Upload docker-compose.yml
   - Build and run

## 🔧 Configuration

### Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `8080` |
| `DATABASE_PATH` | SQLite database file path | `./data/subtrackr.db` |
| `GIN_MODE` | Gin framework mode (debug/release) | `debug` |
| `FIXER_API_KEY` | Fixer.io API key for currency conversion (optional) | None |

### Currency Conversion (Optional)

SubTrackr supports automatic currency conversion using Fixer.io exchange rates:

**Without API key:** (Fully functional)
- Basic multi-currency support with display symbols
- Manual currency selection per subscription
- Subscriptions displayed in their original currency
- No automatic conversion between currencies

**With Fixer.io API key:**
- Real-time exchange rates (cached for 24 hours)
- Automatic conversion between any supported currencies
- Display original amount + converted amount in your preferred currency

**Setup:**
1. Sign up for free at [Fixer.io](https://fixer.io/) (1000 requests/month)
2. Get your API key from the dashboard
3. Add `FIXER_API_KEY=your_key_here` to your environment variables
4. Restart SubTrackr - currency conversion will be automatically enabled

**Note:** The free Fixer.io plan only allows EUR as the base currency. SubTrackr automatically handles cross-rate calculations (e.g., USD→INR goes through EUR) so all currency conversions work correctly regardless of this limitation.

**Supported currencies:** USD, EUR, GBP, JPY, RUB, SEK, PLN, INR, CHF, BRL, COP, BDT, CNY

### Email Notifications (SMTP)

Configure SMTP settings in the web interface:

1. Navigate to Settings → Email Notifications
2. Enter your SMTP details:
   - **Gmail**: smtp.gmail.com:587
   - **Outlook**: smtp-mail.outlook.com:587
   - **Custom**: Your SMTP server details
3. Test connection
4. Enable renewal reminders

### Pushover Notifications

Receive push notifications on your mobile device via Pushover:

1. **Get your Pushover credentials**:
   - Sign up at [pushover.net](https://pushover.net/) (free account)
   - Get your User Key from the dashboard
   - Create an application at [pushover.net/apps/build](https://pushover.net/apps/build) to get an Application Token

2. **Configure in SubTrackr**:
   - Navigate to Settings → Pushover Notifications
   - Enter your User Key and Application Token
   - Click "Test Connection" to verify configuration
   - Save settings

3. **Notification Types**:
   - **Renewal Reminders**: Get notified before subscriptions renew (uses the same reminder days setting as email)
   - **High Cost Alerts**: Receive alerts when adding expensive subscriptions (uses the same threshold as email alerts)

**Note**: Pushover notifications work alongside email notifications. Both will be sent when enabled, giving you multiple ways to stay informed about your subscriptions.

### Data Persistence

**Important**: Always mount a volume to `/app/data` to persist your database!

```yaml
volumes:
  - ./data:/app/data  # Local directory
  # OR
  - subtrackr-data:/app/data  # Named volume
```

## 🔐 Security Recommendations

1. **Reverse Proxy**: Use Nginx/Traefik for HTTPS
2. **Authentication**: Add basic auth or OAuth2 proxy
3. **Network**: Don't expose port 8080 directly to internet
4. **Backups**: Regular backups of the data directory

### Nginx Reverse Proxy Example

```nginx
server {
    server_name subtrackr.yourdomain.com;
    
    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

### Traefik Labels

```yaml
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.subtrackr.rule=Host(`subtrackr.yourdomain.com`)"
  - "traefik.http.routers.subtrackr.entrypoints=websecure"
  - "traefik.http.routers.subtrackr.tls.certresolver=letsencrypt"
```

## 📊 API Documentation

SubTrackr provides a RESTful API for external integrations. All API endpoints require authentication using an API key.

### Authentication

Create an API key from the Settings page in the web interface. Include the API key in your requests using one of these methods:

```bash
# Authorization header (recommended)
curl -H "Authorization: Bearer sk_your_api_key_here" https://your-domain.com/api/v1/subscriptions

# X-API-Key header
curl -H "X-API-Key: sk_your_api_key_here" https://your-domain.com/api/v1/subscriptions
```

### API Endpoints

#### Subscriptions

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/subscriptions` | List all subscriptions |
| POST | `/api/v1/subscriptions` | Create a new subscription |
| GET | `/api/v1/subscriptions/:id` | Get subscription details |
| PUT | `/api/v1/subscriptions/:id` | Update subscription |
| DELETE | `/api/v1/subscriptions/:id` | Delete subscription |

#### Statistics & Export

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/stats` | Get subscription statistics |
| GET | `/api/v1/export/csv` | Export subscriptions as CSV |
| GET | `/api/v1/export/json` | Export subscriptions as JSON |

### Example Requests

#### List Subscriptions
```bash
curl -H "Authorization: Bearer sk_your_api_key_here" \
  https://your-domain.com/api/v1/subscriptions
```

#### Create Subscription
```bash
curl -X POST \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Netflix",
    "cost": 15.99,
    "schedule": "Monthly",
    "status": "Active",
    "category": "Entertainment"
  }' \
  https://your-domain.com/api/v1/subscriptions
```

#### Update Subscription
```bash
curl -X PUT \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "cost": 17.99,
    "status": "Active"
  }' \
  https://your-domain.com/api/v1/subscriptions/123
```

#### Get Statistics
```bash
curl -H "Authorization: Bearer sk_your_api_key_here" \
  https://your-domain.com/api/v1/stats
```

Response:
```json
{
  "total_count": 15,
  "active_count": 12,
  "total_cost": 245.67,
  "categories": {
    "Entertainment": 45.99,
    "Productivity": 89.00,
    "Storage": 29.99
  }
}
```

## 🤖 MCP Server (AI Integration)

SubTrackr includes a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that allows AI assistants like Claude to read and manage your subscriptions via natural language.

### Available Tools

| Tool | Description |
|------|-------------|
| `list_subscriptions` | List all subscriptions |
| `get_subscription` | Get a subscription by ID |
| `create_subscription` | Create a new subscription |
| `update_subscription` | Update an existing subscription |
| `delete_subscription` | Delete a subscription |
| `get_stats` | Get subscription statistics |

### Setup

#### Local Install

Build the MCP server binary:

```bash
go build -o subtrackr-mcp ./cmd/mcp
```

Add to your Claude Desktop (`claude_desktop_config.json`) or Claude Code (`.claude/settings.json`):

```json
{
  "mcpServers": {
    "subtrackr": {
      "command": "/path/to/subtrackr-mcp",
      "env": {
        "DATABASE_PATH": "/path/to/subtrackr.db"
      }
    }
  }
}
```

#### Docker

The MCP binary is included in the Docker image. Configure your MCP client to exec into the container:

```json
{
  "mcpServers": {
    "subtrackr": {
      "command": "docker",
      "args": ["exec", "-i", "subtrackr", "/app/subtrackr-mcp"]
    }
  }
}
```

The MCP server shares the same SQLite database as the web server, so changes made through either interface are immediately visible in the other.

## 🛠️ Development

### Prerequisites

- Go 1.21+
- Docker (optional)

### Local Development

```bash
# Install dependencies
go mod download

# Run development server
go run cmd/server/main.go

# Build binary
go build -o subtrackr cmd/server/main.go
```

### Building Docker Image

```bash
# Build for current platform
docker build -t subtrackr:latest .

# Build multi-platform
docker buildx build --platform linux/amd64,linux/arm64 \
  -t subtrackr:latest --push .
```

## 🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request

## 📝 License

This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details.

## 🙏 Acknowledgments

- Built with [Gin](https://gin-gonic.com/) web framework
- UI powered by [HTMX](https://htmx.org/) and [Tailwind CSS](https://tailwindcss.com/)
- Icons from [Heroicons](https://heroicons.com/)

## ⚠️ Known Limitations

- **Price History**: SubTrackr currently tracks only the current price per subscription. If a subscription changes price over time, annual spend calculations will be based on the current price multiplied by the billing cycle, which may not reflect actual historical spending. For accurate historical tracking, consider manually updating subscription costs when prices change or keeping external records.

## 📞 Support

- 🐛 Issues: [GitHub Issues](https://github.com/bscott/subtrackr/issues)
- 💬 Discussions: [GitHub Discussions](https://github.com/bscott/subtrackr/discussions)

---

Made with ❤️ by me and Vibing 

================================================
FILE: cmd/mcp/main.go
================================================
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"strconv"
	"subtrackr/internal/config"
	"subtrackr/internal/database"
	"subtrackr/internal/models"
	"subtrackr/internal/repository"
	"subtrackr/internal/service"
	"subtrackr/internal/version"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
	cfg := config.Load()

	db, err := database.Initialize(cfg.DatabasePath)
	if err != nil {
		log.Fatal("Failed to initialize database:", err)
	}

	if err := database.RunMigrations(db); err != nil {
		log.Fatal("Failed to run migrations:", err)
	}

	subscriptionRepo := repository.NewSubscriptionRepository(db)
	categoryRepo := repository.NewCategoryRepository(db)
	categoryService := service.NewCategoryService(categoryRepo)
	subscriptionService := service.NewSubscriptionService(subscriptionRepo, categoryService)

	server := mcp.NewServer(
		&mcp.Implementation{Name: "subtrackr", Version: version.GetVersion()},
		nil,
	)

	// list_subscriptions
	type ListInput struct{}
	type ListOutput struct {
		Subscriptions []models.Subscription `json:"subscriptions"`
		Count         int                   `json:"count"`
	}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "list_subscriptions",
		Description: "List all subscriptions",
	}, func(ctx context.Context, req *mcp.CallToolRequest, input ListInput) (*mcp.CallToolResult, ListOutput, error) {
		subs, err := subscriptionService.GetAll()
		if err != nil {
			return nil, ListOutput{}, err
		}
		return nil, ListOutput{Subscriptions: subs, Count: len(subs)}, nil
	})

	// get_subscription
	type GetInput struct {
		ID uint `json:"id" jsonschema:"required,the subscription ID to retrieve"`
	}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "get_subscription",
		Description: "Get a subscription by ID",
	}, func(ctx context.Context, req *mcp.CallToolRequest, input GetInput) (*mcp.CallToolResult, *models.Subscription, error) {
		sub, err := subscriptionService.GetByID(input.ID)
		if err != nil {
			return nil, nil, fmt.Errorf("subscription not found: %w", err)
		}
		return nil, sub, nil
	})

	// create_subscription
	type CreateInput struct {
		Name             string `json:"name" jsonschema:"required,the subscription name"`
		Cost             float64 `json:"cost" jsonschema:"required,the subscription cost"`
		Schedule         string `json:"schedule" jsonschema:"required,billing schedule: Monthly, Annual, Weekly, Daily, or Quarterly"`
		Status           string `json:"status" jsonschema:"subscription status: Active, Cancelled, Paused, or Trial"`
		OriginalCurrency string `json:"original_currency" jsonschema:"currency code e.g. USD, EUR"`
		PaymentMethod    string `json:"payment_method" jsonschema:"payment method"`
		Account          string `json:"account" jsonschema:"account identifier"`
		URL              string `json:"url" jsonschema:"subscription URL"`
		Notes            string `json:"notes" jsonschema:"additional notes"`
		StartDate        string `json:"start_date" jsonschema:"start date in YYYY-MM-DD format"`
		RenewalDate      string `json:"renewal_date" jsonschema:"renewal date in YYYY-MM-DD format"`
		CategoryID       uint   `json:"category_id" jsonschema:"category ID"`
	}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "create_subscription",
		Description: "Create a new subscription",
	}, func(ctx context.Context, req *mcp.CallToolRequest, input CreateInput) (*mcp.CallToolResult, *models.Subscription, error) {
		sub := &models.Subscription{
			Name:             input.Name,
			Cost:             input.Cost,
			Schedule:         input.Schedule,
			Status:           input.Status,
			OriginalCurrency: input.OriginalCurrency,
			PaymentMethod:    input.PaymentMethod,
			Account:          input.Account,
			URL:              input.URL,
			Notes:            input.Notes,
			CategoryID:       input.CategoryID,
		}
		if sub.Status == "" {
			sub.Status = "Active"
		}
		if sub.OriginalCurrency == "" {
			sub.OriginalCurrency = "USD"
		}
		if input.StartDate != "" {
			if t, err := time.Parse("2006-01-02", input.StartDate); err == nil {
				sub.StartDate = &t
			}
		}
		if input.RenewalDate != "" {
			if t, err := time.Parse("2006-01-02", input.RenewalDate); err == nil {
				sub.RenewalDate = &t
			}
		}
		created, err := subscriptionService.Create(sub)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to create subscription: %w", err)
		}
		return nil, created, nil
	})

	// update_subscription
	type UpdateInput struct {
		ID               uint    `json:"id" jsonschema:"required,the subscription ID to update"`
		Name             string  `json:"name" jsonschema:"new name"`
		Cost             float64 `json:"cost" jsonschema:"new cost"`
		Schedule         string  `json:"schedule" jsonschema:"new schedule: Monthly, Annual, Weekly, Daily, or Quarterly"`
		Status           string  `json:"status" jsonschema:"new status: Active, Cancelled, Paused, or Trial"`
		OriginalCurrency string  `json:"original_currency" jsonschema:"new currency code"`
		PaymentMethod    string  `json:"payment_method" jsonschema:"new payment method"`
		Account          string  `json:"account" jsonschema:"new account"`
		URL              string  `json:"url" jsonschema:"new URL"`
		Notes            string  `json:"notes" jsonschema:"new notes"`
		StartDate        string  `json:"start_date" jsonschema:"new start date in YYYY-MM-DD format"`
		RenewalDate      string  `json:"renewal_date" jsonschema:"new renewal date in YYYY-MM-DD format"`
		CategoryID       uint    `json:"category_id" jsonschema:"new category ID"`
	}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "update_subscription",
		Description: "Update an existing subscription",
	}, func(ctx context.Context, req *mcp.CallToolRequest, input UpdateInput) (*mcp.CallToolResult, *models.Subscription, error) {
		// Get existing subscription to merge fields
		existing, err := subscriptionService.GetByID(input.ID)
		if err != nil {
			return nil, nil, fmt.Errorf("subscription not found: %w", err)
		}

		// Detect which fields were explicitly provided via raw JSON
		var provided map[string]json.RawMessage
		json.Unmarshal(req.Params.Arguments, &provided)

		if _, ok := provided["name"]; ok {
			existing.Name = input.Name
		}
		if _, ok := provided["cost"]; ok {
			existing.Cost = input.Cost
		}
		if _, ok := provided["schedule"]; ok {
			existing.Schedule = input.Schedule
		}
		if _, ok := provided["status"]; ok {
			existing.Status = input.Status
		}
		if _, ok := provided["original_currency"]; ok {
			existing.OriginalCurrency = input.OriginalCurrency
		}
		if _, ok := provided["payment_method"]; ok {
			existing.PaymentMethod = input.PaymentMethod
		}
		if _, ok := provided["account"]; ok {
			existing.Account = input.Account
		}
		if _, ok := provided["url"]; ok {
			existing.URL = input.URL
		}
		if _, ok := provided["notes"]; ok {
			existing.Notes = input.Notes
		}
		if _, ok := provided["category_id"]; ok {
			existing.CategoryID = input.CategoryID
		}
		if _, ok := provided["start_date"]; ok && input.StartDate != "" {
			if t, err := time.Parse("2006-01-02", input.StartDate); err == nil {
				existing.StartDate = &t
			}
		}
		if _, ok := provided["renewal_date"]; ok && input.RenewalDate != "" {
			if t, err := time.Parse("2006-01-02", input.RenewalDate); err == nil {
				existing.RenewalDate = &t
			}
		}

		updated, err := subscriptionService.Update(input.ID, existing)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to update subscription: %w", err)
		}
		return nil, updated, nil
	})

	// delete_subscription
	type DeleteInput struct {
		ID uint `json:"id" jsonschema:"required,the subscription ID to delete"`
	}
	type DeleteOutput struct {
		Message string `json:"message"`
	}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "delete_subscription",
		Description: "Delete a subscription by ID",
	}, func(ctx context.Context, req *mcp.CallToolRequest, input DeleteInput) (*mcp.CallToolResult, DeleteOutput, error) {
		if err := subscriptionService.Delete(input.ID); err != nil {
			return nil, DeleteOutput{}, fmt.Errorf("failed to delete subscription: %w", err)
		}
		return nil, DeleteOutput{Message: "Subscription " + strconv.Itoa(int(input.ID)) + " deleted"}, nil
	})

	// get_stats
	type StatsInput struct{}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "get_stats",
		Description: "Get subscription statistics including total spending, counts, and category breakdown",
	}, func(ctx context.Context, req *mcp.CallToolRequest, input StatsInput) (*mcp.CallToolResult, *models.Stats, error) {
		stats, err := subscriptionService.GetStats()
		if err != nil {
			return nil, nil, fmt.Errorf("failed to get stats: %w", err)
		}
		return nil, stats, nil
	})

	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatal(err)
	}
}


================================================
FILE: cmd/migrate-dates/main.go
================================================
package main

import (
	"flag"
	"fmt"
	"log"
	"os"
	"strings"
	"subtrackr/internal/database"
	"subtrackr/internal/models"
	"time"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func main() {
	var (
		dbPath    = flag.String("db", "subtrackr.db", "Path to SQLite database")
		dryRun    = flag.Bool("dry-run", false, "Show what would be changed without making changes")
		action    = flag.String("action", "compare", "Action to perform: compare, migrate, rollback, stats")
		subID     = flag.Uint("subscription-id", 0, "Subscription ID for single operations")
		reason    = flag.String("reason", "Manual migration", "Reason for migration")
	)
	flag.Parse()

	// Open database
	db, err := gorm.Open(sqlite.Open(*dbPath), &gorm.Config{})
	if err != nil {
		log.Fatal("Failed to connect to database:", err)
	}

	// Run migrations to ensure schema is up to date
	if err := database.RunMigrations(db); err != nil {
		log.Fatal("Failed to run migrations:", err)
	}

	// Auto-migrate audit log table
	if err := db.AutoMigrate(&models.DateMigrationLog{}); err != nil {
		log.Fatal("Failed to migrate audit log table:", err)
	}

	// Create migration safety checker
	checker := models.NewDateMigrationSafetyCheck(db)

	switch *action {
	case "compare":
		if *subID == 0 {
			fmt.Println("Comparing all subscriptions V1 vs V2...")
			compareAllSubscriptions(db)
		} else {
			compareSubscription(checker, *subID)
		}

	case "migrate":
		if *subID == 0 {
			fmt.Printf("Migrating all subscriptions to V2 (dry-run: %v)...\n", *dryRun)
			if err := checker.BatchMigrateToV2WithAudit(*dryRun); err != nil {
				log.Fatal("Migration failed:", err)
			}
			fmt.Println("Migration completed successfully")
		} else {
			fmt.Printf("Migrating subscription %d to V2...\n", *subID)
			if err := checker.MigrateSubscriptionToV2(*subID, *reason); err != nil {
				log.Fatal("Migration failed:", err)
			}
			fmt.Println("Subscription migrated successfully")
		}

	case "rollback":
		if *subID == 0 {
			fmt.Println("Batch rollback not supported for safety. Use --subscription-id")
			os.Exit(1)
		}
		fmt.Printf("Rolling back subscription %d to V1...\n", *subID)
		if err := checker.RollbackSubscriptionToV1(*subID, *reason); err != nil {
			log.Fatal("Rollback failed:", err)
		}
		fmt.Println("Subscription rolled back successfully")

	case "stats":
		stats, err := checker.GetMigrationStats()
		if err != nil {
			log.Fatal("Failed to get stats:", err)
		}
		printStats(stats)

	default:
		fmt.Printf("Unknown action: %s\n", *action)
		fmt.Println("Valid actions: compare, migrate, rollback, stats")
		os.Exit(1)
	}
}

func compareAllSubscriptions(db *gorm.DB) {
	var subscriptions []models.Subscription
	db.Find(&subscriptions)

	checker := models.NewDateMigrationSafetyCheck(db)

	fmt.Printf("%-5s %-20s %-12s %-20s %-20s %-10s\n",
		"ID", "Name", "Schedule", "V1 Date", "V2 Date", "Diff (days)")
	fmt.Println(strings.Repeat("-", 90))

	for _, sub := range subscriptions {
		v1Date, v2Date, err := checker.CompareCalculationVersions(sub.ID)
		if err != nil {
			continue
		}

		v1Str := "nil"
		v2Str := "nil"
		diffStr := "N/A"

		if v1Date != nil {
			v1Str = v1Date.Format("2006-01-02")
		}
		if v2Date != nil {
			v2Str = v2Date.Format("2006-01-02")
		}
		if v1Date != nil && v2Date != nil {
			diff := v2Date.Sub(*v1Date).Truncate(24*time.Hour).Hours() / 24
			diffStr = fmt.Sprintf("%.1f", diff)
		}

		name := sub.Name
		if len(name) > 18 {
			name = name[:15] + "..."
		}

		fmt.Printf("%-5d %-20s %-12s %-20s %-20s %-10s\n",
			sub.ID, name, sub.Schedule, v1Str, v2Str, diffStr)
	}
}

func compareSubscription(checker *models.DateMigrationSafetyCheck, id uint) {
	v1Date, v2Date, err := checker.CompareCalculationVersions(id)
	if err != nil {
		log.Fatal("Failed to compare:", err)
	}

	fmt.Printf("Subscription %d comparison:\n", id)
	if v1Date != nil {
		fmt.Printf("V1 Date: %s\n", v1Date.Format("2006-01-02 15:04:05"))
	} else {
		fmt.Println("V1 Date: nil")
	}
	if v2Date != nil {
		fmt.Printf("V2 Date: %s\n", v2Date.Format("2006-01-02 15:04:05"))
	} else {
		fmt.Println("V2 Date: nil")
	}

	if v1Date != nil && v2Date != nil {
		diff := v2Date.Sub(*v1Date).Truncate(24*time.Hour).Hours() / 24
		fmt.Printf("Difference: %.1f days\n", diff)
	}
}

func printStats(stats map[string]interface{}) {
	fmt.Println("Date Calculation Migration Statistics:")
	fmt.Println("=====================================")
	fmt.Printf("V1 Subscriptions: %v\n", stats["v1_subscriptions"])
	fmt.Printf("V2 Subscriptions: %v\n", stats["v2_subscriptions"])
	fmt.Printf("Total Migrations: %v\n", stats["total_migrations"])
	fmt.Printf("Rollbacks: %v\n", stats["rollbacks"])
}

================================================
FILE: docker-compose.yml
================================================
version: '3.8'

services:
  subtrackr:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app/data
      - ./web:/app/web
      - ./templates:/app/templates
    environment:
      - GIN_MODE=release
      - DATABASE_PATH=/app/data/subtrackr.db
      - PORT=8080
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

volumes:
  subtrackr_data:
    driver: local

================================================
FILE: go.mod
================================================
module subtrackr

go 1.24.0

require (
	github.com/dromara/carbon/v2 v2.6.11
	github.com/gin-gonic/gin v1.9.1
	github.com/gorilla/sessions v1.4.0
	github.com/stretchr/testify v1.11.1
	golang.org/x/crypto v0.46.0
	golang.org/x/term v0.38.0
	gorm.io/driver/sqlite v1.5.4
	gorm.io/gorm v1.25.5
)

require (
	github.com/bytedance/sonic v1.9.1 // indirect
	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.14.0 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/google/jsonschema-go v0.4.2 // indirect
	github.com/gorilla/securecookie v1.1.2 // indirect
	github.com/jinzhu/inflection v1.0.0 // indirect
	github.com/jinzhu/now v1.1.5 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
	github.com/leodido/go-urn v1.2.4 // indirect
	github.com/mattn/go-isatty v0.0.19 // indirect
	github.com/mattn/go-sqlite3 v1.14.17 // indirect
	github.com/modelcontextprotocol/go-sdk v1.3.0 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.2.11 // indirect
	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
	golang.org/x/arch v0.3.0 // indirect
	golang.org/x/net v0.47.0 // indirect
	golang.org/x/oauth2 v0.30.0 // indirect
	golang.org/x/sys v0.39.0 // indirect
	golang.org/x/text v0.32.0 // indirect
	google.golang.org/protobuf v1.30.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)


================================================
FILE: go.sum
================================================
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dromara/carbon/v2 v2.6.11 h1:wnAWZ+sbza1uXw3r05hExNSCaBPFaarWfUvYAX86png=
github.com/dromara/carbon/v2 v2.6.11/go.mod h1:7GXqCUplwN1s1b4whGk2zX4+g4CMCoDIZzmjlyt0vLY=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=


================================================
FILE: internal/config/config.go
================================================
package config

import (
	"os"
)

type Config struct {
	DatabasePath string
	Port         string
	Environment  string
}

func Load() *Config {
	return &Config{
		DatabasePath: getEnv("DATABASE_PATH", "./data/subtrackr.db"),
		Port:         getEnv("PORT", "8080"),
		Environment:  getEnv("GIN_MODE", "debug"),
	}
}

func getEnv(key, defaultValue string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return defaultValue
}

================================================
FILE: internal/database/database.go
================================================
package database

import (
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func Initialize(dbPath string) (*gorm.DB, error) {
	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Silent),
	})
	if err != nil {
		return nil, err
	}

	// Enable foreign key constraints
	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}

	_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
	if err != nil {
		return nil, err
	}

	return db, nil
}

================================================
FILE: internal/database/migrations.go
================================================
package database

import (
	"log"
	"subtrackr/internal/models"

	"gorm.io/gorm"
)

// RunMigrations executes all database migrations
func RunMigrations(db *gorm.DB) error {
	// Auto-migrate non-problematic models first
	err := db.AutoMigrate(&models.Category{}, &models.Settings{}, &models.APIKey{}, &models.ExchangeRate{})
	if err != nil {
		return err
	}

	// Run specific migrations
	migrations := []func(*gorm.DB) error{
		migrateCategoriesToDynamic,
		migrateCurrencyFields,
		migrateDateCalculationVersioning,
		migrateSubscriptionIcons,
		migrateReminderTracking,
		migrateCancellationReminderTracking,
		migrateScheduleInterval,
		migrateReminderEnabled,
	}

	for _, migration := range migrations {
		if err := migration(db); err != nil {
			return err
		}
	}

	// Try to auto-migrate subscriptions after the category migration
	// This might fail on existing databases but that's okay
	db.AutoMigrate(&models.Subscription{})

	return nil
}

// migrateCategoriesToDynamic handles the v0.3.0 migration from string categories to category IDs
func migrateCategoriesToDynamic(db *gorm.DB) error {
	// Check if migration is needed by looking for the old category column
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='category'").Scan(&count)

	if count == 0 {
		// Migration already completed
		return nil
	}

	log.Println("Running migration: Converting categories to dynamic system...")

	// First ensure default categories exist
	defaultCategories := []string{"Entertainment", "Productivity", "Storage", "Software", "Fitness", "Education", "Food", "Travel", "Business", "Other"}
	var categories []models.Category
	db.Find(&categories)

	if len(categories) == 0 {
		for _, name := range defaultCategories {
			db.Create(&models.Category{Name: name})
		}
		db.Find(&categories) // Reload categories
	}

	// Create category map
	categoryMap := make(map[string]uint)
	for _, cat := range categories {
		categoryMap[cat.Name] = cat.ID
	}

	// Get all subscriptions that need migration
	type OldSubscription struct {
		ID       uint
		Category string
	}

	var oldSubs []OldSubscription
	db.Table("subscriptions").Select("id, category").Scan(&oldSubs)

	// Update each subscription with the appropriate category_id
	for _, sub := range oldSubs {
		if sub.Category != "" {
			if catID, exists := categoryMap[sub.Category]; exists {
				db.Table("subscriptions").Where("id = ?", sub.ID).Update("category_id", catID)
			} else {
				// If category doesn't exist, use "Other"
				if otherID, exists := categoryMap["Other"]; exists {
					db.Table("subscriptions").Where("id = ?", sub.ID).Update("category_id", otherID)
				}
			}
		}
	}

	// SQLite limitation: we can't drop the old category column
	// The repository layer now handles both old and new schemas transparently
	// This ensures backward compatibility without data loss

	log.Println("Migration completed: Categories converted to dynamic system")
	return nil
}

// migrateCurrencyFields adds original_currency field to existing subscriptions
func migrateCurrencyFields(db *gorm.DB) error {
	// Check if original_currency column already exists
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='original_currency'").Scan(&count)

	if count > 0 {
		// Migration already completed
		return nil
	}

	log.Println("Running migration: Adding currency fields...")

	// Add original_currency column with default 'USD'
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN original_currency TEXT DEFAULT 'USD'").Error; err != nil {
		// Column might already exist, that's okay
		log.Printf("Note: Could not add original_currency column: %v", err)
	}

	// Set USD as default for existing subscriptions
	if err := db.Exec("UPDATE subscriptions SET original_currency = 'USD' WHERE original_currency IS NULL OR original_currency = ''").Error; err != nil {
		log.Printf("Warning: Could not update existing subscriptions with default currency: %v", err)
	}

	log.Println("Migration completed: Currency fields added")
	return nil
}

// migrateDateCalculationVersioning adds date_calculation_version field for versioned date logic
func migrateDateCalculationVersioning(db *gorm.DB) error {
	// Check if date_calculation_version column already exists
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='date_calculation_version'").Scan(&count)

	if count > 0 {
		// Migration already completed
		return nil
	}

	log.Println("Running migration: Adding date calculation versioning...")

	// Add date_calculation_version column with default 1 (existing logic)
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN date_calculation_version INTEGER DEFAULT 1").Error; err != nil {
		// Column might already exist, that's okay
		log.Printf("Note: Could not add date_calculation_version column: %v", err)
	}

	// Set version 1 for all existing subscriptions (maintain backward compatibility)
	if err := db.Exec("UPDATE subscriptions SET date_calculation_version = 1 WHERE date_calculation_version IS NULL").Error; err != nil {
		log.Printf("Warning: Could not update existing subscriptions with default version: %v", err)
	}

	log.Println("Migration completed: Date calculation versioning added")
	return nil
}

// migrateSubscriptionIcons adds icon_url field to subscriptions table
func migrateSubscriptionIcons(db *gorm.DB) error {
	// Check if icon_url column already exists
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='icon_url'").Scan(&count)

	if count > 0 {
		// Migration already completed
		return nil
	}

	log.Println("Running migration: Adding subscription icon URLs...")

	// Add icon_url column (nullable, empty string default)
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN icon_url TEXT DEFAULT ''").Error; err != nil {
		// Column might already exist, that's okay
		log.Printf("Note: Could not add icon_url column: %v", err)
	}

	// Set empty string as default for existing subscriptions
	if err := db.Exec("UPDATE subscriptions SET icon_url = '' WHERE icon_url IS NULL").Error; err != nil {
		log.Printf("Warning: Could not update existing subscriptions with default icon_url: %v", err)
	}

	log.Println("Migration completed: Subscription icon URLs added")
	return nil
}

// migrateReminderTracking adds fields to track when reminders were sent
func migrateReminderTracking(db *gorm.DB) error {
	// Check if last_reminder_sent column already exists
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='last_reminder_sent'").Scan(&count)

	if count > 0 {
		// Migration already completed
		return nil
	}

	log.Println("Running migration: Adding reminder tracking fields...")

	// Add last_reminder_sent column
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN last_reminder_sent DATETIME").Error; err != nil {
		log.Printf("Note: Could not add last_reminder_sent column: %v", err)
	}

	// Add last_reminder_renewal_date column
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN last_reminder_renewal_date DATETIME").Error; err != nil {
		log.Printf("Note: Could not add last_reminder_renewal_date column: %v", err)
	}

	log.Println("Migration completed: Reminder tracking fields added")
	return nil
}

// migrateCancellationReminderTracking adds fields to track when cancellation reminders were sent
func migrateCancellationReminderTracking(db *gorm.DB) error {
	// Check if last_cancellation_reminder_sent column already exists
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='last_cancellation_reminder_sent'").Scan(&count)

	if count > 0 {
		// Migration already completed
		return nil
	}

	log.Println("Running migration: Adding cancellation reminder tracking fields...")

	// Add last_cancellation_reminder_sent column
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN last_cancellation_reminder_sent DATETIME").Error; err != nil {
		log.Printf("Note: Could not add last_cancellation_reminder_sent column: %v", err)
	}

	// Add last_cancellation_reminder_date column
	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN last_cancellation_reminder_date DATETIME").Error; err != nil {
		log.Printf("Note: Could not add last_cancellation_reminder_date column: %v", err)
	}

	log.Println("Migration completed: Cancellation reminder tracking fields added")
	return nil
}

func migrateScheduleInterval(db *gorm.DB) error {
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='schedule_interval'").Scan(&count)

	if count > 0 {
		return nil
	}

	log.Println("Running migration: Adding schedule interval field...")

	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN schedule_interval INTEGER DEFAULT 1").Error; err != nil {
		log.Printf("Note: Could not add schedule_interval column: %v", err)
	}

	if err := db.Exec("UPDATE subscriptions SET schedule_interval = 1 WHERE schedule_interval IS NULL").Error; err != nil {
		log.Printf("Warning: Could not update existing subscriptions with default schedule_interval: %v", err)
	}

	log.Println("Migration completed: Schedule interval field added")
	return nil
}

// migrateReminderEnabled adds per-subscription reminder toggle field
func migrateReminderEnabled(db *gorm.DB) error {
	// Check if column already exists
	var count int64
	db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name = 'reminder_enabled'").Count(&count)

	if count > 0 {
		return nil
	}

	log.Println("Running migration: Adding per-subscription reminder_enabled field...")

	if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN reminder_enabled INTEGER DEFAULT 1").Error; err != nil {
		log.Printf("Note: Could not add reminder_enabled column: %v", err)
	}

	// Set all existing subscriptions to enabled
	db.Exec("UPDATE subscriptions SET reminder_enabled = 1 WHERE reminder_enabled IS NULL")

	log.Println("Migration completed: reminder_enabled field added")
	return nil
}


================================================
FILE: internal/handlers/auth.go
================================================
package handlers

import (
	"crypto/subtle"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"subtrackr/internal/service"

	"github.com/gin-gonic/gin"
)

type AuthHandler struct {
	settingsService *service.SettingsService
	sessionService  *service.SessionService
	emailService    *service.EmailService
}

func NewAuthHandler(settingsService *service.SettingsService, sessionService *service.SessionService, emailService *service.EmailService) *AuthHandler {
	return &AuthHandler{
		settingsService: settingsService,
		sessionService:  sessionService,
		emailService:    emailService,
	}
}

// isValidRedirect validates that a redirect URL is safe (relative URL only)
func isValidRedirect(redirect string) bool {
	// Check URL length to prevent DoS or log injection
	if len(redirect) > 2048 {
		return false
	}

	// Only allow relative URLs starting with / but not //
	// This prevents open redirect vulnerabilities
	if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") {
		return true
	}
	return false
}

// ShowLoginPage displays the login page
func (h *AuthHandler) ShowLoginPage(c *gin.Context) {
	// If already authenticated, redirect to dashboard
	if h.sessionService.IsAuthenticated(c.Request) {
		c.Redirect(http.StatusFound, "/")
		return
	}

	redirect := c.Query("redirect")
	if redirect == "" || !isValidRedirect(redirect) {
		redirect = "/"
	}

	c.HTML(http.StatusOK, "login.html", gin.H{
		"Redirect": redirect,
		"Error":    c.Query("error"),
	})
}

// Login handles login form submission
func (h *AuthHandler) Login(c *gin.Context) {
	username := c.PostForm("username")
	password := c.PostForm("password")
	rememberMe := c.PostForm("remember_me") == "on"
	redirect := c.PostForm("redirect")

	if redirect == "" || !isValidRedirect(redirect) {
		redirect = "/"
	}

	// Validate credentials using constant-time comparison to prevent timing attacks
	storedUsername, err := h.settingsService.GetAuthUsername()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "login-error.html", gin.H{
			"Error": "Authentication system error",
		})
		return
	}

	// Always validate password even for invalid usernames (constant time)
	validUsername := subtle.ConstantTimeCompare([]byte(storedUsername), []byte(username)) == 1

	var validPassword bool
	if err := h.settingsService.ValidatePassword(password); err == nil {
		validPassword = true
	}

	// Only fail after both checks to prevent username enumeration via timing
	if !validUsername || !validPassword {
		c.HTML(http.StatusUnauthorized, "login-error.html", gin.H{
			"Error": "Invalid username or password",
		})
		return
	}

	// Create session
	if err := h.sessionService.CreateSession(c.Writer, c.Request, rememberMe); err != nil {
		c.HTML(http.StatusInternalServerError, "login-error.html", gin.H{
			"Error": "Failed to create session",
		})
		return
	}

	// Redirect to original destination or dashboard
	c.Header("HX-Redirect", redirect)
	c.Status(http.StatusOK)
}

// Logout handles logout
func (h *AuthHandler) Logout(c *gin.Context) {
	if err := h.sessionService.DestroySession(c.Writer, c.Request); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to logout"})
		return
	}

	c.Redirect(http.StatusFound, "/login")
}

// ShowForgotPasswordPage displays the forgot password page
func (h *AuthHandler) ShowForgotPasswordPage(c *gin.Context) {
	c.HTML(http.StatusOK, "forgot-password.html", gin.H{})
}

// ForgotPassword handles forgot password request
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
	// Generate reset token
	token, err := h.settingsService.GenerateResetToken()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "forgot-password-error.html", gin.H{
			"Error": "Failed to generate reset token",
		})
		return
	}

	// Check if SMTP is configured
	_, err = h.settingsService.GetSMTPConfig()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "forgot-password-error.html", gin.H{
			"Error": "Email is not configured. Please contact administrator.",
		})
		return
	}

	// Build reset URL
	resetURL := buildBaseURL(c, h.settingsService.GetBaseURL()) + "/reset-password?token=" + url.QueryEscape(token)

	// Send reset email
	subject := "SubTrackr Password Reset"
	body := fmt.Sprintf(`
		<h2>Password Reset Request</h2>
		<p>You have requested to reset your SubTrackr password.</p>
		<p>Click the link below to reset your password:</p>
		<p><a href="%s">Reset Password</a></p>
		<p>This link will expire in 1 hour.</p>
		<p>If you did not request this reset, please ignore this email.</p>
	`, resetURL)

	err = h.emailService.SendEmail(subject, body)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "forgot-password-error.html", gin.H{
			"Error": "Failed to send reset email: " + err.Error(),
		})
		return
	}

	c.HTML(http.StatusOK, "forgot-password-success.html", gin.H{
		"Message": "Password reset link has been sent to your email",
	})
}

// ShowResetPasswordPage displays the reset password page
func (h *AuthHandler) ShowResetPasswordPage(c *gin.Context) {
	token := c.Query("token")
	if token == "" {
		c.HTML(http.StatusBadRequest, "reset-password.html", gin.H{
			"Error": "Invalid reset token",
		})
		return
	}

	// Validate token
	if err := h.settingsService.ValidateResetToken(token); err != nil {
		c.HTML(http.StatusBadRequest, "reset-password.html", gin.H{
			"Error": "Invalid or expired reset token",
		})
		return
	}

	c.HTML(http.StatusOK, "reset-password.html", gin.H{
		"Token": token,
	})
}

// ResetPassword handles password reset
func (h *AuthHandler) ResetPassword(c *gin.Context) {
	token := c.PostForm("token")
	newPassword := c.PostForm("new_password")
	confirmPassword := c.PostForm("confirm_password")

	// Validate password length FIRST (before checking if they match)
	if len(newPassword) < 8 {
		c.HTML(http.StatusBadRequest, "reset-password-error.html", gin.H{
			"Error": "Password must be at least 8 characters long",
		})
		return
	}

	// Then validate passwords match
	if newPassword != confirmPassword {
		c.HTML(http.StatusBadRequest, "reset-password-error.html", gin.H{
			"Error": "Passwords do not match",
		})
		return
	}

	// Validate token
	if err := h.settingsService.ValidateResetToken(token); err != nil {
		c.HTML(http.StatusBadRequest, "reset-password-error.html", gin.H{
			"Error": "Invalid or expired reset token",
		})
		return
	}

	// Update password
	if err := h.settingsService.SetAuthPassword(newPassword); err != nil {
		c.HTML(http.StatusInternalServerError, "reset-password-error.html", gin.H{
			"Error": "Failed to update password",
		})
		return
	}

	// Clear reset token
	h.settingsService.ClearResetToken()

	c.HTML(http.StatusOK, "reset-password-success.html", gin.H{
		"Message": "Password reset successfully. You can now login with your new password.",
	})
}


================================================
FILE: internal/handlers/category.go
================================================
package handlers

import (
	"net/http"
	"strconv"
	"subtrackr/internal/models"
	"subtrackr/internal/service"

	"github.com/gin-gonic/gin"
)

type CategoryHandler struct {
	service *service.CategoryService
}

func NewCategoryHandler(service *service.CategoryService) *CategoryHandler {
	return &CategoryHandler{service: service}
}

// List all categories
func (h *CategoryHandler) ListCategories(c *gin.Context) {
	categories, err := h.service.GetAll()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, categories)
}

// Create a new category
func (h *CategoryHandler) CreateCategory(c *gin.Context) {
	var category models.Category
	if err := c.ShouldBindJSON(&category); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	created, err := h.service.Create(&category)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, created)
}

// Update a category
func (h *CategoryHandler) UpdateCategory(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 32)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
		return
	}
	var category models.Category
	if err := c.ShouldBindJSON(&category); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	updated, err := h.service.Update(uint(id), &category)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, updated)
}

// Delete a category
func (h *CategoryHandler) DeleteCategory(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 32)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
		return
	}
	if err := h.service.Delete(uint(id)); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.Status(http.StatusNoContent)
}


================================================
FILE: internal/handlers/settings.go
================================================
package handlers

import (
	"crypto/rand"
	"crypto/tls"
	"encoding/hex"
	"fmt"
	"log"
	"net/http"
	"net/smtp"
	"strconv"
	"strings"
	"subtrackr/internal/models"
	"subtrackr/internal/service"
	"time"

	"github.com/gin-gonic/gin"
)

func splitLines(s string) []string { return strings.Split(s, "\n") }
func trimSpace(s string) string    { return strings.TrimSpace(s) }
func splitN(s, sep string, n int) []string { return strings.SplitN(s, sep, n) }

type SettingsHandler struct {
	service *service.SettingsService
}

func NewSettingsHandler(service *service.SettingsService) *SettingsHandler {
	return &SettingsHandler{service: service}
}

// SaveSMTPSettings saves SMTP configuration
func (h *SettingsHandler) SaveSMTPSettings(c *gin.Context) {
	var config models.SMTPConfig

	// Parse form data
	config.Host = c.PostForm("smtp_host")
	config.Username = c.PostForm("smtp_username")
	config.Password = c.PostForm("smtp_password")
	config.From = c.PostForm("smtp_from")
	config.FromName = c.PostForm("smtp_from_name")
	config.To = c.PostForm("smtp_to")

	// Parse port
	if portStr := c.PostForm("smtp_port"); portStr != "" {
		if port, err := strconv.Atoi(portStr); err == nil {
			config.Port = port
		}
	}

	// Validate required fields
	if config.Host == "" || config.Port == 0 || config.Username == "" || config.Password == "" || config.From == "" || config.To == "" {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "Required SMTP fields: Host, Port, Username, Password, From email, To email",
			"Type":  "error",
		})
		return
	}

	// Save configuration
	err := h.service.SaveSMTPConfig(&config)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "smtp-message.html", gin.H{
			"Error": err.Error(),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "smtp-message.html", gin.H{
		"Message": "SMTP settings saved successfully",
		"Type":    "success",
	})
}

// TestSMTPConnection tests SMTP configuration with TLS/SSL support
func (h *SettingsHandler) TestSMTPConnection(c *gin.Context) {
	var config models.SMTPConfig

	// Parse form data
	config.Host = c.PostForm("smtp_host")
	config.Username = c.PostForm("smtp_username")
	config.Password = c.PostForm("smtp_password")
	config.From = c.PostForm("smtp_from")
	config.FromName = c.PostForm("smtp_from_name")
	config.To = c.PostForm("smtp_to")

	// Parse port
	if portStr := c.PostForm("smtp_port"); portStr != "" {
		if port, err := strconv.Atoi(portStr); err == nil {
			config.Port = port
		}
	}

	// Validate required fields for testing (connection test doesn't need From/To, but we validate for consistency)
	if config.Host == "" || config.Port == 0 || config.Username == "" || config.Password == "" {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "Host, Port, Username, and Password are required for testing",
			"Type":  "error",
		})
		return
	}

	// Test connection with TLS/SSL support
	addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
	auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)

	// Determine if this is an implicit TLS port (SMTPS)
	isSSLPort := config.Port == 465 || config.Port == 8465 || config.Port == 443

	var client *smtp.Client
	var err error

	if isSSLPort {
		// Use implicit TLS (direct SSL connection)
		tlsConfig := &tls.Config{
			ServerName: config.Host,
		}

		conn, err := tls.Dial("tcp", addr, tlsConfig)
		if err != nil {
			c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
				"Error": fmt.Sprintf("Failed to connect via SSL: %v", err),
				"Type":  "error",
			})
			return
		}

		client, err = smtp.NewClient(conn, config.Host)
		if err != nil {
			conn.Close()
			c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
				"Error": fmt.Sprintf("Failed to create SMTP client: %v", err),
				"Type":  "error",
			})
			return
		}
	} else {
		// Use STARTTLS (opportunistic TLS)
		client, err = smtp.Dial(addr)
		if err != nil {
			c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
				"Error": fmt.Sprintf("Failed to connect: %v", err),
				"Type":  "error",
			})
			return
		}

		// Upgrade to TLS
		tlsConfig := &tls.Config{
			ServerName: config.Host,
		}

		if err = client.StartTLS(tlsConfig); err != nil {
			client.Close()
			c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
				"Error": fmt.Sprintf("Failed to start TLS: %v", err),
				"Type":  "error",
			})
			return
		}
	}

	defer client.Close()

	// Try to authenticate
	if err = client.Auth(auth); err != nil {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": fmt.Sprintf("Authentication failed: %v", err),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "smtp-message.html", gin.H{
		"Message": "SMTP connection test successful!",
		"Type":    "success",
	})
}

// UpdateNotificationSetting updates a notification preference
func (h *SettingsHandler) UpdateNotificationSetting(c *gin.Context) {
	setting := c.Param("setting")

	switch setting {
	case "renewal":
		current, _ := h.service.GetBoolSetting("renewal_reminders", false)
		err := h.service.SetBoolSetting("renewal_reminders", !current)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{"enabled": !current})

	case "highcost":
		current, _ := h.service.GetBoolSetting("high_cost_alerts", true)
		err := h.service.SetBoolSetting("high_cost_alerts", !current)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{"enabled": !current})

	case "days":
		daysStr := c.PostForm("reminder_days")
		if days, err := strconv.Atoi(daysStr); err == nil && days > 0 && days <= 30 {
			err := h.service.SetIntSetting("reminder_days", days)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
			c.JSON(http.StatusOK, gin.H{"days": days})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid days value"})
		}

	case "threshold":
		thresholdStr := c.PostForm("high_cost_threshold")
		if threshold, err := strconv.ParseFloat(thresholdStr, 64); err == nil && threshold >= 0 && threshold <= 10000 {
			err := h.service.SetFloatSetting("high_cost_threshold", threshold)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
			c.JSON(http.StatusOK, gin.H{"threshold": threshold})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid threshold value (must be between 0 and 10000)"})
		}

	case "cancellation":
		current, _ := h.service.GetBoolSetting("cancellation_reminders", false)
		err := h.service.SetBoolSetting("cancellation_reminders", !current)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{"enabled": !current})

	case "cancellation_days":
		daysStr := c.PostForm("cancellation_reminder_days")
		if days, err := strconv.Atoi(daysStr); err == nil && days > 0 && days <= 30 {
			err := h.service.SetIntSetting("cancellation_reminder_days", days)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
			c.JSON(http.StatusOK, gin.H{"days": days})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid days value"})
		}

	default:
		c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown setting"})
	}
}

// GetNotificationSettings returns current notification settings
func (h *SettingsHandler) GetNotificationSettings(c *gin.Context) {
	settings := models.NotificationSettings{
		RenewalReminders:         h.service.GetBoolSettingWithDefault("renewal_reminders", false),
		HighCostAlerts:           h.service.GetBoolSettingWithDefault("high_cost_alerts", true),
		HighCostThreshold:        h.service.GetFloatSettingWithDefault("high_cost_threshold", 50.0),
		ReminderDays:             h.service.GetIntSettingWithDefault("reminder_days", 7),
		CancellationReminders:    h.service.GetBoolSettingWithDefault("cancellation_reminders", false),
		CancellationReminderDays: h.service.GetIntSettingWithDefault("cancellation_reminder_days", 7),
	}

	c.JSON(http.StatusOK, settings)
}

// GetSMTPConfig returns current SMTP configuration (without password)
func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {
	config, err := h.service.GetSMTPConfig()
	if err != nil {
		c.JSON(http.StatusOK, gin.H{"configured": false})
		return
	}

	// Don't send the password
	config.Password = ""
	c.JSON(http.StatusOK, gin.H{
		"configured": true,
		"config":     config,
	})
}

// ListAPIKeys returns all API keys
func (h *SettingsHandler) ListAPIKeys(c *gin.Context) {
	keys, err := h.service.GetAllAPIKeys()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "api-keys-list.html", gin.H{
			"Error": err.Error(),
		})
		return
	}

	// Don't send the actual key values for existing keys
	for i := range keys {
		if !keys[i].IsNew {
			keys[i].Key = ""
		}
	}

	c.HTML(http.StatusOK, "api-keys-list.html", gin.H{
		"Keys":         keys,
		"GoDateFormat": h.service.GetGoDateFormat(),
	})
}

// CreateAPIKey generates a new API key
func (h *SettingsHandler) CreateAPIKey(c *gin.Context) {
	name := c.PostForm("name")
	if name == "" {
		c.HTML(http.StatusBadRequest, "api-keys-list.html", gin.H{
			"Error": "API key name is required",
		})
		return
	}

	// Generate a secure random API key
	keyBytes := make([]byte, 32)
	if _, err := rand.Read(keyBytes); err != nil {
		c.HTML(http.StatusInternalServerError, "api-keys-list.html", gin.H{
			"Error": "Failed to generate API key",
		})
		return
	}

	apiKey := "sk_" + hex.EncodeToString(keyBytes)

	// Save the API key
	newKey, err := h.service.CreateAPIKey(name, apiKey)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "api-keys-list.html", gin.H{
			"Error": err.Error(),
		})
		return
	}

	// Get all keys including the new one
	keys, err := h.service.GetAllAPIKeys()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "api-keys-list.html", gin.H{
			"Error": err.Error(),
		})
		return
	}

	// Mark the new key and include its value
	for i := range keys {
		if keys[i].ID == newKey.ID {
			keys[i].IsNew = true
			keys[i].Key = apiKey
		} else {
			keys[i].Key = ""
		}
	}

	c.HTML(http.StatusOK, "api-keys-list.html", gin.H{
		"Keys":         keys,
		"GoDateFormat": h.service.GetGoDateFormat(),
	})
}

// DeleteAPIKey removes an API key
func (h *SettingsHandler) DeleteAPIKey(c *gin.Context) {
	idStr := c.Param("id")
	id, err := strconv.ParseUint(idStr, 10, 32)
	if err != nil {
		c.HTML(http.StatusBadRequest, "api-keys-list.html", gin.H{
			"Error": "Invalid API key ID",
		})
		return
	}

	err = h.service.DeleteAPIKey(uint(id))
	if err != nil {
		c.HTML(http.StatusInternalServerError, "api-keys-list.html", gin.H{
			"Error": err.Error(),
		})
		return
	}

	// Return updated list
	h.ListAPIKeys(c)
}

// UpdateCurrency updates the currency preference
func (h *SettingsHandler) UpdateCurrency(c *gin.Context) {
	currency := c.PostForm("currency")

	err := h.service.SetCurrency(currency)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"currency": currency,
		"symbol":   h.service.GetCurrencySymbol(),
	})
}

// UpdateDateFormat updates the date format preference
func (h *SettingsHandler) UpdateDateFormat(c *gin.Context) {
	format := c.PostForm("date_format")

	err := h.service.SetDateFormat(format)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"date_format": format})
}

// ToggleDarkMode toggles dark mode preference
func (h *SettingsHandler) ToggleDarkMode(c *gin.Context) {
	enabled := c.PostForm("enabled") == "true"

	err := h.service.SetDarkMode(enabled)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"dark_mode": enabled,
	})
}

// SetupAuth enables authentication with username and password
func (h *SettingsHandler) SetupAuth(c *gin.Context) {
	username := c.PostForm("username")
	password := c.PostForm("password")
	confirmPassword := c.PostForm("confirm_password")

	// Validate inputs
	if username == "" || password == "" {
		c.HTML(http.StatusBadRequest, "auth-message.html", gin.H{
			"Error": "Username and password are required",
			"Type":  "error",
		})
		return
	}

	if password != confirmPassword {
		c.HTML(http.StatusBadRequest, "auth-message.html", gin.H{
			"Error": "Passwords do not match",
			"Type":  "error",
		})
		return
	}

	if len(password) < 8 {
		c.HTML(http.StatusBadRequest, "auth-message.html", gin.H{
			"Error": "Password must be at least 8 characters long",
			"Type":  "error",
		})
		return
	}

	// Check if SMTP is configured (required for password reset)
	_, err := h.service.GetSMTPConfig()
	if err != nil {
		c.HTML(http.StatusBadRequest, "auth-message.html", gin.H{
			"Error": "Please configure email settings first (required for password recovery)",
			"Type":  "error",
		})
		return
	}

	// Setup authentication
	err = h.service.SetupAuth(username, password)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "auth-message.html", gin.H{
			"Error": err.Error(),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "auth-message.html", gin.H{
		"Message": "Authentication enabled successfully. You will need to login on next page load.",
		"Type":    "success",
	})
}

// DisableAuth disables authentication
func (h *SettingsHandler) DisableAuth(c *gin.Context) {
	err := h.service.DisableAuth()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "auth-message.html", gin.H{
			"Error": err.Error(),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "auth-message.html", gin.H{
		"Message": "Authentication disabled successfully",
		"Type":    "success",
	})
}

// GetAuthStatus returns the current authentication status
func (h *SettingsHandler) GetAuthStatus(c *gin.Context) {
	isEnabled := h.service.IsAuthEnabled()
	username, _ := h.service.GetAuthUsername()

	c.JSON(http.StatusOK, gin.H{
		"enabled":  isEnabled,
		"username": username,
	})
}

// GetTheme returns the current theme setting
func (h *SettingsHandler) GetTheme(c *gin.Context) {
	theme, err := h.service.GetTheme()
	if err != nil {
		// Default to 'default' theme if not set
		theme = "default"
	}

	c.JSON(http.StatusOK, gin.H{
		"theme": theme,
	})
}

// SavePushoverSettings saves Pushover configuration
func (h *SettingsHandler) SavePushoverSettings(c *gin.Context) {
	var config models.PushoverConfig

	// Parse form data
	config.UserKey = c.PostForm("pushover_user_key")
	config.AppToken = c.PostForm("pushover_app_token")

	// Validate required fields
	if config.UserKey == "" || config.AppToken == "" {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "User Key and App Token are required",
			"Type":  "error",
		})
		return
	}

	// Save configuration
	err := h.service.SavePushoverConfig(&config)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "smtp-message.html", gin.H{
			"Error": err.Error(),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "smtp-message.html", gin.H{
		"Message": "Pushover settings saved successfully",
		"Type":    "success",
	})
}

// TestPushoverConnection tests Pushover configuration
func (h *SettingsHandler) TestPushoverConnection(c *gin.Context) {
	var config models.PushoverConfig

	// Parse form data
	config.UserKey = c.PostForm("pushover_user_key")
	config.AppToken = c.PostForm("pushover_app_token")

	// Validate required fields
	if config.UserKey == "" || config.AppToken == "" {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "User Key and App Token are required for testing",
			"Type":  "error",
		})
		return
	}

	// Create a temporary PushoverService to test
	pushoverService := service.NewPushoverService(h.service)

	// Temporarily save config for testing
	originalConfig, _ := h.service.GetPushoverConfig()
	defer func() {
		var restoreErr error
		if originalConfig != nil {
			restoreErr = h.service.SavePushoverConfig(originalConfig)
		} else {
			// No original config existed, so delete the test config by saving empty values
			restoreErr = h.service.SavePushoverConfig(&models.PushoverConfig{
				UserKey:  "",
				AppToken: "",
			})
		}
		if restoreErr != nil {
			log.Printf("Warning: failed to restore Pushover config after test: %v", restoreErr)
		}
	}()

	// Save test config
	if err := h.service.SavePushoverConfig(&config); err != nil {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": fmt.Sprintf("Failed to save test config: %v", err),
			"Type":  "error",
		})
		return
	}

	// Send test notification
	err := pushoverService.SendNotification("SubTrackr Test", "This is a test notification from SubTrackr. If you received this, your Pushover configuration is working correctly!", 0)
	if err != nil {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": fmt.Sprintf("Failed to send test notification: %v", err),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "smtp-message.html", gin.H{
		"Message": "Pushover connection test successful! Check your device for the test notification.",
		"Type":    "success",
	})
}

// SaveWebhookSettings saves Webhook configuration
func (h *SettingsHandler) SaveWebhookSettings(c *gin.Context) {
	var config models.WebhookConfig
	config.URL = c.PostForm("webhook_url")

	if config.URL == "" {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "Webhook URL is required",
			"Type":  "error",
		})
		return
	}

	// Validate URL scheme to prevent SSRF
	if !strings.HasPrefix(config.URL, "http://") && !strings.HasPrefix(config.URL, "https://") {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "Webhook URL must use http:// or https:// scheme",
			"Type":  "error",
		})
		return
	}

	// Parse headers from textarea (Key: Value format, one per line)
	headersRaw := c.PostForm("webhook_headers")
	headers := make(map[string]string)
	for _, line := range splitLines(headersRaw) {
		line = trimSpace(line)
		if line == "" {
			continue
		}
		parts := splitN(line, ":", 2)
		if len(parts) == 2 {
			headers[trimSpace(parts[0])] = trimSpace(parts[1])
		}
	}
	config.Headers = headers

	err := h.service.SaveWebhookConfig(&config)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "smtp-message.html", gin.H{
			"Error": err.Error(),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "smtp-message.html", gin.H{
		"Message": "Webhook settings saved successfully",
		"Type":    "success",
	})
}

// TestWebhookConnection tests Webhook configuration
func (h *SettingsHandler) TestWebhookConnection(c *gin.Context) {
	webhookURL := c.PostForm("webhook_url")
	if webhookURL == "" {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "Webhook URL is required for testing",
			"Type":  "error",
		})
		return
	}

	// Validate URL scheme to prevent SSRF
	if !strings.HasPrefix(webhookURL, "http://") && !strings.HasPrefix(webhookURL, "https://") {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": "Webhook URL must use http:// or https:// scheme",
			"Type":  "error",
		})
		return
	}

	// Parse headers
	headersRaw := c.PostForm("webhook_headers")
	headers := make(map[string]string)
	for _, line := range splitLines(headersRaw) {
		line = trimSpace(line)
		if line == "" {
			continue
		}
		parts := splitN(line, ":", 2)
		if len(parts) == 2 {
			headers[trimSpace(parts[0])] = trimSpace(parts[1])
		}
	}

	testConfig := &models.WebhookConfig{URL: webhookURL, Headers: headers}

	// Temporarily save config for testing
	originalConfig, _ := h.service.GetWebhookConfig()
	defer func() {
		var restoreErr error
		if originalConfig != nil {
			restoreErr = h.service.SaveWebhookConfig(originalConfig)
		} else {
			restoreErr = h.service.SaveWebhookConfig(&models.WebhookConfig{})
		}
		if restoreErr != nil {
			log.Printf("Warning: failed to restore webhook config after test: %v", restoreErr)
		}
	}()

	if err := h.service.SaveWebhookConfig(testConfig); err != nil {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": fmt.Sprintf("Failed to save test config: %v", err),
			"Type":  "error",
		})
		return
	}

	webhookService := service.NewWebhookService(h.service)
	payload := &service.WebhookPayload{
		Event:     "test",
		Title:     "SubTrackr Test",
		Message:   "This is a test notification from SubTrackr. If you received this, your webhook configuration is working correctly!",
		Timestamp: time.Now().UTC().Format(time.RFC3339),
	}

	err := webhookService.SendWebhook(payload)
	if err != nil {
		c.HTML(http.StatusBadRequest, "smtp-message.html", gin.H{
			"Error": fmt.Sprintf("Webhook test failed: %v", err),
			"Type":  "error",
		})
		return
	}

	c.HTML(http.StatusOK, "smtp-message.html", gin.H{
		"Message": "Webhook test successful! Check your endpoint for the test payload.",
		"Type":    "success",
	})
}

// GetPushoverConfig returns current Pushover configuration (without sensitive data)
func (h *SettingsHandler) GetPushoverConfig(c *gin.Context) {
	config, err := h.service.GetPushoverConfig()
	if err != nil {
		c.JSON(http.StatusOK, gin.H{"configured": false})
		return
	}

	// Don't send the full token, just indicate if configured
	c.JSON(http.StatusOK, gin.H{
		"configured":    true,
		"has_user_key":  config.UserKey != "",
		"has_app_token": config.AppToken != "",
	})
}

// ToggleICalSubscription toggles iCal subscription on/off
func (h *SettingsHandler) ToggleICalSubscription(c *gin.Context) {
	current := h.service.IsICalSubscriptionEnabled()
	newState := !current

	if err := h.service.SetICalSubscriptionEnabled(newState); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	var url string
	if newState {
		token, err := h.service.GetOrGenerateICalToken()
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		url = buildBaseURL(c, h.service.GetBaseURL()) + "/ical/" + token
	}

	c.JSON(http.StatusOK, gin.H{
		"enabled": newState,
		"url":     url,
	})
}

// GetICalSubscriptionURL returns the current iCal subscription status and URL
func (h *SettingsHandler) GetICalSubscriptionURL(c *gin.Context) {
	enabled := h.service.IsICalSubscriptionEnabled()
	var url string
	if enabled {
		token, err := h.service.GetOrGenerateICalToken()
		if err == nil {
			url = buildBaseURL(c, h.service.GetBaseURL()) + "/ical/" + token
		}
	}

	c.JSON(http.StatusOK, gin.H{
		"enabled": enabled,
		"url":     url,
	})
}

// RegenerateICalToken generates a new iCal subscription token
func (h *SettingsHandler) RegenerateICalToken(c *gin.Context) {
	token, err := h.service.RegenerateICalToken()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	url := buildBaseURL(c, h.service.GetBaseURL()) + "/ical/" + token

	c.JSON(http.StatusOK, gin.H{
		"url": url,
	})
}

// UpdateBaseURL saves the base URL setting
func (h *SettingsHandler) UpdateBaseURL(c *gin.Context) {
	baseURL := c.PostForm("base_url")

	if err := h.service.SetBaseURL(baseURL); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"base_url": baseURL,
	})
}

// SetTheme saves the theme preference
func (h *SettingsHandler) SetTheme(c *gin.Context) {
	var req struct {
		Theme string `json:"theme" binding:"required"`
	}

	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid request",
		})
		return
	}

	// Validate theme name
	validThemes := map[string]bool{
		"default":   true,
		"dark":      true,
		"christmas": true,
		"midnight":  true,
		"ocean":     true,
	}

	if !validThemes[req.Theme] {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid theme name",
		})
		return
	}

	if err := h.service.SetTheme(req.Theme); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "Failed to save theme",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success": true,
		"theme":   req.Theme,
	})
}


================================================
FILE: internal/handlers/subscription.go
================================================
package handlers

import (
	"encoding/csv"
	"encoding/json"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"strconv"
	"subtrackr/internal/models"
	"subtrackr/internal/service"
	"subtrackr/internal/version"
	"time"

	"github.com/gin-gonic/gin"
)

// SubscriptionWithConversion represents a subscription with currency conversion info
type SubscriptionWithConversion struct {
	*models.Subscription
	ConvertedCost         float64 `json:"converted_cost"`
	ConvertedAnnualCost   float64 `json:"converted_annual_cost"`
	ConvertedMonthlyCost  float64 `json:"converted_monthly_cost"`
	DisplayCurrency       string  `json:"display_currency"`
	DisplayCurrencySymbol string  `json:"display_currency_symbol"`
	ShowConversion        bool    `json:"show_conversion"`
}

type SubscriptionHandler struct {
	service         *service.SubscriptionService
	settingsService *service.SettingsService
	currencyService *service.CurrencyService
	emailService    *service.EmailService
	pushoverService *service.PushoverService
	webhookService  *service.WebhookService
	logoService     *service.LogoService
	categoryService *service.CategoryService
}

func NewSubscriptionHandler(service *service.SubscriptionService, settingsService *service.SettingsService, currencyService *service.CurrencyService, emailService *service.EmailService, pushoverService *service.PushoverService, webhookService *service.WebhookService, logoService *service.LogoService, categoryService *service.CategoryService) *SubscriptionHandler {
	return &SubscriptionHandler{
		service:         service,
		settingsService: settingsService,
		currencyService: currencyService,
		emailService:    emailService,
		pushoverService: pushoverService,
		webhookService:  webhookService,
		logoService:     logoService,
		categoryService: categoryService,
	}
}

// enrichWithCurrencyConversion adds currency conversion info to subscriptions
func (h *SubscriptionHandler) enrichWithCurrencyConversion(subscriptions []models.Subscription) []SubscriptionWithConversion {
	displayCurrency := h.settingsService.GetCurrency()
	displaySymbol := h.settingsService.GetCurrencySymbol()

	result := make([]SubscriptionWithConversion, len(subscriptions))

	for i := range subscriptions {
		// Create a copy of the subscription for modification; this pattern is correct for Go 1.22+
		sub := subscriptions[i]
		enriched := SubscriptionWithConversion{
			Subscription:          &sub,
			DisplayCurrency:       displayCurrency,
			DisplayCurrencySymbol: displaySymbol,
			ShowConversion:        false,
		}

		if h.currencyService.IsEnabled() && sub.OriginalCurrency != "" && sub.OriginalCurrency != displayCurrency {
			if convertedCost, err := h.currencyService.ConvertAmount(sub.Cost, sub.OriginalCurrency, displayCurrency); err == nil {
				enriched.ConvertedCost = convertedCost
				ratio := convertedCost / sub.Cost
				enriched.ConvertedAnnualCost = sub.AnnualCost() * ratio
				enriched.ConvertedMonthlyCost = sub.MonthlyCost() * ratio
				enriched.ShowConversion = true
			}
		} else if sub.OriginalCurrency != "" && sub.OriginalCurrency != displayCurrency {
			// Different currency but conversion not available - show original currency
			enriched.ConvertedCost = sub.Cost
			enriched.ConvertedAnnualCost = sub.AnnualCost()
			enriched.ConvertedMonthlyCost = sub.MonthlyCost()
			enriched.DisplayCurrency = sub.OriginalCurrency
			enriched.DisplayCurrencySymbol = service.CurrencySymbolForCode(sub.OriginalCurrency)
		} else {
			// Same currency or no conversion needed
			enriched.ConvertedCost = sub.Cost
			enriched.ConvertedAnnualCost = sub.AnnualCost()
			enriched.ConvertedMonthlyCost = sub.MonthlyCost()
		}

		result[i] = enriched
	}

	return result
}

// isHighCostWithCurrency checks if a subscription is high-cost, respecting currency conversion
// The threshold is in the user's display currency, so we convert the subscription's monthly cost
// to the display currency before comparing
func (h *SubscriptionHandler) isHighCostWithCurrency(subscription *models.Subscription) bool {
	threshold := h.settingsService.GetFloatSettingWithDefault("high_cost_threshold", 50.0)
	displayCurrency := h.settingsService.GetCurrency()

	// Get monthly cost in subscription's original currency
	monthlyCost := subscription.MonthlyCost()

	// If currencies match or conversion is disabled, compare directly
	if subscription.OriginalCurrency == displayCurrency || !h.currencyService.IsEnabled() {
		return monthlyCost > threshold
	}

	// Convert monthly cost to display currency
	convertedMonthlyCost, err := h.currencyService.ConvertAmount(monthlyCost, subscription.OriginalCurrency, displayCurrency)
	if err != nil {
		// If conversion fails, fall back to direct comparison
		// Note: This may not be accurate if currencies differ, but prevents silent failures
		// The warning log helps identify when this fallback is used
		log.Printf("Warning: Failed to convert currency for high-cost check (%s to %s): %v. Using direct comparison.", subscription.OriginalCurrency, displayCurrency, err)
		return monthlyCost > threshold
	}

	// Compare converted monthly cost against threshold
	return convertedMonthlyCost > threshold
}

// fetchAndSetLogo fetches a logo for a subscription if URL is provided and icon_url is empty
// This is a helper method to avoid code duplication between create and update handlers
func (h *SubscriptionHandler) fetchAndSetLogo(subscription *models.Subscription) {
	if subscription.URL == "" || subscription.IconURL != "" {
		return
	}

	iconURL, err := h.logoService.FetchLogoFromURL(subscription.URL)
	if err == nil && iconURL != "" {
		subscription.IconURL = iconURL
		log.Printf("Fetched logo: %s -> %s", subscription.URL, iconURL)
	} else if err != nil {
		log.Printf("Failed to fetch logo for URL %s: %v", subscription.URL, err)
	}
}

func parseScheduleInterval(s string) int {
	if s == "" {
		return 1
	}
	v, err := strconv.Atoi(s)
	if err != nil || v < 1 {
		return 1
	}
	return v
}

// parseDatePtr parses a date string in "2006-01-02" format and returns a pointer to time.Time.
// Returns nil if the string is empty or if parsing fails.
// Logs parsing errors for debugging purposes.
func parseDatePtr(dateStr string) *time.Time {
	if dateStr == "" {
		return nil
	}
	if date, err := time.Parse("2006-01-02", dateStr); err == nil {
		return &date
	}
	// Log parsing errors for debugging (invalid date format from form)
	log.Printf("Failed to parse date string '%s': expected format YYYY-MM-DD", dateStr)
	return nil
}

// Dashboard renders the main dashboard page
func (h *SubscriptionHandler) Dashboard(c *gin.Context) {
	stats, err := h.service.GetStats()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "error.html", gin.H{"error": err.Error()})
		return
	}

	subscriptions, err := h.service.GetAll()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "error.html", gin.H{"error": err.Error()})
		return
	}

	// Enrich with currency conversion
	enrichedSubs := h.enrichWithCurrencyConversion(subscriptions)

	c.HTML(http.StatusOK, "dashboard.html", gin.H{
		"Title":          "Dashboard",
		"CurrentPage":    "dashboard",
		"Stats":          stats,
		"Subscriptions":  enrichedSubs,
		"CurrencySymbol": h.settingsService.GetCurrencySymbol(),
		"DarkMode":       h.settingsService.IsDarkModeEnabled(),
	})
}

// SubscriptionsList renders the subscriptions list page
func (h *SubscriptionHandler) SubscriptionsList(c *gin.Context) {
	// Get sort parameters from query string
	sortBy := c.DefaultQuery("sort", "created_at")
	order := c.DefaultQuery("order", "desc")

	// Get sorted subscriptions
	subscriptions, err := h.service.GetAllSorted(sortBy, order)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "error.html", gin.H{"error": err.Error()})
		return
	}

	// Enrich with currency conversion
	enrichedSubs := h.enrichWithCurrencyConversion(subscriptions)

	c.HTML(http.StatusOK, "subscriptions.html", gin.H{
		"Title":          "Subscriptions",
		"CurrentPage":    "subscriptions",
		"Subscriptions":  enrichedSubs,
		"CurrencySymbol": h.settingsService.GetCurrencySymbol(),
		"DarkMode":       h.settingsService.IsDarkModeEnabled(),
		"SortBy":         sortBy,
		"Order":          order,
		"GoDateFormat":   h.settingsService.GetGoDateFormat(),
	})
}

// Analytics renders the analytics page
func (h *SubscriptionHandler) Analytics(c *gin.Context) {
	stats, err := h.service.GetStats()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "error.html", gin.H{"error": err.Error()})
		return
	}

	c.HTML(http.StatusOK, "analytics.html", gin.H{
		"Title":          "Analytics",
		"CurrentPage":    "analytics",
		"Stats":          stats,
		"CurrencySymbol": h.settingsService.GetCurrencySymbol(),
		"DarkMode":       h.settingsService.IsDarkModeEnabled(),
	})
}

// Calendar renders the calendar page with subscription renewal dates
func (h *SubscriptionHandler) Calendar(c *gin.Context) {
	// Get all subscriptions with renewal dates
	subscriptions, err := h.service.GetAll()
	if err != nil {
		c.HTML(http.StatusInternalServerError, "error.html", gin.H{"error": err.Error()})
		return
	}

	// Filter subscriptions with renewal dates and group by date
	// Create a simplified structure for JavaScript
	type Event struct {
		Name    string  `json:"name"`
		Cost    float64 `json:"cost"`
		ID      uint    `json:"id"`
		IconURL string  `json:"icon_url"`
	}
	eventsByDate := make(map[string][]Event)
	for _, sub := range subscriptions {
		if sub.RenewalDate != nil && sub.Status == "Active" {
			dateKey := sub.RenewalDate.Format("2006-01-02")
			eventsByDate[dateKey] = append(eventsByDate[dateKey], Event{
				Name:    sub.Name,
				Cost:    sub.Cost,
				ID:      sub.ID,
				IconURL: sub.IconURL,
			})
		}
	}

	// Get current month/year or from query params
	now := time.Now()
	year := now.Year()
	month := int(now.Month())

	if y := c.Query("year"); y != "" {
		if yInt, err := strconv.Atoi(y); err == nil {
			year = yInt
		}
	}
	if m := c.Query("month"); m != "" {
		if mInt, err := strconv.Atoi(m); err == nil {
			month = mInt
		}
	}

	// Validate month range
	if month < 1 {
		month = 1
	}
	if month > 12 {
		month = 12
	}

	// Calculate previous and next month
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	prevMonth := firstOfMonth.AddDate(0, -1, 0)
	nextMonth := firstOfMonth.AddDate(0, 1, 0)

	// Serialize events to JSON for JavaScript
	eventsJSON, _ := json.Marshal(eventsByDate)

	// Prevent caching to ensure calendar updates when navigating months
	c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
	c.Header("Pragma", "no-cache")
	c.Header("Expires", "0")

	// Build iCal subscription URL if enabled
	icalSubscriptionEnabled := h.settingsService.IsICalSubscriptionEnabled()
	var icalSubscriptionURL string
	if icalSubscriptionEnabled {
		token, err := h.settingsService.GetOrGenerateICalToken()
		if err == nil {
			icalSubscriptionURL = buildBaseURL(c, h.settingsService.GetBaseURL()) + "/ical/" + token
		}
	}

	c.HTML(http.StatusOK, "calendar.html", gin.H{
		"Title":                   "Calendar",
		"CurrentPage":             "calendar",
		"Year":                    year,
		"Month":                   month,
		"MonthName":               firstOfMonth.Format("January 2006"),
		"EventsByDate":            template.JS(string(eventsJSON)),
		"FirstOfMonth":            firstOfMonth,
		"PrevMonth":               prevMonth,
		"NextMonth":               nextMonth,
		"CurrencySymbol":          h.settingsService.GetCurrencySymbol(),
		"DarkMode":                h.settingsService.IsDarkModeEnabled(),
		"ICalSubscriptionEnabled": icalSubscriptionEnabled,
		"ICalSubscriptionURL":     icalSubscriptionURL,
	})
}

// generateICalContent generates iCal content for all active subscriptions
// If forSubscription is true, adds subscription-friendly properties for calendar polling
func (h *SubscriptionHandler) generateICalContent(forSubscription bool) (string, error) {
	subscriptions, err := h.service.GetAll()
	if err != nil {
		return "", err
	}

	icalContent := "BEGIN:VCALENDAR\r\n"
	icalContent += "VERSION:2.0\r\n"
	icalContent += "PRODID:-//SubTrackr//Subscription Renewals//EN\r\n"
	icalContent += "CALSCALE:GREGORIAN\r\n"
	icalContent += "METHOD:PUBLISH\r\n"

	if forSubscription {
		icalContent += "X-WR-CALNAME:SubTrackr Renewals\r\n"
		icalContent += "REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\n"
		icalContent += "X-PUBLISHED-TTL:PT1H\r\n"
	}

	now := time.Now()
	for _, sub := range subscriptions {
		if sub.RenewalDate != nil && sub.Status == "Active" {
			dtStart := sub.RenewalDate.Format("20060102T150000Z")
			dtEnd := sub.RenewalDate.Add(1 * time.Hour).Format("20060102T150000Z")
			dtStamp := now.Format("20060102T150000Z")
			uid := fmt.Sprintf("subtrackr-%d-%d@subtrackr", sub.ID, sub.RenewalDate.Unix())

			summary := fmt.Sprintf("%s Renewal", sub.Name)
			subCurrencySymbol := h.settingsService.GetCurrencySymbol()
			if sub.OriginalCurrency != "" && sub.OriginalCurrency != h.settingsService.GetCurrency() {
				subCurrencySymbol = service.CurrencySymbolForCode(sub.OriginalCurrency)
			}
			description := fmt.Sprintf("Subscription: %s\\nCost: %s%.2f\\nSchedule: %s", sub.Name, subCurrencySymbol, sub.Cost, sub.DisplaySchedule())
			if sub.URL != "" {
				description += fmt.Sprintf("\\nURL: %s", sub.URL)
			}

			icalContent += "BEGIN:VEVENT\r\n"
			icalContent += fmt.Sprintf("UID:%s\r\n", uid)
			icalContent += fmt.Sprintf("DTSTAMP:%s\r\n", dtStamp)
			icalContent += fmt.Sprintf("DTSTART:%s\r\n", dtStart)
			icalContent += fmt.Sprintf("DTEND:%s\r\n", dtEnd)
			icalContent += fmt.Sprintf("SUMMARY:%s\r\n", summary)
			icalContent += fmt.Sprintf("DESCRIPTION:%s\r\n", description)
			icalContent += "STATUS:CONFIRMED\r\n"
			icalContent += "SEQUENCE:0\r\n"

			interval := sub.ScheduleInterval
			if interval < 1 {
				interval = 1
			}
			switch sub.Schedule {
			case "Daily":
				icalContent += fmt.Sprintf("RRULE:FREQ=DAILY;INTERVAL=%d\r\n", interval)
			case "Weekly":
				icalContent += fmt.Sprintf("RRULE:FREQ=WEEKLY;INTERVAL=%d\r\n", interval)
			case "Monthly":
				icalContent += fmt.Sprintf("RRULE:FREQ=MONTHLY;INTERVAL=%d\r\n", interval)
			case "Quarterly":
				icalContent += fmt.Sprintf("RRULE:FREQ=MONTHLY;INTERVAL=%d\r\n", 3*interval)
			case "Annual":
				icalContent += fmt.Sprintf("RRULE:FREQ=YEARLY;INTERVAL=%d\r\n", interval)
			}

			icalContent += "END:VEVENT\r\n"
		}
	}

	icalContent += "END:VCALENDAR\r\n"
	return icalContent, nil
}

// ExportICal generates and downloads an iCal file with all subscription renewal dates
func (h *SubscriptionHandler) ExportICal(c *gin.Context) {
	icalContent, err := h.generateICalContent(false)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.Header("Content-Type", "text/calendar; charset=utf-8")
	c.Header("Content-Disposition", `attachment; filename="subtrackr-renewals.ics"`)
	c.Data(http.StatusOK, "text/calendar; charset=utf-8", []byte(icalContent))
}

// ServeICalSubscription serves iCal content for calendar subscription (public, token-validated)
func (h *SubscriptionHandler) ServeICalSubscription(c *gin.Context) {
	token := c.Param("token")

	if !h.settingsService.IsICalSubscriptionEnabled() {
		c.String(http.StatusNotFound, "iCal subscription is not enabled")
		return
	}

	if !h.settingsService.ValidateICalToken(token) {
		c.String(http.StatusUnauthorized, "Invalid token")
		return
	}

	icalContent, err := h.generateICalContent(true)
	if err != nil {
		c.String(http.StatusInternalServerError, "Failed to generate calendar")
		return
	}

	c.Header("Content-Type", "text/calendar; charset=utf-8")
	c.Data(http.StatusOK, "text/calendar; charset=utf-8", []byte(icalContent))
}

// Settings renders the settings page
func (h *SubscriptionHandler) Settings(c *gin.Context) {
	// Load SMTP config if available (without password)
	var smtpConfig *models.SMTPConfig
	smtpConfigured := false
	config, err := h.settingsService.GetSMTPConfig()
	if err == nil && config != nil {
		// Don't include password in template
		config.Password = ""
		smtpConfig = config
		smtpConfigured = true
	}

	// Load Pushover config if available
	var pushoverConfig *models.PushoverConfig
	pushoverConfigured := false
	pushoverCfg, err := h.settingsService.GetPushoverConfig()
	if err == nil && pushoverCfg != nil {
		pushoverConfig = pushoverCfg
		pushoverConfigured = true
	}

	// Load Webhook config if available
	var webhookConfig *models.WebhookConfig
	webhookConfigured := false
	webhookCfg, err := h.settingsService.GetWebhookConfig()
	if err == nil && webhookCfg != nil && webhookCfg.URL != "" {
		webhookConfig = webhookCfg
		webhookConfigured = true
	}

	// Get auth settings
	authEnabled := h.settingsService.IsAuthEnabled()
	authUsername, _ := h.settingsService.GetAuthUsername()

	// Build iCal subscription URL if enabled
	icalSubscriptionEnabled := h.settingsService.IsICalSubscriptionEnabled()
	var icalSubscriptionURL string
	if icalSubscriptionEnabled {
		token, err := h.settingsService.GetOrGenerateICalToken()
		if err == nil {
			icalSubscriptionURL = buildBaseURL(c, h.settingsService.GetBaseURL()) + "/ical/" + token
		}
	}
Download .txt
gitextract_9u87fcx7/

├── .beads/
│   ├── interactions.jsonl
│   └── issues.jsonl
├── .claude/
│   └── commands/
│       └── release.md
├── .dockerignore
├── .gitattributes
├── .github/
│   └── workflows/
│       ├── claude-code-review.yml
│       ├── claude.yml
│       ├── docker-publish.yml
│       └── test-build.yml
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── Dockerfile
├── LICENSE
├── MIGRATION_v0.3.0.md
├── Makefile
├── PLAN-login-settings.md
├── README.md
├── cmd/
│   ├── mcp/
│   │   └── main.go
│   └── migrate-dates/
│       └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── database/
│   │   ├── database.go
│   │   └── migrations.go
│   ├── handlers/
│   │   ├── auth.go
│   │   ├── category.go
│   │   ├── settings.go
│   │   ├── subscription.go
│   │   ├── subscription_test.go
│   │   └── url.go
│   ├── middleware/
│   │   └── auth.go
│   ├── models/
│   │   ├── category.go
│   │   ├── date_migration_audit.go
│   │   ├── date_migration_audit_test.go
│   │   ├── exchange_rate.go
│   │   ├── exchange_rate_test.go
│   │   ├── settings.go
│   │   ├── subscription.go
│   │   └── subscription_test.go
│   ├── repository/
│   │   ├── category.go
│   │   ├── exchange_rate.go
│   │   ├── settings.go
│   │   └── subscription.go
│   ├── service/
│   │   ├── category.go
│   │   ├── currency.go
│   │   ├── currency_integration_test.go
│   │   ├── currency_test.go
│   │   ├── email.go
│   │   ├── logo.go
│   │   ├── pushover.go
│   │   ├── pushover_test.go
│   │   ├── renewal_reminder_test.go
│   │   ├── session.go
│   │   ├── settings.go
│   │   ├── settings_test.go
│   │   ├── subscription.go
│   │   ├── webhook.go
│   │   └── webhook_test.go
│   └── version/
│       └── version.go
├── package.json
├── playwright.config.js
├── templates/
│   ├── analytics.html
│   ├── api-keys-list.html
│   ├── auth-message.html
│   ├── calendar.html
│   ├── categories-list.html
│   ├── dashboard.html
│   ├── error.html
│   ├── forgot-password-error.html
│   ├── forgot-password-success.html
│   ├── forgot-password.html
│   ├── form-errors.html
│   ├── login-error.html
│   ├── login.html
│   ├── reset-password-error.html
│   ├── reset-password-success.html
│   ├── reset-password.html
│   ├── settings.html
│   ├── smtp-message.html
│   ├── subscription-form.html
│   ├── subscription-list.html
│   └── subscriptions.html
├── test-api.sh
├── tests/
│   ├── example.spec.js
│   └── subscription-crud.spec.js
└── web/
    └── static/
        ├── category-management.js
        ├── css/
        │   └── themes.css
        ├── js/
        │   ├── darkmode.js
        │   ├── mobile-menu.js
        │   ├── sorting.js
        │   ├── theme-init.js
        │   └── themes.js
        └── manifest.json
Download .txt
SYMBOL INDEX (432 symbols across 45 files)

FILE: cmd/mcp/main.go
  function main (line 20) | func main() {

FILE: cmd/migrate-dates/main.go
  function main (line 17) | func main() {
  function compareAllSubscriptions (line 95) | func compareAllSubscriptions(db *gorm.DB) {
  function compareSubscription (line 136) | func compareSubscription(checker *models.DateMigrationSafetyCheck, id ui...
  function printStats (line 160) | func printStats(stats map[string]interface{}) {

FILE: internal/config/config.go
  type Config (line 7) | type Config struct
  function Load (line 13) | func Load() *Config {
  function getEnv (line 21) | func getEnv(key, defaultValue string) string {

FILE: internal/database/database.go
  function Initialize (line 9) | func Initialize(dbPath string) (*gorm.DB, error) {

FILE: internal/database/migrations.go
  function RunMigrations (line 11) | func RunMigrations(db *gorm.DB) error {
  function migrateCategoriesToDynamic (line 44) | func migrateCategoriesToDynamic(db *gorm.DB) error {
  function migrateCurrencyFields (line 106) | func migrateCurrencyFields(db *gorm.DB) error {
  function migrateDateCalculationVersioning (line 134) | func migrateDateCalculationVersioning(db *gorm.DB) error {
  function migrateSubscriptionIcons (line 162) | func migrateSubscriptionIcons(db *gorm.DB) error {
  function migrateReminderTracking (line 190) | func migrateReminderTracking(db *gorm.DB) error {
  function migrateCancellationReminderTracking (line 217) | func migrateCancellationReminderTracking(db *gorm.DB) error {
  function migrateScheduleInterval (line 243) | func migrateScheduleInterval(db *gorm.DB) error {
  function migrateReminderEnabled (line 266) | func migrateReminderEnabled(db *gorm.DB) error {

FILE: internal/handlers/auth.go
  type AuthHandler (line 14) | type AuthHandler struct
    method ShowLoginPage (line 44) | func (h *AuthHandler) ShowLoginPage(c *gin.Context) {
    method Login (line 63) | func (h *AuthHandler) Login(c *gin.Context) {
    method Logout (line 112) | func (h *AuthHandler) Logout(c *gin.Context) {
    method ShowForgotPasswordPage (line 122) | func (h *AuthHandler) ShowForgotPasswordPage(c *gin.Context) {
    method ForgotPassword (line 127) | func (h *AuthHandler) ForgotPassword(c *gin.Context) {
    method ShowResetPasswordPage (line 174) | func (h *AuthHandler) ShowResetPasswordPage(c *gin.Context) {
    method ResetPassword (line 197) | func (h *AuthHandler) ResetPassword(c *gin.Context) {
  function NewAuthHandler (line 20) | func NewAuthHandler(settingsService *service.SettingsService, sessionSer...
  function isValidRedirect (line 29) | func isValidRedirect(redirect string) bool {

FILE: internal/handlers/category.go
  type CategoryHandler (line 12) | type CategoryHandler struct
    method ListCategories (line 21) | func (h *CategoryHandler) ListCategories(c *gin.Context) {
    method CreateCategory (line 31) | func (h *CategoryHandler) CreateCategory(c *gin.Context) {
    method UpdateCategory (line 46) | func (h *CategoryHandler) UpdateCategory(c *gin.Context) {
    method DeleteCategory (line 66) | func (h *CategoryHandler) DeleteCategory(c *gin.Context) {
  function NewCategoryHandler (line 16) | func NewCategoryHandler(service *service.CategoryService) *CategoryHandl...

FILE: internal/handlers/settings.go
  function splitLines (line 20) | func splitLines(s string) []string { return strings.Split(s, "\n") }
  function trimSpace (line 21) | func trimSpace(s string) string    { return strings.TrimSpace(s) }
  function splitN (line 22) | func splitN(s, sep string, n int) []string { return strings.SplitN(s, se...
  type SettingsHandler (line 24) | type SettingsHandler struct
    method SaveSMTPSettings (line 33) | func (h *SettingsHandler) SaveSMTPSettings(c *gin.Context) {
    method TestSMTPConnection (line 77) | func (h *SettingsHandler) TestSMTPConnection(c *gin.Context) {
    method UpdateNotificationSetting (line 182) | func (h *SettingsHandler) UpdateNotificationSetting(c *gin.Context) {
    method GetNotificationSettings (line 258) | func (h *SettingsHandler) GetNotificationSettings(c *gin.Context) {
    method GetSMTPConfig (line 272) | func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {
    method ListAPIKeys (line 288) | func (h *SettingsHandler) ListAPIKeys(c *gin.Context) {
    method CreateAPIKey (line 311) | func (h *SettingsHandler) CreateAPIKey(c *gin.Context) {
    method DeleteAPIKey (line 366) | func (h *SettingsHandler) DeleteAPIKey(c *gin.Context) {
    method UpdateCurrency (line 389) | func (h *SettingsHandler) UpdateCurrency(c *gin.Context) {
    method UpdateDateFormat (line 405) | func (h *SettingsHandler) UpdateDateFormat(c *gin.Context) {
    method ToggleDarkMode (line 418) | func (h *SettingsHandler) ToggleDarkMode(c *gin.Context) {
    method SetupAuth (line 433) | func (h *SettingsHandler) SetupAuth(c *gin.Context) {
    method DisableAuth (line 490) | func (h *SettingsHandler) DisableAuth(c *gin.Context) {
    method GetAuthStatus (line 507) | func (h *SettingsHandler) GetAuthStatus(c *gin.Context) {
    method GetTheme (line 518) | func (h *SettingsHandler) GetTheme(c *gin.Context) {
    method SavePushoverSettings (line 531) | func (h *SettingsHandler) SavePushoverSettings(c *gin.Context) {
    method TestPushoverConnection (line 564) | func (h *SettingsHandler) TestPushoverConnection(c *gin.Context) {
    method SaveWebhookSettings (line 627) | func (h *SettingsHandler) SaveWebhookSettings(c *gin.Context) {
    method TestWebhookConnection (line 679) | func (h *SettingsHandler) TestWebhookConnection(c *gin.Context) {
    method GetPushoverConfig (line 760) | func (h *SettingsHandler) GetPushoverConfig(c *gin.Context) {
    method ToggleICalSubscription (line 776) | func (h *SettingsHandler) ToggleICalSubscription(c *gin.Context) {
    method GetICalSubscriptionURL (line 802) | func (h *SettingsHandler) GetICalSubscriptionURL(c *gin.Context) {
    method RegenerateICalToken (line 819) | func (h *SettingsHandler) RegenerateICalToken(c *gin.Context) {
    method UpdateBaseURL (line 834) | func (h *SettingsHandler) UpdateBaseURL(c *gin.Context) {
    method SetTheme (line 848) | func (h *SettingsHandler) SetTheme(c *gin.Context) {
  function NewSettingsHandler (line 28) | func NewSettingsHandler(service *service.SettingsService) *SettingsHandl...

FILE: internal/handlers/subscription.go
  type SubscriptionWithConversion (line 20) | type SubscriptionWithConversion struct
  type SubscriptionHandler (line 30) | type SubscriptionHandler struct
    method enrichWithCurrencyConversion (line 55) | func (h *SubscriptionHandler) enrichWithCurrencyConversion(subscriptio...
    method isHighCostWithCurrency (line 102) | func (h *SubscriptionHandler) isHighCostWithCurrency(subscription *mod...
    method fetchAndSetLogo (line 130) | func (h *SubscriptionHandler) fetchAndSetLogo(subscription *models.Sub...
    method Dashboard (line 171) | func (h *SubscriptionHandler) Dashboard(c *gin.Context) {
    method SubscriptionsList (line 198) | func (h *SubscriptionHandler) SubscriptionsList(c *gin.Context) {
    method Analytics (line 226) | func (h *SubscriptionHandler) Analytics(c *gin.Context) {
    method Calendar (line 243) | func (h *SubscriptionHandler) Calendar(c *gin.Context) {
    method generateICalContent (line 338) | func (h *SubscriptionHandler) generateICalContent(forSubscription bool...
    method ExportICal (line 410) | func (h *SubscriptionHandler) ExportICal(c *gin.Context) {
    method ServeICalSubscription (line 423) | func (h *SubscriptionHandler) ServeICalSubscription(c *gin.Context) {
    method Settings (line 447) | func (h *SubscriptionHandler) Settings(c *gin.Context) {
    method GetSubscriptions (line 523) | func (h *SubscriptionHandler) GetSubscriptions(c *gin.Context) {
    method GetSubscriptionsAPI (line 548) | func (h *SubscriptionHandler) GetSubscriptionsAPI(c *gin.Context) {
    method CreateSubscription (line 559) | func (h *SubscriptionHandler) CreateSubscription(c *gin.Context) {
    method GetSubscription (line 657) | func (h *SubscriptionHandler) GetSubscription(c *gin.Context) {
    method UpdateSubscription (line 674) | func (h *SubscriptionHandler) UpdateSubscription(c *gin.Context) {
    method DeleteSubscription (line 805) | func (h *SubscriptionHandler) DeleteSubscription(c *gin.Context) {
    method GetStats (line 824) | func (h *SubscriptionHandler) GetStats(c *gin.Context) {
    method GetSubscriptionForm (line 835) | func (h *SubscriptionHandler) GetSubscriptionForm(c *gin.Context) {
    method ExportCSV (line 866) | func (h *SubscriptionHandler) ExportCSV(c *gin.Context) {
    method ExportJSON (line 917) | func (h *SubscriptionHandler) ExportJSON(c *gin.Context) {
    method BackupData (line 935) | func (h *SubscriptionHandler) BackupData(c *gin.Context) {
    method RestoreData (line 962) | func (h *SubscriptionHandler) RestoreData(c *gin.Context) {
    method ClearAllData (line 1063) | func (h *SubscriptionHandler) ClearAllData(c *gin.Context) {
  function NewSubscriptionHandler (line 41) | func NewSubscriptionHandler(service *service.SubscriptionService, settin...
  function parseScheduleInterval (line 144) | func parseScheduleInterval(s string) int {
  function parseDatePtr (line 158) | func parseDatePtr(dateStr string) *time.Time {
  function formatCurrency (line 1086) | func formatCurrency(amount float64) string {
  function formatDate (line 1091) | func formatDate(date *time.Time) string {

FILE: internal/handlers/subscription_test.go
  function TestParseDatePtr (line 10) | func TestParseDatePtr(t *testing.T) {
  function timePtr (line 105) | func timePtr(t time.Time) *time.Time {

FILE: internal/handlers/url.go
  function buildBaseURL (line 11) | func buildBaseURL(c *gin.Context, configuredBaseURL string) string {

FILE: internal/middleware/auth.go
  function AuthMiddleware (line 13) | func AuthMiddleware(settingsService *service.SettingsService, sessionSer...
  function isPublicRoute (line 48) | func isPublicRoute(path string) bool {
  function isHTMLRequest (line 78) | func isHTMLRequest(r *http.Request) bool {
  function APIKeyAuth (line 84) | func APIKeyAuth(settingsService *service.SettingsService) gin.HandlerFunc {

FILE: internal/models/category.go
  type Category (line 6) | type Category struct

FILE: internal/models/date_migration_audit.go
  type DateMigrationLog (line 10) | type DateMigrationLog struct
  type DateMigrationSafetyCheck (line 22) | type DateMigrationSafetyCheck struct
    method MigrateSubscriptionToV2 (line 32) | func (dmsc *DateMigrationSafetyCheck) MigrateSubscriptionToV2(subscrip...
    method CompareCalculationVersions (line 72) | func (dmsc *DateMigrationSafetyCheck) CompareCalculationVersions(subsc...
    method BatchMigrateToV2WithAudit (line 94) | func (dmsc *DateMigrationSafetyCheck) BatchMigrateToV2WithAudit(dryRun...
    method RollbackSubscriptionToV1 (line 133) | func (dmsc *DateMigrationSafetyCheck) RollbackSubscriptionToV1(subscri...
    method GetMigrationStats (line 183) | func (dmsc *DateMigrationSafetyCheck) GetMigrationStats() (map[string]...
  function NewDateMigrationSafetyCheck (line 27) | func NewDateMigrationSafetyCheck(db *gorm.DB) *DateMigrationSafetyCheck {

FILE: internal/models/date_migration_audit_test.go
  function setupAuditTestDB (line 12) | func setupAuditTestDB(t *testing.T) *gorm.DB {
  function TestNewDateMigrationSafetyCheck (line 27) | func TestNewDateMigrationSafetyCheck(t *testing.T) {
  function TestCompareCalculationVersions (line 36) | func TestCompareCalculationVersions(t *testing.T) {
  function TestGetMigrationStats (line 66) | func TestGetMigrationStats(t *testing.T) {

FILE: internal/models/exchange_rate.go
  type ExchangeRate (line 8) | type ExchangeRate struct
    method IsStale (line 19) | func (er *ExchangeRate) IsStale() bool {

FILE: internal/models/exchange_rate_test.go
  function TestExchangeRate_IsStale (line 10) | func TestExchangeRate_IsStale(t *testing.T) {

FILE: internal/models/settings.go
  type Settings (line 8) | type Settings struct
  type SMTPConfig (line 17) | type SMTPConfig struct
  type PushoverConfig (line 28) | type PushoverConfig struct
  type WebhookConfig (line 34) | type WebhookConfig struct
  type NotificationSettings (line 40) | type NotificationSettings struct
  type APIKey (line 50) | type APIKey struct

FILE: internal/models/subscription.go
  type Subscription (line 11) | type Subscription struct
    method effectiveInterval (line 40) | func (s *Subscription) effectiveInterval() int {
    method DisplaySchedule (line 48) | func (s *Subscription) DisplaySchedule() string {
    method AnnualCost (line 64) | func (s *Subscription) AnnualCost() float64 {
    method MonthlyCost (line 83) | func (s *Subscription) MonthlyCost() float64 {
    method DailyCost (line 102) | func (s *Subscription) DailyCost() float64 {
    method IsHighCost (line 107) | func (s *Subscription) IsHighCost(threshold float64) bool {
    method BeforeCreate (line 112) | func (s *Subscription) BeforeCreate(tx *gorm.DB) error {
    method AfterFind (line 122) | func (s *Subscription) AfterFind(tx *gorm.DB) error {
    method BeforeUpdate (line 143) | func (s *Subscription) BeforeUpdate(tx *gorm.DB) error {
    method calculateNextRenewalDate (line 206) | func (s *Subscription) calculateNextRenewalDate() {
    method calculateNextRenewalDateV1 (line 218) | func (s *Subscription) calculateNextRenewalDateV1() {
    method calculateNextRenewalDateV2 (line 229) | func (s *Subscription) calculateNextRenewalDateV2() {
    method calculateNextRenewalDateFromStartDate (line 291) | func (s *Subscription) calculateNextRenewalDateFromStartDate() {
    method calculateNextRenewalDateFromNow (line 406) | func (s *Subscription) calculateNextRenewalDateFromNow() {
    method calculateNextRenewalDateFromNowV2 (line 429) | func (s *Subscription) calculateNextRenewalDateFromNowV2() {
  type Stats (line 456) | type Stats struct
  type CategoryStat (line 468) | type CategoryStat struct

FILE: internal/models/subscription_test.go
  function setupTestDB (line 13) | func setupTestDB(t *testing.T) *gorm.DB {
  function TestSubscription_CalculateNextRenewalDate (line 28) | func TestSubscription_CalculateNextRenewalDate(t *testing.T) {
  function TestSubscription_CalculateNextRenewalDateFromNow (line 97) | func TestSubscription_CalculateNextRenewalDateFromNow(t *testing.T) {
  function TestSubscription_BeforeUpdate_ScheduleChange (line 135) | func TestSubscription_BeforeUpdate_ScheduleChange(t *testing.T) {
  function TestSubscription_BeforeUpdate_NoScheduleChange (line 175) | func TestSubscription_BeforeUpdate_NoScheduleChange(t *testing.T) {
  function TestSubscription_BeforeUpdate_NilRenewalDate (line 205) | func TestSubscription_BeforeUpdate_NilRenewalDate(t *testing.T) {
  function TestSubscription_MonthlyCost (line 231) | func TestSubscription_MonthlyCost(t *testing.T) {
  function TestSubscription_BeforeCreate_WithStartDate (line 277) | func TestSubscription_BeforeCreate_WithStartDate(t *testing.T) {
  function TestSubscription_AnnualCost (line 355) | func TestSubscription_AnnualCost(t *testing.T) {
  function TestSubscription_DailyCost (line 402) | func TestSubscription_DailyCost(t *testing.T) {
  function TestSubscription_IsHighCost (line 449) | func TestSubscription_IsHighCost(t *testing.T) {
  function TestSubscription_DateEdgeCases (line 510) | func TestSubscription_DateEdgeCases(t *testing.T) {
  function TestSubscription_ScheduleChangePreservation (line 627) | func TestSubscription_ScheduleChangePreservation(t *testing.T) {
  function TestSubscription_LeapYearHandling (line 715) | func TestSubscription_LeapYearHandling(t *testing.T) {
  function TestSubscription_TimezoneConsistency (line 773) | func TestSubscription_TimezoneConsistency(t *testing.T) {
  function TestSubscription_DateCalculationV2 (line 808) | func TestSubscription_DateCalculationV2(t *testing.T) {
  function TestSubscription_VersionedCalculation (line 899) | func TestSubscription_VersionedCalculation(t *testing.T) {
  function TestSubscription_CarbonLibraryFeatures (line 931) | func TestSubscription_CarbonLibraryFeatures(t *testing.T) {
  function TestSubscription_DisplaySchedule (line 984) | func TestSubscription_DisplaySchedule(t *testing.T) {
  function TestSubscription_CostWithInterval (line 1011) | func TestSubscription_CostWithInterval(t *testing.T) {
  function TestSubscription_RenewalDateWithInterval (line 1040) | func TestSubscription_RenewalDateWithInterval(t *testing.T) {
  function TestSubscription_RenewalDateV2WithInterval (line 1082) | func TestSubscription_RenewalDateV2WithInterval(t *testing.T) {

FILE: internal/repository/category.go
  type CategoryRepository (line 9) | type CategoryRepository struct
    method Create (line 17) | func (r *CategoryRepository) Create(category *models.Category) (*model...
    method GetAll (line 24) | func (r *CategoryRepository) GetAll() ([]models.Category, error) {
    method GetByID (line 32) | func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
    method Update (line 40) | func (r *CategoryRepository) Update(id uint, category *models.Category...
    method Delete (line 47) | func (r *CategoryRepository) Delete(id uint) error {
    method GetByName (line 51) | func (r *CategoryRepository) GetByName(name string) (*models.Category,...
    method HasSubscriptions (line 59) | func (r *CategoryRepository) HasSubscriptions(id uint) (bool, error) {
  function NewCategoryRepository (line 13) | func NewCategoryRepository(db *gorm.DB) *CategoryRepository {

FILE: internal/repository/exchange_rate.go
  type ExchangeRateRepository (line 10) | type ExchangeRateRepository struct
    method GetRate (line 19) | func (r *ExchangeRateRepository) GetRate(baseCurrency, targetCurrency ...
    method SaveRates (line 43) | func (r *ExchangeRateRepository) SaveRates(rates []models.ExchangeRate...
    method GetLatestRates (line 48) | func (r *ExchangeRateRepository) GetLatestRates(baseCurrency string) (...
    method DeleteStaleRates (line 65) | func (r *ExchangeRateRepository) DeleteStaleRates(olderThan time.Durat...
  function NewExchangeRateRepository (line 14) | func NewExchangeRateRepository(db *gorm.DB) *ExchangeRateRepository {

FILE: internal/repository/settings.go
  type SettingsRepository (line 10) | type SettingsRepository struct
    method Set (line 19) | func (r *SettingsRepository) Set(key, value string) error {
    method Get (line 41) | func (r *SettingsRepository) Get(key string) (string, error) {
    method Delete (line 51) | func (r *SettingsRepository) Delete(key string) error {
    method GetAll (line 56) | func (r *SettingsRepository) GetAll() ([]models.Settings, error) {
    method CreateAPIKey (line 63) | func (r *SettingsRepository) CreateAPIKey(apiKey *models.APIKey) (*mod...
    method GetAllAPIKeys (line 71) | func (r *SettingsRepository) GetAllAPIKeys() ([]models.APIKey, error) {
    method GetAPIKeyByKey (line 78) | func (r *SettingsRepository) GetAPIKeyByKey(key string) (*models.APIKe...
    method DeleteAPIKey (line 88) | func (r *SettingsRepository) DeleteAPIKey(id uint) error {
    method UpdateAPIKeyUsage (line 93) | func (r *SettingsRepository) UpdateAPIKeyUsage(id uint) error {
  function NewSettingsRepository (line 14) | func NewSettingsRepository(db *gorm.DB) *SettingsRepository {

FILE: internal/repository/subscription.go
  type SubscriptionRepository (line 11) | type SubscriptionRepository struct
    method checkLegacyColumn (line 20) | func (r *SubscriptionRepository) checkLegacyColumn() bool {
    method Create (line 31) | func (r *SubscriptionRepository) Create(subscription *models.Subscript...
    method GetAll (line 85) | func (r *SubscriptionRepository) GetAll() ([]models.Subscription, erro...
    method GetAllSorted (line 96) | func (r *SubscriptionRepository) GetAllSorted(sortBy, order string) ([...
    method GetByID (line 135) | func (r *SubscriptionRepository) GetByID(id uint) (*models.Subscriptio...
    method Update (line 143) | func (r *SubscriptionRepository) Update(id uint, subscription *models....
    method Delete (line 224) | func (r *SubscriptionRepository) Delete(id uint) error {
    method Count (line 228) | func (r *SubscriptionRepository) Count() int64 {
    method GetActiveSubscriptions (line 234) | func (r *SubscriptionRepository) GetActiveSubscriptions() ([]models.Su...
    method GetCancelledSubscriptions (line 242) | func (r *SubscriptionRepository) GetCancelledSubscriptions() ([]models...
    method GetUpcomingRenewals (line 250) | func (r *SubscriptionRepository) GetUpcomingRenewals(days int) ([]mode...
    method GetUpcomingCancellations (line 261) | func (r *SubscriptionRepository) GetUpcomingCancellations(days int) ([...
    method GetCategoryStats (line 272) | func (r *SubscriptionRepository) GetCategoryStats() ([]models.Category...
  function NewSubscriptionRepository (line 16) | func NewSubscriptionRepository(db *gorm.DB) *SubscriptionRepository {

FILE: internal/service/category.go
  type CategoryService (line 10) | type CategoryService struct
    method Create (line 18) | func (s *CategoryService) Create(category *models.Category) (*models.C...
    method GetAll (line 22) | func (s *CategoryService) GetAll() ([]models.Category, error) {
    method GetByID (line 26) | func (s *CategoryService) GetByID(id uint) (*models.Category, error) {
    method Update (line 30) | func (s *CategoryService) Update(id uint, category *models.Category) (...
    method GetByName (line 34) | func (s *CategoryService) GetByName(name string) (*models.Category, er...
    method Delete (line 38) | func (s *CategoryService) Delete(id uint) error {
  function NewCategoryService (line 14) | func NewCategoryService(repo *repository.CategoryRepository) *CategorySe...

FILE: internal/service/currency.go
  type CurrencyInfo (line 18) | type CurrencyInfo struct
  function init (line 69) | func init() {
  function GetCurrencyInfo (line 79) | func GetCurrencyInfo(code string) CurrencyInfo {
  function GetAvailableCurrencies (line 87) | func GetAvailableCurrencies() []CurrencyInfo {
  function supportedCurrencySymbols (line 92) | func supportedCurrencySymbols() string {
  type CurrencyService (line 96) | type CurrencyService struct
    method IsEnabled (line 123) | func (s *CurrencyService) IsEnabled() bool {
    method GetExchangeRate (line 128) | func (s *CurrencyService) GetExchangeRate(fromCurrency, toCurrency str...
    method ConvertAmount (line 149) | func (s *CurrencyService) ConvertAmount(amount float64, fromCurrency, ...
    method fetchAndCacheRates (line 160) | func (s *CurrencyService) fetchAndCacheRates(baseCurrency, targetCurre...
    method RefreshRates (line 262) | func (s *CurrencyService) RefreshRates() error {
  type FixerResponse (line 101) | type FixerResponse struct
  type FixerError (line 110) | type FixerError struct
  function NewCurrencyService (line 115) | func NewCurrencyService(repo *repository.ExchangeRateRepository) *Curren...

FILE: internal/service/currency_integration_test.go
  function setupTestDB (line 15) | func setupTestDB(t *testing.T) *gorm.DB {
  function TestCurrencyService_Integration_IsEnabled (line 30) | func TestCurrencyService_Integration_IsEnabled(t *testing.T) {
  function TestCurrencyService_Integration_ConvertAmount_SameCurrency (line 69) | func TestCurrencyService_Integration_ConvertAmount_SameCurrency(t *testi...
  function TestCurrencyService_Integration_ConvertAmount_WithCachedRate (line 82) | func TestCurrencyService_Integration_ConvertAmount_WithCachedRate(t *tes...
  function TestCurrencyService_Integration_ConvertAmount_NoAPIKey (line 108) | func TestCurrencyService_Integration_ConvertAmount_NoAPIKey(t *testing.T) {
  function TestCurrencyService_Integration_ConvertAmount_InvalidAmount (line 123) | func TestCurrencyService_Integration_ConvertAmount_InvalidAmount(t *test...
  function TestCurrencyService_Integration_SupportedCurrencies (line 158) | func TestCurrencyService_Integration_SupportedCurrencies(t *testing.T) {
  function TestCurrencyService_Integration_BDTCurrency (line 179) | func TestCurrencyService_Integration_BDTCurrency(t *testing.T) {
  function TestSettingsService_GetCurrencySymbol_BDT (line 203) | func TestSettingsService_GetCurrencySymbol_BDT(t *testing.T) {
  function TestSettingsService_SetCurrency_BDT (line 230) | func TestSettingsService_SetCurrency_BDT(t *testing.T) {

FILE: internal/service/currency_test.go
  function TestGetCurrencyInfo_KnownCurrencies (line 13) | func TestGetCurrencyInfo_KnownCurrencies(t *testing.T) {
  function TestGetCurrencyInfo_UnknownCurrency (line 41) | func TestGetCurrencyInfo_UnknownCurrency(t *testing.T) {
  function TestGetCurrencyInfo_EmptyCode (line 48) | func TestGetCurrencyInfo_EmptyCode(t *testing.T) {
  function TestGetAvailableCurrencies (line 55) | func TestGetAvailableCurrencies(t *testing.T) {
  function TestSupportedCurrencies_DerivedFromBuiltin (line 66) | func TestSupportedCurrencies_DerivedFromBuiltin(t *testing.T) {
  function TestCurrencyInfoMap_AllEntriesPresent (line 74) | func TestCurrencyInfoMap_AllEntriesPresent(t *testing.T) {
  function TestCurrencySymbolForCode (line 82) | func TestCurrencySymbolForCode(t *testing.T) {
  function TestCurrencySymbolForSubscription (line 101) | func TestCurrencySymbolForSubscription(t *testing.T) {

FILE: internal/service/email.go
  function currencySymbolForSubscription (line 15) | func currencySymbolForSubscription(subscription *models.Subscription, se...
  type EmailService (line 24) | type EmailService struct
    method SendEmail (line 36) | func (e *EmailService) SendEmail(subject, body string) error {
    method SendHighCostAlert (line 176) | func (e *EmailService) SendHighCostAlert(subscription *models.Subscrip...
    method SendRenewalReminder (line 258) | func (e *EmailService) SendRenewalReminder(subscription *models.Subscr...
    method SendCancellationReminder (line 346) | func (e *EmailService) SendCancellationReminder(subscription *models.S...
  function NewEmailService (line 29) | func NewEmailService(settingsService *SettingsService) *EmailService {

FILE: internal/service/logo.go
  type LogoService (line 13) | type LogoService struct
    method FetchLogoFromURL (line 28) | func (s *LogoService) FetchLogoFromURL(websiteURL string) (string, err...
    method GetLogoURL (line 69) | func (s *LogoService) GetLogoURL(iconURL, websiteURL string) string {
    method ValidateLogoURL (line 90) | func (s *LogoService) ValidateLogoURL(logoURL string) bool {
    method FetchAndValidateLogo (line 106) | func (s *LogoService) FetchAndValidateLogo(websiteURL string) (string,...
    method ExtractDomain (line 124) | func (s *LogoService) ExtractDomain(websiteURL string) string {
    method DownloadLogo (line 157) | func (s *LogoService) DownloadLogo(logoURL string) ([]byte, error) {
  function NewLogoService (line 18) | func NewLogoService() *LogoService {

FILE: internal/service/pushover.go
  type PushoverService (line 15) | type PushoverService struct
    method SendNotification (line 34) | func (p *PushoverService) SendNotification(title, message string, prio...
    method SendHighCostAlert (line 91) | func (p *PushoverService) SendHighCostAlert(subscription *models.Subsc...
    method SendRenewalReminder (line 122) | func (p *PushoverService) SendRenewalReminder(subscription *models.Sub...
    method SendCancellationReminder (line 158) | func (p *PushoverService) SendCancellationReminder(subscription *model...
  function NewPushoverService (line 20) | func NewPushoverService(settingsService *SettingsService) *PushoverServi...
  type PushoverResponse (line 27) | type PushoverResponse struct

FILE: internal/service/pushover_test.go
  function setupPushoverTestDB (line 27) | func setupPushoverTestDB(t *testing.T) *gorm.DB {
  function TestPushoverService_SendNotification_NoConfig (line 45) | func TestPushoverService_SendNotification_NoConfig(t *testing.T) {
  function TestPushoverService_SendNotification_EmptyUserKey (line 58) | func TestPushoverService_SendNotification_EmptyUserKey(t *testing.T) {
  function TestPushoverService_SendNotification_EmptyAppToken (line 76) | func TestPushoverService_SendNotification_EmptyAppToken(t *testing.T) {
  function TestPushoverService_SendHighCostAlert_Disabled (line 94) | func TestPushoverService_SendHighCostAlert_Disabled(t *testing.T) {
  function TestPushoverService_SendHighCostAlert_EnabledButNoConfig (line 116) | func TestPushoverService_SendHighCostAlert_EnabledButNoConfig(t *testing...
  function TestPushoverService_SendRenewalReminder_Disabled (line 139) | func TestPushoverService_SendRenewalReminder_Disabled(t *testing.T) {
  function TestPushoverService_SendRenewalReminder_EnabledButNoConfig (line 162) | func TestPushoverService_SendRenewalReminder_EnabledButNoConfig(t *testi...
  function TestPushoverService_SendHighCostAlert_MessageFormat (line 186) | func TestPushoverService_SendHighCostAlert_MessageFormat(t *testing.T) {
  function TestPushoverService_SendRenewalReminder_MessageFormat (line 219) | func TestPushoverService_SendRenewalReminder_MessageFormat(t *testing.T) {
  function TestPushoverService_SendRenewalReminder_DaysText (line 252) | func TestPushoverService_SendRenewalReminder_DaysText(t *testing.T) {
  function getPushoverTestCredentials (line 306) | func getPushoverTestCredentials() (userKey, appToken string) {
  function TestPushoverService_SendNotification_Integration (line 314) | func TestPushoverService_SendNotification_Integration(t *testing.T) {
  function TestPushoverService_SendHighCostAlert_Integration (line 339) | func TestPushoverService_SendHighCostAlert_Integration(t *testing.T) {
  function TestPushoverService_SendRenewalReminder_Integration (line 374) | func TestPushoverService_SendRenewalReminder_Integration(t *testing.T) {

FILE: internal/service/renewal_reminder_test.go
  function setupRenewalReminderTestDB (line 14) | func setupRenewalReminderTestDB(t *testing.T) *gorm.DB {
  function TestSubscriptionService_GetSubscriptionsNeedingReminders (line 33) | func TestSubscriptionService_GetSubscriptionsNeedingReminders(t *testing...
  function TestEmailService_SendRenewalReminder_Disabled (line 212) | func TestEmailService_SendRenewalReminder_Disabled(t *testing.T) {
  function TestEmailService_SendRenewalReminder_EnabledButNoSMTP (line 234) | func TestEmailService_SendRenewalReminder_EnabledButNoSMTP(t *testing.T) {
  function TestEmailService_SendRenewalReminder_WithSMTPConfig (line 257) | func TestEmailService_SendRenewalReminder_WithSMTPConfig(t *testing.T) {
  function TestSubscriptionService_GetSubscriptionsNeedingReminders_DaysCalculation (line 294) | func TestSubscriptionService_GetSubscriptionsNeedingReminders_DaysCalcul...
  function TestSubscriptionService_GetSubscriptionsNeedingReminders_BoundaryCases (line 328) | func TestSubscriptionService_GetSubscriptionsNeedingReminders_BoundaryCa...
  function TestSubscriptionService_GetSubscriptionsNeedingReminders_DuplicatePrevention (line 401) | func TestSubscriptionService_GetSubscriptionsNeedingReminders_DuplicateP...
  function TestSubscriptionService_GetSubscriptionsNeedingReminders_ReminderDisabled (line 453) | func TestSubscriptionService_GetSubscriptionsNeedingReminders_ReminderDi...
  function timePtr (line 501) | func timePtr(t time.Time) *time.Time {

FILE: internal/service/session.go
  constant SessionName (line 10) | SessionName     = "subtrackr_session"
  constant SessionUserKey (line 11) | SessionUserKey  = "user_authenticated"
  constant SessionMaxAge (line 12) | SessionMaxAge   = 24 * 60 * 60
  constant RememberMeMaxAge (line 13) | RememberMeMaxAge = 30 * 24 * 60 * 60
  type SessionService (line 16) | type SessionService struct
    method CreateSession (line 37) | func (s *SessionService) CreateSession(w http.ResponseWriter, r *http....
    method IsAuthenticated (line 56) | func (s *SessionService) IsAuthenticated(r *http.Request) bool {
    method DestroySession (line 67) | func (s *SessionService) DestroySession(w http.ResponseWriter, r *http...
    method RefreshSession (line 81) | func (s *SessionService) RefreshSession(w http.ResponseWriter, r *http...
    method UpdateSessionExpiry (line 101) | func (s *SessionService) UpdateSessionExpiry(maxAge int) {
    method GetSession (line 106) | func (s *SessionService) GetSession(r *http.Request) (*sessions.Sessio...
  function NewSessionService (line 21) | func NewSessionService(secretKey string) *SessionService {

FILE: internal/service/settings.go
  type SettingsService (line 17) | type SettingsService struct
    method SaveSMTPConfig (line 26) | func (s *SettingsService) SaveSMTPConfig(config *models.SMTPConfig) er...
    method GetSMTPConfig (line 37) | func (s *SettingsService) GetSMTPConfig() (*models.SMTPConfig, error) {
    method SetBoolSetting (line 53) | func (s *SettingsService) SetBoolSetting(key string, value bool) error {
    method GetBoolSetting (line 58) | func (s *SettingsService) GetBoolSetting(key string, defaultValue bool...
    method GetBoolSettingWithDefault (line 68) | func (s *SettingsService) GetBoolSettingWithDefault(key string, defaul...
    method SetIntSetting (line 77) | func (s *SettingsService) SetIntSetting(key string, value int) error {
    method GetIntSetting (line 82) | func (s *SettingsService) GetIntSetting(key string, defaultValue int) ...
    method GetIntSettingWithDefault (line 97) | func (s *SettingsService) GetIntSettingWithDefault(key string, default...
    method SetFloatSetting (line 106) | func (s *SettingsService) SetFloatSetting(key string, value float64) e...
    method GetFloatSetting (line 111) | func (s *SettingsService) GetFloatSetting(key string, defaultValue flo...
    method GetTheme (line 126) | func (s *SettingsService) GetTheme() (string, error) {
    method SetTheme (line 135) | func (s *SettingsService) SetTheme(theme string) error {
    method GetFloatSettingWithDefault (line 140) | func (s *SettingsService) GetFloatSettingWithDefault(key string, defau...
    method CreateAPIKey (line 149) | func (s *SettingsService) CreateAPIKey(name, key string) (*models.APIK...
    method GetAllAPIKeys (line 158) | func (s *SettingsService) GetAllAPIKeys() ([]models.APIKey, error) {
    method DeleteAPIKey (line 163) | func (s *SettingsService) DeleteAPIKey(id uint) error {
    method ValidateAPIKey (line 168) | func (s *SettingsService) ValidateAPIKey(key string) (*models.APIKey, ...
    method SetCurrency (line 184) | func (s *SettingsService) SetCurrency(currency string) error {
    method GetCurrency (line 193) | func (s *SettingsService) GetCurrency() string {
    method GetCurrencySymbol (line 207) | func (s *SettingsService) GetCurrencySymbol() string {
    method SetDateFormat (line 212) | func (s *SettingsService) SetDateFormat(format string) error {
    method GetDateFormat (line 222) | func (s *SettingsService) GetDateFormat() string {
    method GetGoDateFormat (line 231) | func (s *SettingsService) GetGoDateFormat() string {
    method GetGoDateFormatLong (line 236) | func (s *SettingsService) GetGoDateFormatLong() string {
    method SetDarkMode (line 265) | func (s *SettingsService) SetDarkMode(enabled bool) error {
    method IsDarkModeEnabled (line 270) | func (s *SettingsService) IsDarkModeEnabled() bool {
    method IsAuthEnabled (line 277) | func (s *SettingsService) IsAuthEnabled() bool {
    method SetAuthEnabled (line 282) | func (s *SettingsService) SetAuthEnabled(enabled bool) error {
    method GetAuthUsername (line 287) | func (s *SettingsService) GetAuthUsername() (string, error) {
    method SetAuthUsername (line 292) | func (s *SettingsService) SetAuthUsername(username string) error {
    method HashPassword (line 297) | func (s *SettingsService) HashPassword(password string) (string, error) {
    method SetAuthPassword (line 306) | func (s *SettingsService) SetAuthPassword(password string) error {
    method ValidatePassword (line 315) | func (s *SettingsService) ValidatePassword(password string) error {
    method GetOrGenerateSessionSecret (line 324) | func (s *SettingsService) GetOrGenerateSessionSecret() (string, error) {
    method SetupAuth (line 346) | func (s *SettingsService) SetupAuth(username, password string) error {
    method DisableAuth (line 367) | func (s *SettingsService) DisableAuth() error {
    method GenerateResetToken (line 381) | func (s *SettingsService) GenerateResetToken() (string, error) {
    method ValidateResetToken (line 402) | func (s *SettingsService) ValidateResetToken(token string) error {
    method ClearResetToken (line 422) | func (s *SettingsService) ClearResetToken() error {
    method GetBaseURL (line 429) | func (s *SettingsService) GetBaseURL() string {
    method SetBaseURL (line 438) | func (s *SettingsService) SetBaseURL(baseURL string) error {
    method IsICalSubscriptionEnabled (line 445) | func (s *SettingsService) IsICalSubscriptionEnabled() bool {
    method SetICalSubscriptionEnabled (line 450) | func (s *SettingsService) SetICalSubscriptionEnabled(enabled bool) err...
    method GetOrGenerateICalToken (line 455) | func (s *SettingsService) GetOrGenerateICalToken() (string, error) {
    method RegenerateICalToken (line 476) | func (s *SettingsService) RegenerateICalToken() (string, error) {
    method ValidateICalToken (line 491) | func (s *SettingsService) ValidateICalToken(token string) bool {
    method SavePushoverConfig (line 500) | func (s *SettingsService) SavePushoverConfig(config *models.PushoverCo...
    method GetPushoverConfig (line 511) | func (s *SettingsService) GetPushoverConfig() (*models.PushoverConfig,...
    method SaveWebhookConfig (line 527) | func (s *SettingsService) SaveWebhookConfig(config *models.WebhookConf...
    method GetWebhookConfig (line 536) | func (s *SettingsService) GetWebhookConfig() (*models.WebhookConfig, e...
  function NewSettingsService (line 21) | func NewSettingsService(repo *repository.SettingsRepository) *SettingsSe...
  function CurrencySymbolForCode (line 202) | func CurrencySymbolForCode(currency string) string {
  function DateFormatToGo (line 241) | func DateFormatToGo(format string) string {
  function DateFormatToGoLong (line 253) | func DateFormatToGoLong(format string) string {

FILE: internal/service/settings_test.go
  function setupSettingsTestDB (line 13) | func setupSettingsTestDB(t *testing.T) *SettingsService {
  function TestSetDateFormat_Valid (line 26) | func TestSetDateFormat_Valid(t *testing.T) {
  function TestSetDateFormat_Invalid (line 49) | func TestSetDateFormat_Invalid(t *testing.T) {
  function TestGetDateFormat_Default (line 71) | func TestGetDateFormat_Default(t *testing.T) {
  function TestDateFormatToGo (line 78) | func TestDateFormatToGo(t *testing.T) {
  function TestDateFormatToGoLong (line 97) | func TestDateFormatToGoLong(t *testing.T) {
  function TestGetGoDateFormat (line 116) | func TestGetGoDateFormat(t *testing.T) {
  function TestGetGoDateFormatLong (line 131) | func TestGetGoDateFormatLong(t *testing.T) {
  function TestWebhookConfig_SaveAndRetrieve (line 142) | func TestWebhookConfig_SaveAndRetrieve(t *testing.T) {
  function TestWebhookConfig_NotConfigured (line 162) | func TestWebhookConfig_NotConfigured(t *testing.T) {

FILE: internal/service/subscription.go
  type SubscriptionService (line 9) | type SubscriptionService struct
    method Create (line 18) | func (s *SubscriptionService) Create(subscription *models.Subscription...
    method GetAll (line 22) | func (s *SubscriptionService) GetAll() ([]models.Subscription, error) {
    method GetAllSorted (line 26) | func (s *SubscriptionService) GetAllSorted(sortBy, order string) ([]mo...
    method GetByID (line 30) | func (s *SubscriptionService) GetByID(id uint) (*models.Subscription, ...
    method Update (line 34) | func (s *SubscriptionService) Update(id uint, subscription *models.Sub...
    method Delete (line 38) | func (s *SubscriptionService) Delete(id uint) error {
    method Count (line 42) | func (s *SubscriptionService) Count() int64 {
    method GetStats (line 46) | func (s *SubscriptionService) GetStats() (*models.Stats, error) {
    method GetAllCategories (line 94) | func (s *SubscriptionService) GetAllCategories() ([]models.Category, e...
    method GetSubscriptionsNeedingReminders (line 100) | func (s *SubscriptionService) GetSubscriptionsNeedingReminders(reminde...
    method GetSubscriptionsNeedingCancellationReminders (line 146) | func (s *SubscriptionService) GetSubscriptionsNeedingCancellationRemin...
  function NewSubscriptionService (line 14) | func NewSubscriptionService(repo *repository.SubscriptionRepository, cat...

FILE: internal/service/webhook.go
  type WebhookService (line 13) | type WebhookService struct
    method SendWebhook (line 76) | func (w *WebhookService) SendWebhook(payload *WebhookPayload) error {
    method SendHighCostAlert (line 114) | func (w *WebhookService) SendHighCostAlert(subscription *models.Subscr...
    method SendRenewalReminder (line 133) | func (w *WebhookService) SendRenewalReminder(subscription *models.Subs...
    method SendCancellationReminder (line 155) | func (w *WebhookService) SendCancellationReminder(subscription *models...
  function NewWebhookService (line 18) | func NewWebhookService(settingsService *SettingsService) *WebhookService {
  type WebhookPayload (line 25) | type WebhookPayload struct
  type WebhookSubscription (line 34) | type WebhookSubscription struct
  function subscriptionToWebhook (line 48) | func subscriptionToWebhook(sub *models.Subscription, settings *SettingsS...

FILE: internal/service/webhook_test.go
  function setupWebhookTestDB (line 14) | func setupWebhookTestDB(t *testing.T) (*SettingsService, *WebhookService) {
  function TestWebhookService_SendWebhook_NoConfig (line 30) | func TestWebhookService_SendWebhook_NoConfig(t *testing.T) {
  function TestWebhookService_SendWebhook_EmptyURL (line 43) | func TestWebhookService_SendWebhook_EmptyURL(t *testing.T) {
  function TestWebhookService_SendHighCostAlert_Disabled (line 61) | func TestWebhookService_SendHighCostAlert_Disabled(t *testing.T) {
  function TestWebhookService_SendHighCostAlert_EnabledNoConfig (line 77) | func TestWebhookService_SendHighCostAlert_EnabledNoConfig(t *testing.T) {
  function TestWebhookService_SendRenewalReminder_Disabled (line 94) | func TestWebhookService_SendRenewalReminder_Disabled(t *testing.T) {
  function TestWebhookService_SendRenewalReminder_EnabledNoConfig (line 111) | func TestWebhookService_SendRenewalReminder_EnabledNoConfig(t *testing.T) {
  function TestWebhookService_SendCancellationReminder_Disabled (line 129) | func TestWebhookService_SendCancellationReminder_Disabled(t *testing.T) {
  function TestWebhookService_SendCancellationReminder_EnabledNoConfig (line 146) | func TestWebhookService_SendCancellationReminder_EnabledNoConfig(t *test...
  function TestSubscriptionToWebhook (line 164) | func TestSubscriptionToWebhook(t *testing.T) {
  function TestSubscriptionToWebhook_MinimalFields (line 207) | func TestSubscriptionToWebhook_MinimalFields(t *testing.T) {
  function TestWebhookService_SendRenewalReminder_DaysText (line 237) | func TestWebhookService_SendRenewalReminder_DaysText(t *testing.T) {

FILE: internal/version/version.go
  function GetVersion (line 12) | func GetVersion() string {

FILE: web/static/category-management.js
  function startEditCategory (line 1) | function startEditCategory(id) {
  function cancelEditCategory (line 6) | function cancelEditCategory(id) {

FILE: web/static/js/darkmode.js
  class DarkModeManager (line 2) | class DarkModeManager {
    method constructor (line 3) | constructor() {
    method init (line 7) | init() {
    method setDarkMode (line 17) | setDarkMode(enabled, save = true) {
    method toggle (line 31) | toggle() {
    method syncWithServer (line 36) | syncWithServer(enabled) {
    method setupSystemPreferenceListener (line 44) | setupSystemPreferenceListener() {
  function toggleDarkMode (line 59) | function toggleDarkMode() {

FILE: web/static/js/mobile-menu.js
  function openMobileMenu (line 4) | function openMobileMenu() {
  function closeMobileMenu (line 12) | function closeMobileMenu() {
  function closeMobileMenuAndThen (line 22) | function closeMobileMenuAndThen(callback) {

FILE: web/static/js/sorting.js
  constant SORT_STORAGE_KEY (line 4) | const SORT_STORAGE_KEY = 'subtrackr-sort';
  constant VALID_SORT_FIELDS (line 5) | const VALID_SORT_FIELDS = ['name', 'cost', 'renewal_date', 'status', 'ca...
  constant VALID_SORT_ORDERS (line 6) | const VALID_SORT_ORDERS = ['asc', 'desc'];
  function isValidSortPreference (line 9) | function isValidSortPreference(sortBy, order) {
  function saveSortPreference (line 14) | function saveSortPreference(sortBy, order) {
  function getSortPreference (line 21) | function getSortPreference() {
  function extractSortParams (line 35) | function extractSortParams(url) {
  function applySavedSortPreference (line 50) | function applySavedSortPreference() {

FILE: web/static/js/themes.js
  function applyTheme (line 121) | function applyTheme(themeName) {
  function saveThemePreference (line 159) | function saveThemePreference(themeName) {
  function getStoredTheme (line 171) | function getStoredTheme() {
  function loadSavedTheme (line 193) | function loadSavedTheme() {
  function enableSnowfall (line 200) | function enableSnowfall() {
  function createSnowflake (line 225) | function createSnowflake(container) {
  function disableSnowfall (line 250) | function disableSnowfall() {
Condensed preview — 95 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (775K chars).
[
  {
    "path": ".beads/interactions.jsonl",
    "chars": 0,
    "preview": ""
  },
  {
    "path": ".beads/issues.jsonl",
    "chars": 8323,
    "preview": "{\"id\":\"subtrackr-xyz-1fb\",\"title\":\"Implement cancellation date email notifications (#88)\",\"description\":\"Add email notif"
  },
  {
    "path": ".claude/commands/release.md",
    "chars": 1653,
    "preview": "# Release $ARGUMENTS\n\nExecute the SubTrackr release workflow for version $ARGUMENTS.\n\n## Pre-flight\n\n1. Verify you're on"
  },
  {
    "path": ".dockerignore",
    "chars": 677,
    "preview": "# Git files\n.git/\n.gitignore\n.github/\n\n# Documentation\n*.md\ndocs/\nLICENSE\nscreenshots/\n\n# Development files\ndocker-compo"
  },
  {
    "path": ".gitattributes",
    "chars": 70,
    "preview": "\n# Use bd merge for beads JSONL files\n.beads/issues.jsonl merge=beads\n"
  },
  {
    "path": ".github/workflows/claude-code-review.yml",
    "chars": 1433,
    "preview": "name: Claude Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize, ready_for_review, reopened]\n    # Optiona"
  },
  {
    "path": ".github/workflows/claude.yml",
    "chars": 1886,
    "preview": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issue"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "chars": 2133,
    "preview": "name: Build and Publish Docker Image\n\non:\n  push:\n    tags: [ 'v*' ]\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IM"
  },
  {
    "path": ".github/workflows/test-build.yml",
    "chars": 2042,
    "preview": "name: Test Build\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - '**.go'\n      - 'go.mod'\n      - 'go.sum"
  },
  {
    "path": ".gitignore",
    "chars": 1025,
    "preview": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\nsubtrackr\nmain\n\n# Beads (keep issues.jsonl and inter"
  },
  {
    "path": "AGENTS.md",
    "chars": 11890,
    "preview": "# SubTrackr - Agent Documentation\n\n## Project Overview\n\nSubTrackr is a self-hosted subscription management application b"
  },
  {
    "path": "CLAUDE.md",
    "chars": 2708,
    "preview": "# SubTrackr - Claude Code Instructions\n\n## Release Workflow\n\nThis project uses versioned branches for releases. Follow t"
  },
  {
    "path": "Dockerfile",
    "chars": 1911,
    "preview": "# Build stage\nFROM golang:1.24 AS builder\n\n# Install build dependencies\nRUN apt-get update && apt-get install -y \\\n    g"
  },
  {
    "path": "LICENSE",
    "chars": 34523,
    "preview": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C)"
  },
  {
    "path": "MIGRATION_v0.3.0.md",
    "chars": 3260,
    "preview": "# Migration Guide for SubTrackr v0.3.0\n\n## Overview\n\nSubTrackr v0.3.0 introduces a new dynamic categories system that re"
  },
  {
    "path": "Makefile",
    "chars": 1866,
    "preview": "# Variables\nGIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo \"unknown\")\nGIT_TAG := $(shell git descr"
  },
  {
    "path": "PLAN-login-settings.md",
    "chars": 17832,
    "preview": "# Plan: Optional Login Support in Settings\n\n## Overview\n\nAdd optional authentication to SubTrackr that can be enabled/di"
  },
  {
    "path": "README.md",
    "chars": 15568,
    "preview": "# SubTrackr\n\nA self-hosted subscription management application built with Go and HTMX. Track your subscriptions, visuali"
  },
  {
    "path": "cmd/mcp/main.go",
    "chars": 8771,
    "preview": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"strconv\"\n\t\"subtrackr/internal/config\"\n\t\"subtrackr/int"
  },
  {
    "path": "cmd/migrate-dates/main.go",
    "chars": 4644,
    "preview": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\t\"subtrackr/internal/database\"\n\t\"subtrackr/internal/models"
  },
  {
    "path": "docker-compose.yml",
    "chars": 594,
    "preview": "version: '3.8'\n\nservices:\n  subtrackr:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    ports:\n      - \"8080"
  },
  {
    "path": "go.mod",
    "chars": 1978,
    "preview": "module subtrackr\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/dromara/carbon/v2 v2.6.11\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgithub.co"
  },
  {
    "path": "go.sum",
    "chars": 10290,
    "preview": "github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=\ngithub.com/bytedance/sonic v1.9"
  },
  {
    "path": "internal/config/config.go",
    "chars": 445,
    "preview": "package config\n\nimport (\n\t\"os\"\n)\n\ntype Config struct {\n\tDatabasePath string\n\tPort         string\n\tEnvironment  string\n}\n"
  },
  {
    "path": "internal/database/database.go",
    "chars": 496,
    "preview": "package database\n\nimport (\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc Initialize(dbPath str"
  },
  {
    "path": "internal/database/migrations.go",
    "chars": 10016,
    "preview": "package database\n\nimport (\n\t\"log\"\n\t\"subtrackr/internal/models\"\n\n\t\"gorm.io/gorm\"\n)\n\n// RunMigrations executes all databas"
  },
  {
    "path": "internal/handlers/auth.go",
    "chars": 6790,
    "preview": "package handlers\n\nimport (\n\t\"crypto/subtle\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"subtrackr/internal/service\"\n\n\t\"gi"
  },
  {
    "path": "internal/handlers/category.go",
    "chars": 1974,
    "preview": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/service\"\n\n\t\"github.c"
  },
  {
    "path": "internal/handlers/settings.go",
    "chars": 24239,
    "preview": "package handlers\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/tls\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/smtp\"\n\t\"strconv\""
  },
  {
    "path": "internal/handlers/subscription.go",
    "chars": 36097,
    "preview": "package handlers\n\nimport (\n\t\"encoding/csv\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"log\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"subt"
  },
  {
    "path": "internal/handlers/subscription_test.go",
    "chars": 2412,
    "preview": "package handlers\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParseDatePtr(t *testing"
  },
  {
    "path": "internal/handlers/url.go",
    "chars": 737,
    "preview": "package handlers\n\nimport (\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// buildBaseURL returns the external base URL for "
  },
  {
    "path": "internal/middleware/auth.go",
    "chars": 2555,
    "preview": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"subtrackr/internal/service\"\n\n\t\"github.com/gin-gonic/gin"
  },
  {
    "path": "internal/models/category.go",
    "chars": 343,
    "preview": "package models\n\nimport \"time\"\n\n// Category represents a subscription category\ntype Category struct {\n\tID        uint    "
  },
  {
    "path": "internal/models/date_migration_audit.go",
    "chars": 6114,
    "preview": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\n// DateMigrationLog tracks changes made during date calculation mig"
  },
  {
    "path": "internal/models/date_migration_audit_test.go",
    "chars": 2973,
    "preview": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/go"
  },
  {
    "path": "internal/models/exchange_rate.go",
    "chars": 697,
    "preview": "package models\n\nimport (\n\t\"time\"\n)\n\n// ExchangeRate represents currency exchange rate data\ntype ExchangeRate struct {\n\tI"
  },
  {
    "path": "internal/models/exchange_rate_test.go",
    "chars": 1681,
    "preview": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestExchangeRate_IsStale(t *t"
  },
  {
    "path": "internal/models/settings.go",
    "chars": 2240,
    "preview": "package models\n\nimport (\n\t\"time\"\n)\n\n// Settings represents application settings\ntype Settings struct {\n\tID        uint  "
  },
  {
    "path": "internal/models/subscription.go",
    "chars": 15667,
    "preview": "package models\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/dromara/carbon/v2\"\n\t\"gorm.io/gorm\"\n)\n\ntype Subscription struct {\n\t"
  },
  {
    "path": "internal/models/subscription_test.go",
    "chars": 33266,
    "preview": "package models\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t"
  },
  {
    "path": "internal/repository/category.go",
    "chars": 1638,
    "preview": "package repository\n\nimport (\n\t\"subtrackr/internal/models\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype CategoryRepository struct {\n\tdb *gorm"
  },
  {
    "path": "internal/repository/exchange_rate.go",
    "chars": 1977,
    "preview": "package repository\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype ExchangeRateRepository struct"
  },
  {
    "path": "internal/repository/settings.go",
    "chars": 2519,
    "preview": "package repository\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype SettingsRepository struct {\n\t"
  },
  {
    "path": "internal/repository/subscription.go",
    "chars": 10473,
    "preview": "package repository\n\nimport (\n\t\"strings\"\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype SubscriptionReposi"
  },
  {
    "path": "internal/service/category.go",
    "chars": 1218,
    "preview": "package service\n\nimport (\n\t\"errors\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n)\n\n// CategoryService "
  },
  {
    "path": "internal/service/currency.go",
    "chars": 8866,
    "preview": "package service\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"subtrac"
  },
  {
    "path": "internal/service/currency_integration_test.go",
    "chars": 7462,
    "preview": "package service\n\nimport (\n\t\"os\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"gith"
  },
  {
    "path": "internal/service/currency_test.go",
    "chars": 3978,
    "preview": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\n\t\"github.com/stretch"
  },
  {
    "path": "internal/service/email.go",
    "chars": 15518,
    "preview": "package service\n\nimport (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/smtp\"\n\t\"subtrackr/internal/models\"\n)\n\n// "
  },
  {
    "path": "internal/service/logo.go",
    "chars": 4630,
    "preview": "package service\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\n// LogoService handles fetching logo"
  },
  {
    "path": "internal/service/pushover.go",
    "chars": 6860,
    "preview": "package service\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"subtrackr/internal/models"
  },
  {
    "path": "internal/service/pushover_test.go",
    "chars": 14823,
    "preview": "package service\n\nimport (\n\t\"os\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"gith"
  },
  {
    "path": "internal/service/renewal_reminder_test.go",
    "chars": 16369,
    "preview": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com"
  },
  {
    "path": "internal/service/session.go",
    "chars": 2684,
    "preview": "package service\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gorilla/sessions\"\n)\n\nconst (\n\tSessionName     = \"subtrackr_session\"\n"
  },
  {
    "path": "internal/service/settings.go",
    "chars": 14633,
    "preview": "package service\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/subtle\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"subtra"
  },
  {
    "path": "internal/service/settings_test.go",
    "chars": 3851,
    "preview": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\n\t\"github.com/stretch"
  },
  {
    "path": "internal/service/subscription.go",
    "chars": 5389,
    "preview": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"time\"\n)\n\ntype SubscriptionServ"
  },
  {
    "path": "internal/service/webhook.go",
    "chars": 5813,
    "preview": "package service\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"subtrackr/internal/models\"\n\t\"time\"\n)\n\n// Webhoo"
  },
  {
    "path": "internal/service/webhook_test.go",
    "chars": 7394,
    "preview": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com"
  },
  {
    "path": "internal/version/version.go",
    "chars": 481,
    "preview": "package version\n\nvar (\n\t// GitCommit is the git commit SHA that will be set at build time\n\tGitCommit = \"unknown\"\n\t// Ver"
  },
  {
    "path": "package.json",
    "chars": 243,
    "preview": "{\n  \"dependencies\": {\n    \"@playwright/test\": \"^1.54.2\"\n  },\n  \"scripts\": {\n    \"test\": \"playwright test\",\n    \"test:hea"
  },
  {
    "path": "playwright.config.js",
    "chars": 1965,
    "preview": "// @ts-check\nconst { defineConfig, devices } = require('@playwright/test');\n\n/**\n * @see https://playwright.dev/docs/tes"
  },
  {
    "path": "templates/analytics.html",
    "chars": 20896,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/api-keys-list.html",
    "chars": 2638,
    "preview": "{{if .Keys}}\n    {{range .Keys}}\n    <div class=\"flex items-center justify-between p-3 bg-white border border-gray-200 r"
  },
  {
    "path": "templates/auth-message.html",
    "chars": 1309,
    "preview": "{{if .Error}}\n<div class=\"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 transiti"
  },
  {
    "path": "templates/calendar.html",
    "chars": 27218,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/categories-list.html",
    "chars": 1472,
    "preview": "{{if .}}\n    {{range .}}\n        <div class=\"flex items-center justify-between p-3 bg-white border border-gray-200 round"
  },
  {
    "path": "templates/dashboard.html",
    "chars": 24376,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/error.html",
    "chars": 898,
    "preview": "{{define \"content\"}}\n<div class=\"flex items-center justify-center min-h-[400px]\">\n    <div class=\"text-center\">\n        "
  },
  {
    "path": "templates/forgot-password-error.html",
    "chars": 246,
    "preview": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-3 mb-4\">\n    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n</d"
  },
  {
    "path": "templates/forgot-password-success.html",
    "chars": 352,
    "preview": "<div class=\"bg-green-50 border border-green-200 rounded-lg p-4 mb-4\">\n    <p class=\"text-sm text-green-700\">{{.Message}}"
  },
  {
    "path": "templates/forgot-password.html",
    "chars": 1942,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/form-errors.html",
    "chars": 656,
    "preview": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-4 mb-4\">\n    <div class=\"flex\">\n        <svg class=\"w-5 h-5 tex"
  },
  {
    "path": "templates/login-error.html",
    "chars": 119,
    "preview": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-3\">\n    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n</div>\n"
  },
  {
    "path": "templates/login.html",
    "chars": 3153,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/reset-password-error.html",
    "chars": 267,
    "preview": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-3 mb-4\">\n    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n</d"
  },
  {
    "path": "templates/reset-password-success.html",
    "chars": 311,
    "preview": "<div class=\"bg-green-50 border border-green-200 rounded-lg p-4 mb-4\">\n    <p class=\"text-sm text-green-700\">{{.Message}}"
  },
  {
    "path": "templates/reset-password.html",
    "chars": 3296,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/settings.html",
    "chars": 101225,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "templates/smtp-message.html",
    "chars": 1308,
    "preview": "{{if .Error}}\n<div class=\"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 transiti"
  },
  {
    "path": "templates/subscription-form.html",
    "chars": 23961,
    "preview": "<div class=\"p-6\">\n    <div class=\"flex items-center justify-between mb-6\">\n        <h3 class=\"text-lg font-semibold text"
  },
  {
    "path": "templates/subscription-list.html",
    "chars": 17427,
    "preview": "<div id=\"subscription-list\" class=\"overflow-x-auto\">\n    {{if .Subscriptions}}\n    <table class=\"min-w-full divide-y div"
  },
  {
    "path": "templates/subscriptions.html",
    "chars": 33820,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n"
  },
  {
    "path": "test-api.sh",
    "chars": 1709,
    "preview": "#!/bin/bash\n\n# SubTrackr API Test Script\n# This script demonstrates how to use the SubTrackr API with authentication\n\nAP"
  },
  {
    "path": "tests/example.spec.js",
    "chars": 555,
    "preview": "// @ts-check\nconst { test, expect } = require('@playwright/test');\n\ntest('has title', async ({ page }) => {\n  await page"
  },
  {
    "path": "tests/subscription-crud.spec.js",
    "chars": 3823,
    "preview": "// @ts-check\nconst { test, expect } = require('@playwright/test');\n\ntest.describe('Subscription CRUD Operations', () => "
  },
  {
    "path": "web/static/category-management.js",
    "chars": 536,
    "preview": "function startEditCategory(id) {\n    document.getElementById(`edit-category-form-${id}`).classList.remove('hidden');\n   "
  },
  {
    "path": "web/static/css/themes.css",
    "chars": 9318,
    "preview": "/* SubTrackr Theme System Styles */\n\n:root {\n    /* Default theme colors (will be overridden by theme selection) */\n    "
  },
  {
    "path": "web/static/js/darkmode.js",
    "chars": 2275,
    "preview": "// Enhanced Dark Mode Management for SubTrackr\nclass DarkModeManager {\n    constructor() {\n        this.init();\n    }\n  "
  },
  {
    "path": "web/static/js/mobile-menu.js",
    "chars": 2340,
    "preview": "// Mobile menu functions for responsive navigation\n// Used across all page templates to provide consistent mobile menu b"
  },
  {
    "path": "web/static/js/sorting.js",
    "chars": 3151,
    "preview": "// SubTrackr Sort Preference Persistence\n// Saves and restores user's sort preference using localStorage\n\nconst SORT_STO"
  },
  {
    "path": "web/static/js/theme-init.js",
    "chars": 376,
    "preview": "// Theme initialization - runs immediately to prevent flash\n(function() {\n    const theme = localStorage.getItem('subtra"
  },
  {
    "path": "web/static/js/themes.js",
    "chars": 7603,
    "preview": "// SubTrackr Theme System\nconst themes = {\n    default: {\n        name: 'Default',\n        description: 'Clean and profe"
  },
  {
    "path": "web/static/manifest.json",
    "chars": 502,
    "preview": "{\n  \"name\": \"SubTrackr\",\n  \"short_name\": \"SubTrackr\",\n  \"description\": \"Track and manage your subscriptions\",\n  \"start_u"
  }
]

About this extraction

This page contains the full source code of the bscott/subtrackr GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 95 files (704.8 KB), approximately 188.7k tokens, and a symbol index with 432 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!