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 ` 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 --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 ## 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 --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:` 5. **Complete**: `bd close --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. ================================================ 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 --status=in_progress # Close when complete bd close --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 # 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 --body "Fixed in PR #XX. Description of fix." ``` ### 8. Monitor CI and Merge ```bash # Watch GitHub Actions gh run watch --exit-status # Merge when CI passes gh pr merge --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. 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. Copyright (C) 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 . 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 . ================================================ 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 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:
Christmas Theme
Christmas 🎄
Festive and jolly! (with snowfall animation)
Ocean Theme
Ocean
Cool and refreshing
Login Page
Optional Authentication
Secure your data with optional login support
**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(`

Password Reset Request

You have requested to reset your SubTrackr password.

Click the link below to reset your password:

Reset Password

This link will expire in 1 hour.

If you did not request this reset, please ignore this email.

`, 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 } } c.HTML(http.StatusOK, "settings.html", gin.H{ "Title": "Settings", "CurrentPage": "settings", "Currency": h.settingsService.GetCurrency(), "CurrencySymbol": h.settingsService.GetCurrencySymbol(), "RenewalReminders": h.settingsService.GetBoolSettingWithDefault("renewal_reminders", false), "HighCostAlerts": h.settingsService.GetBoolSettingWithDefault("high_cost_alerts", true), "PushoverConfig": pushoverConfig, "PushoverConfigured": pushoverConfigured, "HighCostThreshold": h.settingsService.GetFloatSettingWithDefault("high_cost_threshold", 50.0), "ReminderDays": h.settingsService.GetIntSettingWithDefault("reminder_days", 7), "CancellationReminders": h.settingsService.GetBoolSettingWithDefault("cancellation_reminders", false), "CancellationReminderDays": h.settingsService.GetIntSettingWithDefault("cancellation_reminder_days", 7), "DarkMode": h.settingsService.IsDarkModeEnabled(), "Version": version.GetVersion(), "SMTPConfig": smtpConfig, "SMTPConfigured": smtpConfigured, "AuthEnabled": authEnabled, "AuthUsername": authUsername, "ICalSubscriptionEnabled": icalSubscriptionEnabled, "ICalSubscriptionURL": icalSubscriptionURL, "BaseURL": h.settingsService.GetBaseURL(), "Currencies": service.GetAvailableCurrencies(), "DateFormat": h.settingsService.GetDateFormat(), "WebhookConfig": webhookConfig, "WebhookConfigured": webhookConfigured, }) } // API endpoints for HTMX // GetSubscriptions returns subscriptions as HTML fragments func (h *SubscriptionHandler) GetSubscriptions(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.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Enrich with currency conversion enrichedSubs := h.enrichWithCurrencyConversion(subscriptions) c.HTML(http.StatusOK, "subscription-list.html", gin.H{ "Subscriptions": enrichedSubs, "CurrencySymbol": h.settingsService.GetCurrencySymbol(), "SortBy": sortBy, "Order": order, "GoDateFormat": h.settingsService.GetGoDateFormat(), }) } // GetSubscriptionsAPI returns subscriptions as JSON for API calls func (h *SubscriptionHandler) GetSubscriptionsAPI(c *gin.Context) { subscriptions, err := h.service.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, subscriptions) } // CreateSubscription handles creating a new subscription func (h *SubscriptionHandler) CreateSubscription(c *gin.Context) { var subscription models.Subscription // Parse form data subscription.Name = c.PostForm("name") // Parse category_id as uint if categoryIDStr := c.PostForm("category_id"); categoryIDStr != "" { if categoryID, err := strconv.ParseUint(categoryIDStr, 10, 32); err == nil { subscription.CategoryID = uint(categoryID) } } subscription.Schedule = c.PostForm("schedule") subscription.ScheduleInterval = parseScheduleInterval(c.PostForm("schedule_interval")) subscription.Status = c.PostForm("status") subscription.OriginalCurrency = c.PostForm("original_currency") if subscription.OriginalCurrency == "" { subscription.OriginalCurrency = "USD" } subscription.PaymentMethod = c.PostForm("payment_method") subscription.Account = c.PostForm("account") subscription.URL = c.PostForm("url") subscription.IconURL = c.PostForm("icon_url") subscription.Notes = c.PostForm("notes") subscription.Usage = c.PostForm("usage") // Default reminders to enabled unless explicitly set to false reminderVal := c.PostForm("reminder_enabled") if reminderVal == "" { subscription.ReminderEnabled = true } else { subscription.ReminderEnabled = reminderVal == "true" } // Parse cost if costStr := c.PostForm("cost"); costStr != "" { if cost, err := strconv.ParseFloat(costStr, 64); err == nil { subscription.Cost = cost } } // Parse dates using helper function subscription.StartDate = parseDatePtr(c.PostForm("start_date")) subscription.RenewalDate = parseDatePtr(c.PostForm("renewal_date")) subscription.CancellationDate = parseDatePtr(c.PostForm("cancellation_date")) // Fetch logo synchronously before creation if URL is provided and icon_url is empty h.fetchAndSetLogo(&subscription) // Create subscription created, err := h.service.Create(&subscription) if err != nil { // Log the error for debugging log.Printf("Failed to create subscription: %v", err) log.Printf("Subscription data: Name=%s, CategoryID=%d, Status=%s, Schedule=%s", subscription.Name, subscription.CategoryID, subscription.Status, subscription.Schedule) if c.GetHeader("HX-Request") != "" { c.Header("HX-Retarget", "#form-errors") c.HTML(http.StatusBadRequest, "form-errors.html", gin.H{ "Error": err.Error(), }) } else { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } return } // Send high-cost alert email and Pushover notification if applicable if h.isHighCostWithCurrency(created) { // Reload subscription with category for email template subscriptionWithCategory, err := h.service.GetByID(created.ID) if err == nil && subscriptionWithCategory != nil { // Send email notification if err := h.emailService.SendHighCostAlert(subscriptionWithCategory); err != nil { // Log error but don't fail the request log.Printf("Failed to send high-cost alert email: %v", err) } // Send Pushover notification if err := h.pushoverService.SendHighCostAlert(subscriptionWithCategory); err != nil { // Log error but don't fail the request log.Printf("Failed to send high-cost alert Pushover notification: %v", err) } // Send Webhook notification if err := h.webhookService.SendHighCostAlert(subscriptionWithCategory); err != nil { log.Printf("Failed to send high-cost alert webhook: %v", err) } } } if c.GetHeader("HX-Request") != "" { c.Header("HX-Refresh", "true") c.Status(http.StatusCreated) } else { c.JSON(http.StatusCreated, created) } } // GetSubscription returns a single subscription func (h *SubscriptionHandler) GetSubscription(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 } subscription, err := h.service.GetByID(uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}) return } c.JSON(http.StatusOK, subscription) } // UpdateSubscription handles updating an existing subscription func (h *SubscriptionHandler) UpdateSubscription(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 } // Fetch existing subscription first — only overwrite fields actually sent in the request existing, err := h.service.GetByID(uint(id)) if err != nil || existing == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}) return } wasHighCost := h.isHighCostWithCurrency(existing) // Merge form data: only update fields that were actually submitted if val, ok := c.GetPostForm("name"); ok { existing.Name = val } if val, ok := c.GetPostForm("category_id"); ok && val != "" { if categoryID, err := strconv.ParseUint(val, 10, 32); err == nil { existing.CategoryID = uint(categoryID) } } if val, ok := c.GetPostForm("schedule"); ok { existing.Schedule = val } if val, ok := c.GetPostForm("schedule_interval"); ok { existing.ScheduleInterval = parseScheduleInterval(val) } if val, ok := c.GetPostForm("status"); ok { existing.Status = val } if val, ok := c.GetPostForm("original_currency"); ok { if val == "" { existing.OriginalCurrency = "USD" } else { existing.OriginalCurrency = val } } if val, ok := c.GetPostForm("payment_method"); ok { existing.PaymentMethod = val } if val, ok := c.GetPostForm("account"); ok { existing.Account = val } // Track URL changes for logo refresh oldURL := existing.URL if val, ok := c.GetPostForm("url"); ok { existing.URL = val } urlChanged := existing.URL != oldURL if val, ok := c.GetPostForm("icon_url"); ok && val != "" { existing.IconURL = val } else if urlChanged { // URL changed but no explicit icon — re-fetch existing.IconURL = "" } if val, ok := c.GetPostForm("notes"); ok { existing.Notes = val } if val, ok := c.GetPostForm("usage"); ok { existing.Usage = val } if val, ok := c.GetPostForm("reminder_enabled"); ok { existing.ReminderEnabled = val == "true" } if val, ok := c.GetPostForm("cost"); ok && val != "" { if cost, err := strconv.ParseFloat(val, 64); err == nil { existing.Cost = cost } } // Parse dates — only update if the field was submitted if val, ok := c.GetPostForm("start_date"); ok { existing.StartDate = parseDatePtr(val) } if val, ok := c.GetPostForm("renewal_date"); ok { existing.RenewalDate = parseDatePtr(val) } if val, ok := c.GetPostForm("cancellation_date"); ok { existing.CancellationDate = parseDatePtr(val) } // Fetch new logo if URL changed or URL is set but no icon if urlChanged || (existing.URL != "" && existing.IconURL == "") { h.fetchAndSetLogo(existing) } // Update subscription updated, err := h.service.Update(uint(id), existing) if err != nil { c.Header("HX-Retarget", "#form-errors") c.HTML(http.StatusBadRequest, "form-errors.html", gin.H{ "Error": err.Error(), }) return } // Send high-cost alert email and Pushover notification if subscription became high-cost (wasn't before, but is now) if updated != nil && !wasHighCost && h.isHighCostWithCurrency(updated) { // Reload subscription with category for email template subscriptionWithCategory, err := h.service.GetByID(updated.ID) if err == nil && subscriptionWithCategory != nil { // Send email notification if err := h.emailService.SendHighCostAlert(subscriptionWithCategory); err != nil { // Log error but don't fail the request log.Printf("Failed to send high-cost alert email: %v", err) } // Send Pushover notification if err := h.pushoverService.SendHighCostAlert(subscriptionWithCategory); err != nil { // Log error but don't fail the request log.Printf("Failed to send high-cost alert Pushover notification: %v", err) } // Send Webhook notification if err := h.webhookService.SendHighCostAlert(subscriptionWithCategory); err != nil { log.Printf("Failed to send high-cost alert webhook: %v", err) } } } // Return success response that triggers a page refresh c.Header("HX-Refresh", "true") c.Status(http.StatusOK) } // DeleteSubscription handles deleting a subscription func (h *SubscriptionHandler) DeleteSubscription(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 } err = h.service.Delete(uint(id)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Return success response that triggers a page refresh c.Header("HX-Refresh", "true") c.Status(http.StatusOK) } // GetStats returns current statistics func (h *SubscriptionHandler) GetStats(c *gin.Context) { stats, err := h.service.GetStats() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, stats) } // GetSubscriptionForm returns the subscription form (for add/edit) func (h *SubscriptionHandler) GetSubscriptionForm(c *gin.Context) { var subscription *models.Subscription isEdit := false // Check if this is an edit form if idStr := c.Param("id"); idStr != "" { id, err := strconv.ParseUint(idStr, 10, 32) if err == nil { sub, err := h.service.GetByID(uint(id)) if err == nil { subscription = sub isEdit = true } } } categories, err := h.service.GetAllCategories() if err != nil { categories = []models.Category{} } c.HTML(http.StatusOK, "subscription-form.html", gin.H{ "Subscription": subscription, "IsEdit": isEdit, "CurrencySymbol": h.settingsService.GetCurrencySymbol(), "Categories": categories, "Currencies": service.GetAvailableCurrencies(), }) } // ExportCSV exports all subscriptions as CSV func (h *SubscriptionHandler) ExportCSV(c *gin.Context) { subscriptions, err := h.service.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.Header("Content-Type", "text/csv") c.Header("Content-Disposition", "attachment; filename=subscriptions.csv") writer := csv.NewWriter(c.Writer) defer writer.Flush() // Write CSV header header := []string{"ID", "Name", "Category", "Cost", "Currency", "Schedule", "Schedule Interval", "Status", "Payment Method", "Account", "Start Date", "Renewal Date", "Cancellation Date", "URL", "Notes", "Usage", "Created At"} writer.Write(header) // Write subscription data for _, sub := range subscriptions { categoryName := "" if sub.Category.Name != "" { categoryName = sub.Category.Name } currency := sub.OriginalCurrency if currency == "" { currency = h.settingsService.GetCurrency() } record := []string{ fmt.Sprintf("%d", sub.ID), sub.Name, categoryName, fmt.Sprintf("%.2f", sub.Cost), currency, sub.DisplaySchedule(), fmt.Sprintf("%d", sub.ScheduleInterval), sub.Status, sub.PaymentMethod, sub.Account, formatDate(sub.StartDate), formatDate(sub.RenewalDate), formatDate(sub.CancellationDate), sub.URL, sub.Notes, sub.Usage, sub.CreatedAt.Format("2006-01-02 15:04:05"), } writer.Write(record) } } // ExportJSON exports all subscriptions as JSON func (h *SubscriptionHandler) ExportJSON(c *gin.Context) { subscriptions, err := h.service.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.Header("Content-Type", "application/json") c.Header("Content-Disposition", "attachment; filename=subscriptions.json") c.JSON(http.StatusOK, gin.H{ "subscriptions": subscriptions, "exported_at": time.Now(), "total_count": len(subscriptions), }) } // BackupData creates a complete backup of all data func (h *SubscriptionHandler) BackupData(c *gin.Context) { subscriptions, err := h.service.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } stats, err := h.service.GetStats() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } backup := gin.H{ "version": "1.0", "backup_date": time.Now(), "subscriptions": subscriptions, "stats": stats, "total_count": len(subscriptions), } c.Header("Content-Type", "application/json") c.Header("Content-Disposition", "attachment; filename=subtrackr-backup.json") c.JSON(http.StatusOK, backup) } // RestoreData imports subscriptions from a backup JSON file func (h *SubscriptionHandler) RestoreData(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10<<20) // 10 MB limit file, _, err := c.Request.FormFile("backup_file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No backup file provided or file too large (max 10 MB)"}) return } defer file.Close() var backup struct { Version string `json:"version"` Subscriptions []models.Subscription `json:"subscriptions"` } decoder := json.NewDecoder(file) if err := decoder.Decode(&backup); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup file format"}) return } if len(backup.Subscriptions) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Backup file contains no subscriptions"}) return } mode := c.PostForm("mode") if mode == "" { mode = "replace" } if mode != "replace" && mode != "merge" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid mode, must be 'replace' or 'merge'"}) return } if mode == "replace" { existing, err := h.service.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch existing data"}) return } for _, sub := range existing { if err := h.service.Delete(sub.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to clear existing data: %v", err)}) return } } } categoryMap := make(map[string]uint) categories, _ := h.categoryService.GetAll() for _, cat := range categories { categoryMap[cat.Name] = cat.ID } imported := 0 var errors []string for _, sub := range backup.Subscriptions { if sub.Category.Name != "" { if catID, ok := categoryMap[sub.Category.Name]; ok { sub.CategoryID = catID } else { newCat := &models.Category{Name: sub.Category.Name} created, err := h.categoryService.Create(newCat) if err == nil { categoryMap[created.Name] = created.ID sub.CategoryID = created.ID } } } sub.ID = 0 sub.Category = models.Category{} sub.CreatedAt = time.Time{} sub.UpdatedAt = time.Time{} _, err := h.service.Create(&sub) if err != nil { errors = append(errors, fmt.Sprintf("Failed to import '%s': %v", sub.Name, err)) continue } imported++ } result := gin.H{ "message": fmt.Sprintf("Successfully imported %d subscriptions", imported), "imported_count": imported, "total_in_file": len(backup.Subscriptions), "mode": mode, } if len(errors) > 0 { result["errors"] = errors result["partial_success"] = true c.JSON(http.StatusMultiStatus, result) return } c.JSON(http.StatusOK, result) } // ClearAllData removes all subscription data func (h *SubscriptionHandler) ClearAllData(c *gin.Context) { subscriptions, err := h.service.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Delete all subscriptions for _, sub := range subscriptions { err := h.service.Delete(sub.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete subscription %d: %v", sub.ID, err)}) return } } c.JSON(http.StatusOK, gin.H{ "message": "All subscription data has been cleared", "deleted_count": len(subscriptions), }) } // Helper function to format currency func formatCurrency(amount float64) string { return fmt.Sprintf("$%.2f", amount) } // Helper function to format date pointers func formatDate(date *time.Time) string { if date == nil { return "" } return date.Format("2006-01-02") } ================================================ FILE: internal/handlers/subscription_test.go ================================================ package handlers import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestParseDatePtr(t *testing.T) { tests := []struct { name string input string expected *time.Time valid bool }{ { name: "Valid date string", input: "2024-01-15", expected: timePtr(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)), valid: true, }, { name: "Valid date with leap year", input: "2024-02-29", expected: timePtr(time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)), valid: true, }, { name: "Valid date at year boundary", input: "2024-12-31", expected: timePtr(time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)), valid: true, }, { name: "Empty string", input: "", expected: nil, valid: true, }, { name: "Invalid date format - wrong separator", input: "2024/01/15", expected: nil, valid: false, }, { name: "Invalid date format - wrong order", input: "15-01-2024", expected: nil, valid: false, }, { name: "Invalid date - invalid month", input: "2024-13-15", expected: nil, valid: false, }, { name: "Invalid date - invalid day", input: "2024-02-30", expected: nil, valid: false, }, { name: "Invalid date - non-leap year Feb 29", input: "2025-02-29", expected: nil, valid: false, }, { name: "Invalid date - text", input: "not-a-date", expected: nil, valid: false, }, { name: "Invalid date - partial", input: "2024-01", expected: nil, valid: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := parseDatePtr(tt.input) if tt.expected == nil { assert.Nil(t, result, "Expected nil for invalid/empty input") } else { assert.NotNil(t, result, "Expected non-nil result for valid input") if result != nil { // Compare date components only (Year, Month, Day) as parseDatePtr returns UTC dates with zero time components assert.Equal(t, tt.expected.Year(), result.Year(), "Year should match") assert.Equal(t, tt.expected.Month(), result.Month(), "Month should match") assert.Equal(t, tt.expected.Day(), result.Day(), "Day should match") } } }) } } // Helper function to create time pointer func timePtr(t time.Time) *time.Time { return &t } ================================================ FILE: internal/handlers/url.go ================================================ package handlers import ( "strings" "github.com/gin-gonic/gin" ) // buildBaseURL returns the external base URL for the application. // Priority: configured base URL > X-Forwarded headers > request Host. func buildBaseURL(c *gin.Context, configuredBaseURL string) string { if configuredBaseURL != "" { return strings.TrimRight(configuredBaseURL, "/") } scheme := "http" host := c.Request.Host // Check X-Forwarded-Proto / X-Forwarded-Host (reverse proxy headers) if fwdProto := c.GetHeader("X-Forwarded-Proto"); fwdProto != "" { scheme = fwdProto } else if c.Request.TLS != nil { scheme = "https" } if fwdHost := c.GetHeader("X-Forwarded-Host"); fwdHost != "" { host = fwdHost } return scheme + "://" + host } ================================================ FILE: internal/middleware/auth.go ================================================ package middleware import ( "net/http" "net/url" "strings" "subtrackr/internal/service" "github.com/gin-gonic/gin" ) // AuthMiddleware creates middleware that requires authentication func AuthMiddleware(settingsService *service.SettingsService, sessionService *service.SessionService) gin.HandlerFunc { return func(c *gin.Context) { // Check if auth is enabled if !settingsService.IsAuthEnabled() { c.Next() return } // Skip auth for certain routes path := c.Request.URL.Path if isPublicRoute(path) { c.Next() return } // Check if user is authenticated if !sessionService.IsAuthenticated(c.Request) { // Redirect to login page for HTML requests if isHTMLRequest(c.Request) { c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(c.Request.URL.Path)) c.Abort() return } // Return 401 for API requests c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) c.Abort() return } c.Next() } } // isPublicRoute checks if a route should be accessible without authentication func isPublicRoute(path string) bool { publicRoutes := []string{ "/login", "/forgot-password", "/reset-password", "/api/auth/login", "/api/auth/logout", "/api/auth/forgot-password", "/api/auth/reset-password", "/static/", "/favicon.ico", "/healthz", "/ical/", } // API v1 routes use API keys, not session auth if strings.HasPrefix(path, "/api/v1/") { return true } for _, route := range publicRoutes { if strings.HasPrefix(path, route) { return true } } return false } // isHTMLRequest checks if the request is for HTML content func isHTMLRequest(r *http.Request) bool { accept := r.Header.Get("Accept") return strings.Contains(accept, "text/html") || accept == "" } // APIKeyAuth creates middleware that requires API key authentication func APIKeyAuth(settingsService *service.SettingsService) gin.HandlerFunc { return func(c *gin.Context) { apiKey := c.GetHeader("X-API-Key") // Also check Authorization: Bearer header if apiKey == "" { authHeader := c.GetHeader("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { apiKey = strings.TrimPrefix(authHeader, "Bearer ") } } if apiKey == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"}) c.Abort() return } // Validate API key _, err := settingsService.ValidateAPIKey(apiKey) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) c.Abort() return } c.Next() } } ================================================ FILE: internal/models/category.go ================================================ package models import "time" // Category represents a subscription category type Category struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"uniqueIndex;not null"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } ================================================ FILE: internal/models/date_migration_audit.go ================================================ package models import ( "time" "gorm.io/gorm" ) // DateMigrationLog tracks changes made during date calculation migrations type DateMigrationLog struct { ID uint `json:"id" gorm:"primaryKey"` SubscriptionID uint `json:"subscription_id" gorm:"not null"` OldVersion int `json:"old_version" gorm:"not null"` NewVersion int `json:"new_version" gorm:"not null"` OldRenewalDate *time.Time `json:"old_renewal_date"` NewRenewalDate *time.Time `json:"new_renewal_date"` MigrationReason string `json:"migration_reason" gorm:"size:255"` MigratedAt time.Time `json:"migrated_at" gorm:"autoCreateTime"` } // DateMigrationSafetyCheck provides utilities for safe date calculation migrations type DateMigrationSafetyCheck struct { db *gorm.DB } // NewDateMigrationSafetyCheck creates a new migration safety checker func NewDateMigrationSafetyCheck(db *gorm.DB) *DateMigrationSafetyCheck { return &DateMigrationSafetyCheck{db: db} } // MigrateSubscriptionToV2 safely migrates a single subscription to V2 date calculation func (dmsc *DateMigrationSafetyCheck) MigrateSubscriptionToV2(subscriptionID uint, reason string) error { // Load the subscription var sub Subscription if err := dmsc.db.First(&sub, subscriptionID).Error; err != nil { return err } // Skip if already V2 if sub.DateCalculationVersion == 2 { return nil } // Store original values for audit oldVersion := sub.DateCalculationVersion oldRenewalDate := sub.RenewalDate // Calculate with V2 sub.DateCalculationVersion = 2 sub.calculateNextRenewalDate() // Create audit log entry auditLog := DateMigrationLog{ SubscriptionID: subscriptionID, OldVersion: oldVersion, NewVersion: 2, OldRenewalDate: oldRenewalDate, NewRenewalDate: sub.RenewalDate, MigrationReason: reason, } // Save both subscription and audit log in transaction return dmsc.db.Transaction(func(tx *gorm.DB) error { if err := tx.Save(&sub).Error; err != nil { return err } return tx.Create(&auditLog).Error }) } // CompareCalculationVersions compares V1 and V2 calculations without changing data func (dmsc *DateMigrationSafetyCheck) CompareCalculationVersions(subscriptionID uint) (V1Date, V2Date *time.Time, err error) { var sub Subscription if err = dmsc.db.First(&sub, subscriptionID).Error; err != nil { return nil, nil, err } // Calculate V1 subV1 := sub subV1.DateCalculationVersion = 1 subV1.calculateNextRenewalDate() V1Date = subV1.RenewalDate // Calculate V2 subV2 := sub subV2.DateCalculationVersion = 2 subV2.calculateNextRenewalDate() V2Date = subV2.RenewalDate return V1Date, V2Date, nil } // BatchMigrateToV2WithAudit migrates all subscriptions to V2 with comprehensive auditing func (dmsc *DateMigrationSafetyCheck) BatchMigrateToV2WithAudit(dryRun bool) error { var subscriptions []Subscription if err := dmsc.db.Where("date_calculation_version = 1").Find(&subscriptions).Error; err != nil { return err } for _, sub := range subscriptions { // Compare versions first v1Date, v2Date, err := dmsc.CompareCalculationVersions(sub.ID) if err != nil { continue // Skip on error } // Log significant differences if v1Date != nil && v2Date != nil { diff := v2Date.Sub(*v1Date).Abs() if diff > 7*24*time.Hour { // More than 7 days difference auditLog := DateMigrationLog{ SubscriptionID: sub.ID, OldVersion: 1, NewVersion: 2, OldRenewalDate: v1Date, NewRenewalDate: v2Date, MigrationReason: "Batch migration - significant difference detected", } dmsc.db.Create(&auditLog) } } // Perform actual migration if not dry run if !dryRun { dmsc.MigrateSubscriptionToV2(sub.ID, "Batch migration to V2") } } return nil } // RollbackSubscriptionToV1 rolls back a subscription to V1 calculation (emergency rollback) func (dmsc *DateMigrationSafetyCheck) RollbackSubscriptionToV1(subscriptionID uint, reason string) error { // Load the subscription var sub Subscription if err := dmsc.db.First(&sub, subscriptionID).Error; err != nil { return err } // Skip if already V1 if sub.DateCalculationVersion == 1 { return nil } // Find the original audit log to restore previous renewal date var auditLog DateMigrationLog err := dmsc.db.Where("subscription_id = ? AND new_version = ?", subscriptionID, 2). Order("migrated_at DESC").First(&auditLog).Error oldRenewalDate := sub.RenewalDate if err == nil && auditLog.OldRenewalDate != nil { // Restore original renewal date if we have audit record sub.RenewalDate = auditLog.OldRenewalDate } else { // Recalculate with V1 if no audit record sub.DateCalculationVersion = 1 sub.calculateNextRenewalDate() } sub.DateCalculationVersion = 1 // Create rollback audit log rollbackLog := DateMigrationLog{ SubscriptionID: subscriptionID, OldVersion: 2, NewVersion: 1, OldRenewalDate: oldRenewalDate, NewRenewalDate: sub.RenewalDate, MigrationReason: "ROLLBACK: " + reason, } // Save both subscription and audit log in transaction return dmsc.db.Transaction(func(tx *gorm.DB) error { if err := tx.Save(&sub).Error; err != nil { return err } return tx.Create(&rollbackLog).Error }) } // GetMigrationStats returns statistics about date calculation migrations func (dmsc *DateMigrationSafetyCheck) GetMigrationStats() (map[string]interface{}, error) { stats := make(map[string]interface{}) // Count subscriptions by version var v1Count, v2Count int64 dmsc.db.Model(&Subscription{}).Where("date_calculation_version = 1").Count(&v1Count) dmsc.db.Model(&Subscription{}).Where("date_calculation_version = 2").Count(&v2Count) // Count audit logs var auditCount int64 dmsc.db.Model(&DateMigrationLog{}).Count(&auditCount) // Count rollbacks var rollbackCount int64 dmsc.db.Model(&DateMigrationLog{}).Where("migration_reason LIKE 'ROLLBACK:%'").Count(&rollbackCount) stats["v1_subscriptions"] = v1Count stats["v2_subscriptions"] = v2Count stats["total_migrations"] = auditCount stats["rollbacks"] = rollbackCount return stats, nil } ================================================ FILE: internal/models/date_migration_audit_test.go ================================================ package models import ( "testing" "time" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupAuditTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } // Migrate the schema err = db.AutoMigrate(&Subscription{}, &DateMigrationLog{}) if err != nil { t.Fatalf("Failed to migrate schema: %v", err) } return db } func TestNewDateMigrationSafetyCheck(t *testing.T) { db := setupAuditTestDB(t) safety := NewDateMigrationSafetyCheck(db) assert.NotNil(t, safety, "SafetyCheck should not be nil") assert.Equal(t, db, safety.db, "Database should be set correctly") } func TestCompareCalculationVersions(t *testing.T) { db := setupAuditTestDB(t) safety := NewDateMigrationSafetyCheck(db) // Create a test subscription startDate := time.Date(2025, 1, 31, 10, 0, 0, 0, time.UTC) sub := &Subscription{ Name: "Test Subscription", Cost: 15.99, Schedule: "Monthly", Status: "Active", StartDate: &startDate, DateCalculationVersion: 1, } err := db.Create(sub).Error assert.NoError(t, err, "Should create test subscription") // Compare V1 vs V2 calculations v1Date, v2Date, err := safety.CompareCalculationVersions(sub.ID) assert.NoError(t, err, "Should compare calculations successfully") assert.NotNil(t, v1Date, "V1 calculation should return a date") assert.NotNil(t, v2Date, "V2 calculation should return a date") // Both should be in the future assert.True(t, v1Date.After(time.Now()), "V1 date should be in future") assert.True(t, v2Date.After(time.Now()), "V2 date should be in future") } func TestGetMigrationStats(t *testing.T) { db := setupAuditTestDB(t) safety := NewDateMigrationSafetyCheck(db) // Create test subscriptions with different versions subs := []Subscription{ {Name: "V1 Sub 1", Cost: 10, Schedule: "Monthly", Status: "Active", DateCalculationVersion: 1}, {Name: "V1 Sub 2", Cost: 20, Schedule: "Annual", Status: "Active", DateCalculationVersion: 1}, {Name: "V2 Sub 1", Cost: 15, Schedule: "Monthly", Status: "Active", DateCalculationVersion: 2}, } for _, sub := range subs { err := db.Create(&sub).Error assert.NoError(t, err) } // Create a migration log entry log := &DateMigrationLog{ SubscriptionID: subs[0].ID, OldVersion: 1, NewVersion: 2, OldRenewalDate: nil, NewRenewalDate: nil, MigrationReason: "Test migration", MigratedAt: time.Now(), } err := db.Create(log).Error assert.NoError(t, err) stats, err := safety.GetMigrationStats() assert.NoError(t, err, "Should get migration stats successfully") assert.Equal(t, int64(2), stats["v1_subscriptions"], "Should have 2 V1 subscriptions") assert.Equal(t, int64(1), stats["v2_subscriptions"], "Should have 1 V2 subscription") assert.Equal(t, int64(1), stats["total_migrations"], "Should have 1 migration logged") } ================================================ FILE: internal/models/exchange_rate.go ================================================ package models import ( "time" ) // ExchangeRate represents currency exchange rate data type ExchangeRate struct { ID uint `json:"id" gorm:"primaryKey"` BaseCurrency string `json:"base_currency" gorm:"size:3;not null"` Currency string `json:"currency" gorm:"size:3;not null"` Rate float64 `json:"rate" gorm:"not null"` Date time.Time `json:"date" gorm:"not null"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } // IsStale checks if the exchange rate is older than 24 hours func (er *ExchangeRate) IsStale() bool { return time.Since(er.Date) > 24*time.Hour } ================================================ FILE: internal/models/exchange_rate_test.go ================================================ package models import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestExchangeRate_IsStale(t *testing.T) { tests := []struct { name string lastUpdated time.Time expectedStale bool description string }{ { name: "Fresh rate - just updated", lastUpdated: time.Now(), expectedStale: false, description: "Rate updated now should not be stale", }, { name: "Fresh rate - 30 minutes old", lastUpdated: time.Now().Add(-30 * time.Minute), expectedStale: false, description: "Rate updated 30 minutes ago should not be stale", }, { name: "Stale rate - 25 hours old", lastUpdated: time.Now().Add(-25 * time.Hour), expectedStale: true, description: "Rate updated 25 hours ago should be stale", }, { name: "Very stale rate - 2 days old", lastUpdated: time.Now().Add(-48 * time.Hour), expectedStale: true, description: "Rate updated 2 days ago should be stale", }, { name: "Boundary case - just over 24 hours old", lastUpdated: time.Now().Add(-24*time.Hour - time.Minute), expectedStale: true, description: "Rate updated just over 24 hours ago should be stale", }, { name: "Boundary case - just under 24 hours", lastUpdated: time.Now().Add(-23 * time.Hour), expectedStale: false, description: "Rate updated 23 hours ago should not be stale", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rate := &ExchangeRate{ Date: tt.lastUpdated, } result := rate.IsStale() assert.Equal(t, tt.expectedStale, result, tt.description) }) } } ================================================ FILE: internal/models/settings.go ================================================ package models import ( "time" ) // Settings represents application settings type Settings struct { ID uint `json:"id" gorm:"primaryKey"` Key string `json:"key" gorm:"uniqueIndex;not null"` Value string `json:"value"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } // SMTPConfig represents SMTP configuration type SMTPConfig struct { Host string `json:"smtp_host"` Port int `json:"smtp_port"` Username string `json:"smtp_username"` Password string `json:"smtp_password"` From string `json:"smtp_from"` FromName string `json:"smtp_from_name"` To string `json:"smtp_to"` // Recipient email address for notifications } // PushoverConfig represents Pushover notification configuration type PushoverConfig struct { UserKey string `json:"pushover_user_key"` // Pushover user key AppToken string `json:"pushover_app_token"` // Pushover application token } // WebhookConfig represents generic webhook notification configuration type WebhookConfig struct { URL string `json:"webhook_url"` Headers map[string]string `json:"webhook_headers"` } // NotificationSettings represents notification preferences type NotificationSettings struct { RenewalReminders bool `json:"renewal_reminders"` HighCostAlerts bool `json:"high_cost_alerts"` HighCostThreshold float64 `json:"high_cost_threshold"` ReminderDays int `json:"reminder_days"` CancellationReminders bool `json:"cancellation_reminders"` CancellationReminderDays int `json:"cancellation_reminder_days"` } // APIKey represents an API key for external access type APIKey struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"not null"` Key string `json:"key" gorm:"uniqueIndex;not null"` LastUsed *time.Time `json:"last_used"` UsageCount int `json:"usage_count" gorm:"default:0"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` IsNew bool `json:"is_new" gorm:"-"` // Not stored in DB, just for display } ================================================ FILE: internal/models/subscription.go ================================================ package models import ( "fmt" "time" "github.com/dromara/carbon/v2" "gorm.io/gorm" ) type Subscription struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"not null" validate:"required"` Cost float64 `json:"cost" gorm:"not null" validate:"required,gt=0"` OriginalCurrency string `json:"original_currency" gorm:"size:3;default:'USD'"` Schedule string `json:"schedule" gorm:"not null" validate:"required,oneof=Monthly Annual Weekly Daily Quarterly"` Status string `json:"status" gorm:"not null" validate:"required,oneof=Active Cancelled Paused Trial"` CategoryID uint `json:"category_id"` Category Category `json:"category" gorm:"foreignKey:CategoryID"` PaymentMethod string `json:"payment_method" gorm:""` Account string `json:"account" gorm:""` StartDate *time.Time `json:"start_date" gorm:""` RenewalDate *time.Time `json:"renewal_date" gorm:""` CancellationDate *time.Time `json:"cancellation_date" gorm:""` URL string `json:"url" gorm:""` IconURL string `json:"icon_url" gorm:""` // URL to subscription icon/logo Notes string `json:"notes" gorm:""` Usage string `json:"usage" gorm:"" validate:"omitempty,oneof=High Medium Low None"` ScheduleInterval int `json:"schedule_interval" gorm:"default:1"` ReminderEnabled bool `json:"reminder_enabled" gorm:"default:true"` DateCalculationVersion int `json:"date_calculation_version" gorm:"default:1"` LastReminderSent *time.Time `json:"last_reminder_sent" gorm:""` // Tracks when the last reminder was sent LastReminderRenewalDate *time.Time `json:"last_reminder_renewal_date" gorm:""` // Tracks which renewal date the last reminder was for LastCancellationReminderSent *time.Time `json:"last_cancellation_reminder_sent" gorm:""` // Tracks when the last cancellation reminder was sent LastCancellationReminderDate *time.Time `json:"last_cancellation_reminder_date" gorm:""` // Tracks which cancellation date the last reminder was for CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } func (s *Subscription) effectiveInterval() int { if s.ScheduleInterval <= 0 { return 1 } return s.ScheduleInterval } // DisplaySchedule returns a human-friendly schedule label func (s *Subscription) DisplaySchedule() string { interval := s.effectiveInterval() if interval == 1 { return s.Schedule } unit := map[string]string{ "Daily": "Days", "Weekly": "Weeks", "Monthly": "Months", "Quarterly": "Quarters", "Annual": "Years", } if u, ok := unit[s.Schedule]; ok { return fmt.Sprintf("Every %d %s", interval, u) } return s.Schedule } // AnnualCost calculates the annual cost based on schedule func (s *Subscription) AnnualCost() float64 { interval := s.effectiveInterval() switch s.Schedule { case "Annual": return s.Cost / float64(interval) case "Quarterly": return s.Cost * 4 / float64(interval) case "Monthly": return s.Cost * 12 / float64(interval) case "Weekly": return s.Cost * 52 / float64(interval) case "Daily": return s.Cost * 365 / float64(interval) default: return s.Cost * 12 / float64(interval) } } // MonthlyCost calculates the monthly cost based on schedule func (s *Subscription) MonthlyCost() float64 { interval := s.effectiveInterval() switch s.Schedule { case "Annual": return s.Cost / (12 * float64(interval)) case "Quarterly": return s.Cost / (3 * float64(interval)) case "Monthly": return s.Cost / float64(interval) case "Weekly": return s.Cost * 4.33 / float64(interval) case "Daily": return s.Cost * 30.44 / float64(interval) default: return s.Cost / float64(interval) } } // DailyCost calculates the daily cost func (s *Subscription) DailyCost() float64 { return s.MonthlyCost() / 30.44 // Average days per month } // IsHighCost determines if this is a high-cost subscription based on the threshold func (s *Subscription) IsHighCost(threshold float64) bool { return s.MonthlyCost() > threshold } // BeforeCreate hook to set renewal date for active subscriptions func (s *Subscription) BeforeCreate(tx *gorm.DB) error { if s.Status == "Active" && s.RenewalDate == nil { // Set renewal date based on schedule s.calculateNextRenewalDate() } return nil } // AfterFind hook to auto-update renewal date if it has passed (Issue #29) // This ensures renewal dates are automatically updated when subscriptions are loaded func (s *Subscription) AfterFind(tx *gorm.DB) error { // Auto-update renewal date if it has passed and subscription is active if s.RenewalDate != nil && s.Status == "Active" && s.ID > 0 { now := time.Now() if s.RenewalDate.Before(now) || s.RenewalDate.Equal(now) { // Renewal date has passed, calculate the next one oldRenewalDate := s.RenewalDate s.calculateNextRenewalDate() // Only update if the date actually changed to avoid unnecessary writes if s.RenewalDate != nil && !s.RenewalDate.Equal(*oldRenewalDate) { // Update only the renewal_date field using UpdateColumn to avoid triggering hooks // This prevents infinite recursion and only updates the specific field tx.Model(s).UpdateColumn("renewal_date", s.RenewalDate) } } } return nil } // BeforeUpdate hook to recalculate renewal date when schedule changes, start date changes, or date passes func (s *Subscription) BeforeUpdate(tx *gorm.DB) error { // Get the original values to check for schedule or start date changes var original Subscription if err := tx.Model(&Subscription{}).Where("id = ?", s.ID).First(&original).Error; err == nil { // If schedule changed and status is Active, recalculate renewal date // Use start date if available to preserve billing anniversary if (original.Schedule != s.Schedule || original.ScheduleInterval != s.ScheduleInterval) && s.Status == "Active" { s.calculateNextRenewalDate() } // If start date changed and status is Active, recalculate renewal date // This ensures renewal dates update when start dates are modified if s.Status == "Active" { startDateChanged := false if original.StartDate == nil && s.StartDate != nil { // Start date was added startDateChanged = true } else if original.StartDate != nil && s.StartDate == nil { // Start date was removed startDateChanged = true } else if original.StartDate != nil && s.StartDate != nil { // Both exist, check if they're different if !original.StartDate.Equal(*s.StartDate) { startDateChanged = true } } if startDateChanged { s.calculateNextRenewalDate() } } } // Calculate if renewal date is nil and status is Active if s.RenewalDate == nil && s.Status == "Active" { s.calculateNextRenewalDate() } // Auto-update renewal date if it has passed (Issue #29) if s.RenewalDate != nil && s.Status == "Active" { now := time.Now() if s.RenewalDate.Before(now) || s.RenewalDate.Equal(now) { // Renewal date has passed, calculate the next one s.calculateNextRenewalDate() } } return nil } // calculateNextRenewalDate calculates the next renewal date based on schedule and version. // // Version Selection Logic: // - V1 (default): Original calculation logic for backward compatibility // - All existing subscriptions use V1 unless explicitly migrated // - Uses standard Go time.AddDate() which may cause edge cases // - Example: Jan 31 + 1 month = Mar 3 (due to Feb having 28 days) // // - V2: Enhanced calculation using Carbon library for robust date arithmetic // - Must be explicitly set via DateCalculationVersion = 2 // - Uses Carbon's AddMonthsNoOverflow/AddYearsNoOverflow for better handling // - Example: Jan 31 + 1 month = Feb 28 (preserves month-end semantics) // - Recommended for new subscriptions and can be migrated via migrate-dates command func (s *Subscription) calculateNextRenewalDate() { // Use versioned calculation approach switch s.DateCalculationVersion { case 2: s.calculateNextRenewalDateV2() default: // Use V1 logic for backward compatibility s.calculateNextRenewalDateV1() } } // calculateNextRenewalDateV1 uses the original calculation logic func (s *Subscription) calculateNextRenewalDateV1() { // If we have a start date, calculate renewal from start date // Otherwise, calculate from now if s.StartDate != nil { s.calculateNextRenewalDateFromStartDate() } else { s.calculateNextRenewalDateFromNow() } } // calculateNextRenewalDateV2 uses Carbon library for robust date handling func (s *Subscription) calculateNextRenewalDateV2() { if s.StartDate == nil { s.calculateNextRenewalDateFromNowV2() return } interval := s.effectiveInterval() start := carbon.CreateFromStdTime(*s.StartDate) now := carbon.Now() switch s.Schedule { case "Monthly": current := start.Copy() for current.Lte(now) { current = current.AddMonthsNoOverflow(interval) } renewalDate := current.StdTime() s.RenewalDate = &renewalDate case "Quarterly": current := start.Copy() for current.Lte(now) { current = current.AddMonthsNoOverflow(3 * interval) } renewalDate := current.StdTime() s.RenewalDate = &renewalDate case "Annual": current := start.Copy() for current.Lte(now) { current = current.AddYearsNoOverflow(interval) } renewalDate := current.StdTime() s.RenewalDate = &renewalDate case "Weekly": current := start.Copy() for current.Lte(now) { current = current.AddWeeks(interval) } renewalDate := current.StdTime() s.RenewalDate = &renewalDate case "Daily": current := start.Copy() for current.Lte(now) { current = current.AddDays(interval) } renewalDate := current.StdTime() s.RenewalDate = &renewalDate default: current := start.Copy() for current.Lte(now) { current = current.AddMonthsNoOverflow(interval) } renewalDate := current.StdTime() s.RenewalDate = &renewalDate } } // calculateNextRenewalDateFromStartDate calculates the next renewal date from start date func (s *Subscription) calculateNextRenewalDateFromStartDate() { if s.StartDate == nil { s.calculateNextRenewalDateFromNow() return } interval := s.effectiveInterval() var renewalDate time.Time baseDate := *s.StartDate now := time.Now() switch s.Schedule { case "Annual": years := interval for { renewalDate = baseDate.AddDate(years, 0, 0) if renewalDate.After(now) { break } years += interval } case "Quarterly": startDay := baseDate.Day() startYear := baseDate.Year() startMonth := int(baseDate.Month()) step := 3 * interval periods := 1 for { totalMonths := startMonth + (periods * step) - 1 targetYear := startYear + totalMonths/12 targetMonth := time.Month((totalMonths % 12) + 1) lastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, baseDate.Location()).Day() targetDay := startDay if startDay > lastDay { targetDay = lastDay } renewalDate = time.Date(targetYear, targetMonth, targetDay, baseDate.Hour(), baseDate.Minute(), baseDate.Second(), baseDate.Nanosecond(), baseDate.Location()) if renewalDate.After(now) { break } periods++ } case "Monthly": startDay := baseDate.Day() startYear := baseDate.Year() startMonth := int(baseDate.Month()) periods := 1 for { totalMonths := startMonth + (periods * interval) - 1 targetYear := startYear + totalMonths/12 targetMonth := time.Month((totalMonths % 12) + 1) lastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, baseDate.Location()).Day() targetDay := startDay if startDay > lastDay { targetDay = lastDay } renewalDate = time.Date(targetYear, targetMonth, targetDay, baseDate.Hour(), baseDate.Minute(), baseDate.Second(), baseDate.Nanosecond(), baseDate.Location()) if renewalDate.After(now) { break } periods++ } case "Weekly": weeks := interval for { renewalDate = baseDate.AddDate(0, 0, weeks*7) if renewalDate.After(now) { break } weeks += interval } case "Daily": days := interval for { renewalDate = baseDate.AddDate(0, 0, days) if renewalDate.After(now) { break } days += interval } default: startDay := baseDate.Day() startYear := baseDate.Year() startMonth := int(baseDate.Month()) periods := 1 for { totalMonths := startMonth + (periods * interval) - 1 targetYear := startYear + totalMonths/12 targetMonth := time.Month((totalMonths % 12) + 1) lastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, baseDate.Location()).Day() targetDay := startDay if startDay > lastDay { targetDay = lastDay } renewalDate = time.Date(targetYear, targetMonth, targetDay, baseDate.Hour(), baseDate.Minute(), baseDate.Second(), baseDate.Nanosecond(), baseDate.Location()) if renewalDate.After(now) { break } periods++ } } s.RenewalDate = &renewalDate } // calculateNextRenewalDateFromNow calculates the next renewal date from current time func (s *Subscription) calculateNextRenewalDateFromNow() { interval := s.effectiveInterval() var renewalDate time.Time baseDate := time.Now() switch s.Schedule { case "Annual": renewalDate = baseDate.AddDate(interval, 0, 0) case "Quarterly": renewalDate = baseDate.AddDate(0, 3*interval, 0) case "Monthly": renewalDate = baseDate.AddDate(0, interval, 0) case "Weekly": renewalDate = baseDate.AddDate(0, 0, 7*interval) case "Daily": renewalDate = baseDate.AddDate(0, 0, interval) default: renewalDate = baseDate.AddDate(0, interval, 0) } s.RenewalDate = &renewalDate } // calculateNextRenewalDateFromNowV2 calculates renewal date from now using Carbon func (s *Subscription) calculateNextRenewalDateFromNowV2() { interval := s.effectiveInterval() now := carbon.Now() switch s.Schedule { case "Annual": renewalDate := now.AddYearsNoOverflow(interval).StdTime() s.RenewalDate = &renewalDate case "Quarterly": renewalDate := now.AddMonthsNoOverflow(3 * interval).StdTime() s.RenewalDate = &renewalDate case "Monthly": renewalDate := now.AddMonthsNoOverflow(interval).StdTime() s.RenewalDate = &renewalDate case "Weekly": renewalDate := now.AddWeeks(interval).StdTime() s.RenewalDate = &renewalDate case "Daily": renewalDate := now.AddDays(interval).StdTime() s.RenewalDate = &renewalDate default: renewalDate := now.AddMonthsNoOverflow(interval).StdTime() s.RenewalDate = &renewalDate } } // Stats represents aggregated subscription statistics type Stats struct { TotalMonthlySpend float64 `json:"total_monthly_spend"` TotalAnnualSpend float64 `json:"total_annual_spend"` ActiveSubscriptions int `json:"active_subscriptions"` CancelledSubscriptions int `json:"cancelled_subscriptions"` TotalSaved float64 `json:"total_saved"` MonthlySaved float64 `json:"monthly_saved"` UpcomingRenewals int `json:"upcoming_renewals"` CategorySpending map[string]float64 `json:"category_spending"` } // CategoryStat represents spending by category type CategoryStat struct { Category string `json:"category"` Amount float64 `json:"amount"` Count int `json:"count"` } ================================================ FILE: internal/models/subscription_test.go ================================================ package models import ( "strings" "testing" "time" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } // Migrate the schema err = db.AutoMigrate(&Subscription{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } return db } func TestSubscription_CalculateNextRenewalDate(t *testing.T) { now := time.Now() tests := []struct { name string schedule string startDate *time.Time expectedDuration time.Duration description string }{ { name: "Monthly schedule", schedule: "Monthly", startDate: &now, expectedDuration: 30 * 24 * time.Hour, // Approximately 30 days description: "Should add approximately 1 month", }, { name: "Annual schedule", schedule: "Annual", startDate: &now, expectedDuration: 365 * 24 * time.Hour, // Approximately 365 days description: "Should add approximately 1 year", }, { name: "Weekly schedule", schedule: "Weekly", startDate: &now, expectedDuration: 7 * 24 * time.Hour, // Exactly 7 days description: "Should add exactly 7 days", }, { name: "Daily schedule", schedule: "Daily", startDate: &now, expectedDuration: 24 * time.Hour, // Exactly 1 day description: "Should add exactly 1 day", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, StartDate: tt.startDate, Status: "Active", } sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate, tt.description) if tt.schedule == "Monthly" { // For monthly, check it's in the next month expectedMonth := now.AddDate(0, 1, 0) assert.Equal(t, expectedMonth.Month(), sub.RenewalDate.Month()) assert.Equal(t, expectedMonth.Year(), sub.RenewalDate.Year()) } else if tt.schedule == "Annual" { // For annual, check it's in the next year expectedYear := now.AddDate(1, 0, 0) assert.Equal(t, expectedYear.Year(), sub.RenewalDate.Year()) } else { // For weekly and daily, we can check exact duration actualDuration := sub.RenewalDate.Sub(*tt.startDate) assert.InDelta(t, tt.expectedDuration.Hours(), actualDuration.Hours(), 1, tt.description) } }) } } func TestSubscription_CalculateNextRenewalDateFromNow(t *testing.T) { tests := []struct { name string schedule string status string }{ { name: "Monthly renewal from now", schedule: "Monthly", status: "Active", }, { name: "Annual renewal from now", schedule: "Annual", status: "Active", }, { name: "Weekly renewal from now", schedule: "Weekly", status: "Active", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, Status: tt.status, } sub.calculateNextRenewalDateFromNow() assert.NotNil(t, sub.RenewalDate) assert.True(t, sub.RenewalDate.After(time.Now()), "Renewal date should be in the future") }) } } func TestSubscription_BeforeUpdate_ScheduleChange(t *testing.T) { db := setupTestDB(t) // Create a subscription with initial schedule startDate := time.Now().AddDate(0, -3, 0) // 3 months ago renewalDate := time.Now().AddDate(0, 1, 0) // 1 month from now sub := &Subscription{ Name: "Test Subscription", Cost: 9.99, Schedule: "Monthly", Status: "Active", StartDate: &startDate, RenewalDate: &renewalDate, } // Save the subscription err := db.Create(sub).Error assert.NoError(t, err) // Simulate schedule change by fetching and updating var existing Subscription err = db.First(&existing, sub.ID).Error assert.NoError(t, err) // Change schedule from Monthly to Annual existing.Schedule = "Annual" // Trigger BeforeUpdate hook err = existing.BeforeUpdate(db) assert.NoError(t, err) // Verify renewal date was recalculated assert.NotNil(t, existing.RenewalDate) // The new renewal date should be in the future (using start date + schedule) assert.True(t, existing.RenewalDate.After(time.Now()), "Renewal should be in future") // For schedule change from Monthly to Annual, it should preserve the start date anniversary assert.Equal(t, startDate.Month(), existing.RenewalDate.Month(), "Should preserve start date month") assert.Equal(t, startDate.Day(), existing.RenewalDate.Day(), "Should preserve start date day") } func TestSubscription_BeforeUpdate_NoScheduleChange(t *testing.T) { db := setupTestDB(t) // Create a subscription originalRenewal := time.Now().AddDate(0, 1, 0) sub := &Subscription{ ID: 1, Name: "Test Subscription", Cost: 9.99, Schedule: "Monthly", Status: "Active", RenewalDate: &originalRenewal, } // Save the subscription err := db.Create(sub).Error assert.NoError(t, err) // Update without changing schedule sub.Cost = 19.99 // Trigger BeforeUpdate hook err = sub.BeforeUpdate(db) assert.NoError(t, err) // Verify renewal date was NOT changed assert.NotNil(t, sub.RenewalDate) assert.Equal(t, originalRenewal.Format("2006-01-02"), sub.RenewalDate.Format("2006-01-02")) } func TestSubscription_BeforeUpdate_NilRenewalDate(t *testing.T) { db := setupTestDB(t) // Create a subscription without renewal date sub := &Subscription{ ID: 1, Name: "Test Subscription", Cost: 9.99, Schedule: "Monthly", Status: "Active", RenewalDate: nil, // No renewal date set } // Save the subscription err := db.Create(sub).Error assert.NoError(t, err) // Trigger BeforeUpdate hook err = sub.BeforeUpdate(db) assert.NoError(t, err) // Verify renewal date was calculated assert.NotNil(t, sub.RenewalDate) assert.True(t, sub.RenewalDate.After(time.Now())) } func TestSubscription_MonthlyCost(t *testing.T) { tests := []struct { name string schedule string cost float64 expected float64 }{ { name: "Monthly subscription", schedule: "Monthly", cost: 10.00, expected: 10.00, }, { name: "Annual subscription", schedule: "Annual", cost: 120.00, expected: 10.00, }, { name: "Weekly subscription", schedule: "Weekly", cost: 10.00, expected: 43.30, // 10 * 52 / 12 = 43.333... }, { name: "Daily subscription", schedule: "Daily", cost: 1.00, expected: 30.44, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, Cost: tt.cost, } result := sub.MonthlyCost() assert.InDelta(t, tt.expected, result, 0.01) }) } } func TestSubscription_BeforeCreate_WithStartDate(t *testing.T) { db := setupTestDB(t) tests := []struct { name string schedule string startDate time.Time description string }{ { name: "Monthly subscription with past start date", schedule: "Monthly", startDate: time.Now().AddDate(0, -2, -15), // 2.5 months ago description: "Should calculate next monthly anniversary", }, { name: "Annual subscription with past start date", schedule: "Annual", startDate: time.Now().AddDate(0, -6, 0), // 6 months ago description: "Should calculate next annual anniversary", }, { name: "Weekly subscription with past start date", schedule: "Weekly", startDate: time.Now().AddDate(0, 0, -10), // 10 days ago description: "Should calculate next weekly anniversary", }, { name: "Future start date", schedule: "Monthly", startDate: time.Now().AddDate(0, 0, 7), // 7 days in future description: "Should set renewal one month after future start date", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Name: "Test Subscription", Cost: 9.99, Schedule: tt.schedule, Status: "Active", StartDate: &tt.startDate, } // Trigger BeforeCreate hook err := sub.BeforeCreate(db) assert.NoError(t, err) // Verify renewal date was set assert.NotNil(t, sub.RenewalDate, tt.description) assert.True(t, sub.RenewalDate.After(time.Now()), "Renewal date should be in the future") // For past start dates, verify it's the next occurrence if tt.startDate.Before(time.Now()) { // The renewal should be after now but follow the schedule pattern switch tt.schedule { case "Monthly": // Should be on the same day of month as start date, unless start date is month-end startYear, startMonth, _ := tt.startDate.Date() renewalYear, renewalMonth, _ := sub.RenewalDate.Date() startLastDay := time.Date(startYear, startMonth+1, 0, 0, 0, 0, 0, tt.startDate.Location()).Day() renewalLastDay := time.Date(renewalYear, renewalMonth+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day() if tt.startDate.Day() == startLastDay { assert.Equal(t, renewalLastDay, sub.RenewalDate.Day(), "Renewal date should be last day of month if start date was") } else { assert.Equal(t, tt.startDate.Day(), sub.RenewalDate.Day()) } case "Annual": // Should be on same month/day as start date assert.Equal(t, tt.startDate.Month(), sub.RenewalDate.Month()) assert.Equal(t, tt.startDate.Day(), sub.RenewalDate.Day()) } } }) } } func TestSubscription_AnnualCost(t *testing.T) { tests := []struct { name string schedule string cost float64 expected float64 }{ { name: "Monthly subscription", schedule: "Monthly", cost: 10.00, expected: 120.00, }, { name: "Annual subscription", schedule: "Annual", cost: 120.00, expected: 120.00, }, { name: "Weekly subscription", schedule: "Weekly", cost: 10.00, expected: 520.00, }, { name: "Daily subscription", schedule: "Daily", cost: 1.00, expected: 365.00, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, Cost: tt.cost, } result := sub.AnnualCost() assert.InDelta(t, tt.expected, result, 0.01) }) } } // TestSubscription_DailyCost tests daily cost calculation func TestSubscription_DailyCost(t *testing.T) { tests := []struct { name string schedule string cost float64 expected float64 }{ { name: "Monthly subscription", schedule: "Monthly", cost: 30.44, // Should result in 1.00 daily expected: 1.00, }, { name: "Annual subscription", schedule: "Annual", cost: 365.00, // Should result in ~1.00 daily expected: 1.00, }, { name: "Weekly subscription", schedule: "Weekly", cost: 7.00, // Should result in ~1.00 daily expected: 1.00, }, { name: "Daily subscription", schedule: "Daily", cost: 2.00, expected: 2.00, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, Cost: tt.cost, } result := sub.DailyCost() assert.InDelta(t, tt.expected, result, 0.01) }) } } // TestSubscription_IsHighCost tests high cost detection func TestSubscription_IsHighCost(t *testing.T) { threshold := 50.0 // Default threshold tests := []struct { name string schedule string cost float64 expected bool }{ { name: "Low cost monthly", schedule: "Monthly", cost: 25.00, expected: false, }, { name: "High cost monthly", schedule: "Monthly", cost: 75.00, expected: true, }, { name: "Boundary case - exactly 50", schedule: "Monthly", cost: 50.00, expected: false, }, { name: "Boundary case - just over 50", schedule: "Monthly", cost: 50.01, expected: true, }, { name: "High cost annual (converted to monthly)", schedule: "Annual", cost: 720.00, // $60/month expected: true, }, { name: "Low cost weekly (converted to monthly)", schedule: "Weekly", cost: 10.00, // ~$43.30/month expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, Cost: tt.cost, } result := sub.IsHighCost(threshold) assert.Equal(t, tt.expected, result) }) } } // TestSubscription_DateEdgeCases tests critical edge cases for date calculations // Note: These tests focus on the core logic, not exact historical sequences func TestSubscription_DateEdgeCases(t *testing.T) { tests := []struct { name string startDate string schedule string expectedBehavior string description string }{ { name: "January 31st Monthly - Month End Handling", startDate: "2025-01-31T10:00:00Z", schedule: "Monthly", expectedBehavior: "future_month_end", description: "Jan 31 should calculate next month-end after current date", }, { name: "February 29th Leap Year - Next Occurrence", startDate: "2024-02-29T10:00:00Z", // 2024 is leap year schedule: "Monthly", expectedBehavior: "next_valid_date", description: "Feb 29 (leap) should find next valid renewal after current date", }, { name: "February 29th Annual - Leap Year Handling", startDate: "2024-02-29T10:00:00Z", schedule: "Annual", expectedBehavior: "next_anniversary", description: "Feb 29 annual should find next anniversary after current date", }, { name: "Past Start Date Monthly", startDate: "2024-01-31T10:00:00Z", // Past date schedule: "Monthly", expectedBehavior: "next_occurrence_after_now", description: "Past start date should find next occurrence after current time", }, { name: "Future Start Date Monthly", startDate: "2025-10-15T10:00:00Z", // Future date schedule: "Monthly", expectedBehavior: "first_renewal_after_start", description: "Future start date should calculate first renewal properly", }, { name: "July 31st Monthly - Current Edge Case", startDate: "2025-07-31T10:00:00Z", schedule: "Monthly", expectedBehavior: "next_month_end", description: "July 31 should handle month-end logic correctly", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { startTime, err := time.Parse(time.RFC3339, tt.startDate) assert.NoError(t, err, "Failed to parse start date") sub := &Subscription{ Schedule: tt.schedule, StartDate: &startTime, Status: "Active", } // Test renewal calculation sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate, tt.description) // All renewal dates should be in the future assert.True(t, sub.RenewalDate.After(time.Now()), "Renewal date should be in the future for %s", tt.description) // Test specific behaviors based on the expected behavior switch tt.expectedBehavior { case "future_month_end": // Should preserve month-end logic lastDayOfRenewalMonth := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day() assert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDayOfRenewalMonth, "Should preserve month-end logic for %s", tt.description) case "next_occurrence_after_now": // Should find next occurrence after now assert.True(t, sub.RenewalDate.After(time.Now()), "Should be after current time for %s", tt.description) // For Jan 31 start, should preserve month-end logic if startTime.Day() == 31 { lastDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day() assert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDay, "Should preserve month-end for past Jan 31") } case "first_renewal_after_start": // For future dates, should be exactly one period after start if tt.schedule == "Monthly" { expected := startTime.AddDate(0, 1, 0) assert.Equal(t, expected.Day(), sub.RenewalDate.Day(), "Should be one month after start for %s", tt.description) } case "next_month_end": // July 31 -> should find next month-end occurrence after current date lastDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day() assert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDay, "Should handle month-end correctly for %s", tt.description) default: // Just verify it's a valid future date assert.True(t, sub.RenewalDate.After(time.Now()), "Should be a valid future date for %s", tt.description) } }) } } // TestSubscription_ScheduleChangePreservation tests that schedule changes preserve billing anniversary func TestSubscription_ScheduleChangePreservation(t *testing.T) { db := setupTestDB(t) tests := []struct { name string initialSchedule string newSchedule string startDate string expectedDay int description string }{ { name: "Monthly to Annual preserves day", initialSchedule: "Monthly", newSchedule: "Annual", startDate: "2025-01-15T10:00:00Z", expectedDay: 15, description: "Changing Monthly → Annual should preserve 15th", }, { name: "Annual to Monthly preserves day", initialSchedule: "Annual", newSchedule: "Monthly", startDate: "2024-03-20T10:00:00Z", expectedDay: 20, description: "Changing Annual → Monthly should preserve 20th", }, { name: "Monthly to Annual with month-end date", initialSchedule: "Monthly", newSchedule: "Annual", startDate: "2024-01-31T10:00:00Z", expectedDay: 31, description: "Jan 31 Monthly → Annual should preserve 31st", }, { name: "Weekly to Monthly preserves weekday as much as possible", initialSchedule: "Weekly", newSchedule: "Monthly", startDate: "2025-01-07T10:00:00Z", // Tuesday expectedDay: 7, description: "Weekly → Monthly should preserve original date", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { startTime, err := time.Parse(time.RFC3339, tt.startDate) assert.NoError(t, err) // Create subscription with initial schedule sub := &Subscription{ Name: "Test Subscription", Cost: 9.99, Schedule: tt.initialSchedule, Status: "Active", StartDate: &startTime, } err = db.Create(sub).Error assert.NoError(t, err) // Load the subscription to get the initial renewal date var loaded Subscription err = db.First(&loaded, sub.ID).Error assert.NoError(t, err) // Change the schedule loaded.Schedule = tt.newSchedule // Trigger the BeforeUpdate hook err = loaded.BeforeUpdate(db) assert.NoError(t, err) // Verify the renewal date preserves the billing anniversary assert.NotNil(t, loaded.RenewalDate, tt.description) if tt.name != "Weekly to Monthly preserves weekday as much as possible" { assert.Equal(t, tt.expectedDay, loaded.RenewalDate.Day(), tt.description) } // Ensure renewal is in the future assert.True(t, loaded.RenewalDate.After(time.Now()), "Renewal should be in future for %s", tt.description) }) } } // TestSubscription_LeapYearHandling tests comprehensive leap year scenarios func TestSubscription_LeapYearHandling(t *testing.T) { tests := []struct { name string startDate string schedule string testYears []int expectedDays []int description string }{ { name: "Feb 29 Monthly - Leap Year Handling", startDate: "2024-02-29T10:00:00Z", // Leap year schedule: "Monthly", description: "Feb 29 should find next valid monthly renewal after current date", }, { name: "Feb 29 Annual across multiple leap years", startDate: "2024-02-29T10:00:00Z", schedule: "Annual", testYears: []int{2025, 2026, 2027, 2028, 2029}, expectedDays: []int{28, 28, 28, 29, 28}, // Non-leap years use 28th description: "Feb 29 Annual should use Feb 28 except in leap years", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { startTime, err := time.Parse(time.RFC3339, tt.startDate) assert.NoError(t, err) sub := &Subscription{ Schedule: tt.schedule, StartDate: &startTime, Status: "Active", } // Calculate the next renewal from the start date sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate, tt.description) // Verify the renewal is in the future assert.True(t, sub.RenewalDate.After(time.Now()), "Leap year renewal should be in future for %s", tt.description) // For leap year handling, verify it's reasonable if tt.name == "Feb 29 Annual across multiple leap years" { assert.True(t, sub.RenewalDate.Month() == time.February || sub.RenewalDate.Month() == time.March, "Annual Feb 29 should result in Feb/Mar renewal") // Be flexible with day range - could be Feb 28, Feb 29, or Mar 1 assert.True(t, (sub.RenewalDate.Month() == time.February && sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= 29) || (sub.RenewalDate.Month() == time.March && sub.RenewalDate.Day() == 1), "Day should be Feb 28/29 or Mar 1 for leap year handling, got %v", sub.RenewalDate) } }) } } // TestSubscription_TimezoneConsistency tests date calculations across timezones func TestSubscription_TimezoneConsistency(t *testing.T) { timezones := []string{ "UTC", "America/New_York", "America/Los_Angeles", "Europe/London", "Asia/Tokyo", "Australia/Sydney", } for _, tz := range timezones { t.Run("Timezone "+tz, func(t *testing.T) { location, err := time.LoadLocation(tz) assert.NoError(t, err) startTime := time.Date(2025, 1, 31, 12, 0, 0, 0, location) sub := &Subscription{ Schedule: "Monthly", StartDate: &startTime, Status: "Active", } sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate) // Renewal should preserve the timezone assert.Equal(t, location, sub.RenewalDate.Location()) // Should handle month-end correctly regardless of timezone assert.True(t, sub.RenewalDate.After(startTime)) }) } } // TestSubscription_DateCalculationV2 tests the Carbon-based V2 date calculation func TestSubscription_DateCalculationV2(t *testing.T) { tests := []struct { name string startDate string schedule string expectedNext []string // First few renewal dates description string }{ { name: "V2 January 31st Monthly - Month End Handling", startDate: "2025-01-31T10:00:00Z", schedule: "Monthly", expectedNext: []string{"2025-02-28", "2025-03-31", "2025-04-30", "2025-05-31"}, description: "Jan 31 → Feb 28 → Mar 31 → Apr 30 → May 31 (Carbon NoOverflow)", }, { name: "V2 February 29th Leap Year Monthly", startDate: "2024-02-29T10:00:00Z", schedule: "Monthly", expectedNext: []string{"2024-03-29", "2024-04-29", "2024-05-29"}, description: "Feb 29 (leap) → Mar 29 → Apr 29 → May 29 (Carbon NoOverflow)", }, { name: "V2 March 31st Monthly - April Has 30 Days", startDate: "2025-03-31T10:00:00Z", schedule: "Monthly", expectedNext: []string{"2025-04-30", "2025-05-31", "2025-06-30", "2025-07-31"}, description: "Mar 31 → Apr 30 → May 31 → Jun 30 → Jul 31 (Carbon NoOverflow)", }, { name: "V2 July 31st Monthly - August and September", startDate: "2025-07-31T10:00:00Z", schedule: "Monthly", expectedNext: []string{"2025-08-31", "2025-09-30", "2025-10-31", "2025-11-30"}, description: "Jul 31 → Aug 31 → Sep 30 → Oct 31 → Nov 30 (Carbon NoOverflow)", }, { name: "V2 February 29th Annual Leap Year", startDate: "2024-02-29T10:00:00Z", schedule: "Annual", expectedNext: []string{"2025-02-28", "2026-02-28", "2027-02-28", "2028-02-29"}, description: "Feb 29 leap → Feb 28 non-leap years → Feb 29 next leap (Carbon NoOverflow)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { startTime, err := time.Parse(time.RFC3339, tt.startDate) assert.NoError(t, err, "Failed to parse start date") sub := &Subscription{ Schedule: tt.schedule, StartDate: &startTime, Status: "Active", DateCalculationVersion: 2, // Use V2 Carbon-based calculation } // Test V2 renewal calculation sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate, tt.description) // All V2 calculations should result in future dates assert.True(t, sub.RenewalDate.After(time.Now()), "V2 renewal date should be in the future for %s", tt.description) // Test V2 Carbon-based behaviors if strings.Contains(tt.name, "January 31st") || strings.Contains(tt.name, "July 31st") { // Should preserve month-end logic with Carbon's NoOverflow lastDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day() assert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDay, "Carbon should handle month-end correctly for %s", tt.description) } else if strings.Contains(tt.name, "February 29th") { // Feb 29 should be handled gracefully by Carbon if tt.schedule == "Annual" { // Feb 29 annual should find next valid anniversary assert.True(t, sub.RenewalDate.Month() == time.February || sub.RenewalDate.Month() == time.March, "Carbon annual should handle Feb 29 appropriately for %s", tt.description) assert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= 29, "Carbon should use Feb 28 or 29 for leap year for %s", tt.description) } else { // Monthly should handle leap year transition assert.True(t, sub.RenewalDate.After(time.Now()), "Carbon should handle leap year transition for %s", tt.description) } } }) } } // TestSubscription_VersionedCalculation tests that versioning works correctly func TestSubscription_VersionedCalculation(t *testing.T) { startTime := time.Date(2025, 1, 31, 10, 0, 0, 0, time.UTC) // Test V1 calculation subV1 := &Subscription{ Schedule: "Monthly", StartDate: &startTime, Status: "Active", DateCalculationVersion: 1, // V1 } subV1.calculateNextRenewalDate() // Test V2 calculation subV2 := &Subscription{ Schedule: "Monthly", StartDate: &startTime, Status: "Active", DateCalculationVersion: 2, // V2 } subV2.calculateNextRenewalDate() // Both should have renewal dates set assert.NotNil(t, subV1.RenewalDate, "V1 should calculate renewal date") assert.NotNil(t, subV2.RenewalDate, "V2 should calculate renewal date") // V2 should handle month-end dates better with Carbon's NoOverflow // Both should be in the future assert.True(t, subV1.RenewalDate.After(time.Now()), "V1 renewal should be in future") assert.True(t, subV2.RenewalDate.After(time.Now()), "V2 renewal should be in future") } // TestSubscription_CarbonLibraryFeatures tests specific Carbon library features func TestSubscription_CarbonLibraryFeatures(t *testing.T) { tests := []struct { name string startDate string schedule string description string }{ { name: "Carbon NoOverflow handles Feb 31st", startDate: "2025-01-31T10:00:00Z", schedule: "Monthly", description: "Carbon AddMonthsNoOverflow should handle Jan 31 → Feb properly", }, { name: "Carbon handles leap year transitions", startDate: "2024-02-29T10:00:00Z", schedule: "Annual", description: "Carbon should handle Feb 29 → Feb 28 in non-leap years", }, { name: "Carbon preserves time zones", startDate: "2025-01-15T10:00:00-05:00", // EST timezone schedule: "Monthly", description: "Carbon should preserve timezone information", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { startTime, err := time.Parse(time.RFC3339, tt.startDate) assert.NoError(t, err, "Failed to parse start date") sub := &Subscription{ Schedule: tt.schedule, StartDate: &startTime, Status: "Active", DateCalculationVersion: 2, // Use V2 Carbon-based calculation } sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate, tt.description) assert.True(t, sub.RenewalDate.After(time.Now()), "Renewal should be in future") // Test timezone preservation if tt.name == "Carbon preserves time zones" { assert.Equal(t, startTime.Location(), sub.RenewalDate.Location(), "Timezone should be preserved") } }) } } func TestSubscription_DisplaySchedule(t *testing.T) { tests := []struct { name string schedule string interval int expected string }{ {"Monthly default", "Monthly", 1, "Monthly"}, {"Monthly zero interval", "Monthly", 0, "Monthly"}, {"Annual default", "Annual", 1, "Annual"}, {"Every 2 Years", "Annual", 2, "Every 2 Years"}, {"Every 10 Years", "Annual", 10, "Every 10 Years"}, {"Every 2 Weeks", "Weekly", 2, "Every 2 Weeks"}, {"Every 6 Months", "Monthly", 6, "Every 6 Months"}, {"Every 3 Days", "Daily", 3, "Every 3 Days"}, {"Quarterly default", "Quarterly", 1, "Quarterly"}, {"Every 2 Quarters", "Quarterly", 2, "Every 2 Quarters"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{Schedule: tt.schedule, ScheduleInterval: tt.interval} assert.Equal(t, tt.expected, sub.DisplaySchedule()) }) } } func TestSubscription_CostWithInterval(t *testing.T) { tests := []struct { name string schedule string interval int cost float64 expectedAnnual float64 expectedMonthly float64 }{ {"Monthly interval=1", "Monthly", 1, 10.00, 120.00, 10.00}, {"Monthly interval=2", "Monthly", 2, 10.00, 60.00, 5.00}, {"Annual interval=1", "Annual", 1, 120.00, 120.00, 10.00}, {"Annual interval=2", "Annual", 2, 120.00, 60.00, 5.00}, {"Annual interval=10", "Annual", 10, 200.00, 20.00, 200.0 / 120.0}, {"Weekly interval=2", "Weekly", 2, 10.00, 260.00, 10.0 * 4.33 / 2}, {"Daily interval=1", "Daily", 1, 1.00, 365.00, 30.44}, {"Quarterly interval=1", "Quarterly", 1, 30.00, 120.00, 10.00}, {"Quarterly interval=2", "Quarterly", 2, 30.00, 60.00, 5.00}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{Schedule: tt.schedule, ScheduleInterval: tt.interval, Cost: tt.cost} assert.InDelta(t, tt.expectedAnnual, sub.AnnualCost(), 0.01, "AnnualCost") assert.InDelta(t, tt.expectedMonthly, sub.MonthlyCost(), 0.01, "MonthlyCost") }) } } func TestSubscription_RenewalDateWithInterval(t *testing.T) { now := time.Now() pastStart := now.AddDate(0, 0, -10) // 10 days ago tests := []struct { name string schedule string interval int start *time.Time }{ {"Every 2 Years", "Annual", 2, &pastStart}, {"Every 2 Weeks", "Weekly", 2, &pastStart}, {"Every 3 Months", "Monthly", 3, &pastStart}, {"Every 5 Years from now", "Annual", 5, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &Subscription{ Schedule: tt.schedule, ScheduleInterval: tt.interval, StartDate: tt.start, Status: "Active", } sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate) assert.True(t, sub.RenewalDate.After(now), "Renewal should be in the future") // Verify interval is respected: e.g. "Every 2 Weeks" from 10 days ago should be 4 days from now (14-10=4) if tt.schedule == "Weekly" && tt.interval == 2 && tt.start != nil { daysDiff := sub.RenewalDate.Sub(pastStart).Hours() / 24 assert.InDelta(t, 14, daysDiff, 1, "Every 2 Weeks should be ~14 days from start") } if tt.schedule == "Annual" && tt.interval == 5 && tt.start == nil { yearsDiff := sub.RenewalDate.Year() - now.Year() assert.Equal(t, 5, yearsDiff, "Every 5 Years from now should be 5 years out") } }) } } func TestSubscription_RenewalDateV2WithInterval(t *testing.T) { pastStart := time.Now().AddDate(-1, 0, 0) // 1 year ago sub := &Subscription{ Schedule: "Annual", ScheduleInterval: 2, StartDate: &pastStart, Status: "Active", DateCalculationVersion: 2, } sub.calculateNextRenewalDate() assert.NotNil(t, sub.RenewalDate) assert.True(t, sub.RenewalDate.After(time.Now())) // 1 year ago + 2 years = 1 year from now expectedYear := pastStart.Year() + 2 assert.Equal(t, expectedYear, sub.RenewalDate.Year(), "Every 2 Years V2 should be 2 years from start") } ================================================ FILE: internal/repository/category.go ================================================ package repository import ( "subtrackr/internal/models" "gorm.io/gorm" ) type CategoryRepository struct { db *gorm.DB } func NewCategoryRepository(db *gorm.DB) *CategoryRepository { return &CategoryRepository{db: db} } func (r *CategoryRepository) Create(category *models.Category) (*models.Category, error) { if err := r.db.Create(category).Error; err != nil { return nil, err } return category, nil } func (r *CategoryRepository) GetAll() ([]models.Category, error) { var categories []models.Category if err := r.db.Order("name ASC").Find(&categories).Error; err != nil { return nil, err } return categories, nil } func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) { var category models.Category if err := r.db.First(&category, id).Error; err != nil { return nil, err } return &category, nil } func (r *CategoryRepository) Update(id uint, category *models.Category) (*models.Category, error) { if err := r.db.Model(&models.Category{}).Where("id = ?", id).Updates(category).Error; err != nil { return nil, err } return r.GetByID(id) } func (r *CategoryRepository) Delete(id uint) error { return r.db.Delete(&models.Category{}, id).Error } func (r *CategoryRepository) GetByName(name string) (*models.Category, error) { var category models.Category if err := r.db.Where("name = ?", name).First(&category).Error; err != nil { return nil, err } return &category, nil } func (r *CategoryRepository) HasSubscriptions(id uint) (bool, error) { var count int64 err := r.db.Model(&models.Subscription{}).Where("category_id = ?", id).Count(&count).Error return count > 0, err } ================================================ FILE: internal/repository/exchange_rate.go ================================================ package repository import ( "subtrackr/internal/models" "time" "gorm.io/gorm" ) type ExchangeRateRepository struct { db *gorm.DB } func NewExchangeRateRepository(db *gorm.DB) *ExchangeRateRepository { return &ExchangeRateRepository{db: db} } // GetRate retrieves the exchange rate for a specific currency pair func (r *ExchangeRateRepository) GetRate(baseCurrency, targetCurrency string) (*models.ExchangeRate, error) { if baseCurrency == targetCurrency { // Return rate of 1.0 for same currency return &models.ExchangeRate{ BaseCurrency: baseCurrency, Currency: targetCurrency, Rate: 1.0, Date: time.Now(), }, nil } var rate models.ExchangeRate err := r.db.Where("base_currency = ? AND currency = ?", baseCurrency, targetCurrency). Order("date DESC"). First(&rate).Error if err != nil { return nil, err } return &rate, nil } // SaveRates saves multiple exchange rates func (r *ExchangeRateRepository) SaveRates(rates []models.ExchangeRate) error { return r.db.Create(&rates).Error } // GetLatestRates retrieves the latest exchange rates for a base currency func (r *ExchangeRateRepository) GetLatestRates(baseCurrency string) ([]models.ExchangeRate, error) { var rates []models.ExchangeRate // Get the latest rate for each target currency subQuery := r.db.Model(&models.ExchangeRate{}). Select("currency, MAX(date) as latest_date"). Where("base_currency = ?", baseCurrency). Group("currency") err := r.db.Joins("JOIN (?) as latest ON exchange_rates.currency = latest.currency AND exchange_rates.date = latest.latest_date", subQuery). Where("base_currency = ?", baseCurrency). Find(&rates).Error return rates, err } // DeleteStaleRates removes exchange rates older than the specified duration func (r *ExchangeRateRepository) DeleteStaleRates(olderThan time.Duration) error { cutoff := time.Now().Add(-olderThan) return r.db.Where("date < ?", cutoff).Delete(&models.ExchangeRate{}).Error } ================================================ FILE: internal/repository/settings.go ================================================ package repository import ( "subtrackr/internal/models" "time" "gorm.io/gorm" ) type SettingsRepository struct { db *gorm.DB } func NewSettingsRepository(db *gorm.DB) *SettingsRepository { return &SettingsRepository{db: db} } // Set stores or updates a setting func (r *SettingsRepository) Set(key, value string) error { var setting models.Settings // Try to find existing setting err := r.db.Where("key = ?", key).First(&setting).Error if err == gorm.ErrRecordNotFound { // Create new setting setting = models.Settings{ Key: key, Value: value, } return r.db.Create(&setting).Error } else if err != nil { return err } // Update existing setting setting.Value = value return r.db.Save(&setting).Error } // Get retrieves a setting value func (r *SettingsRepository) Get(key string) (string, error) { var setting models.Settings err := r.db.Where("key = ?", key).First(&setting).Error if err != nil { return "", err } return setting.Value, nil } // Delete removes a setting func (r *SettingsRepository) Delete(key string) error { return r.db.Where("key = ?", key).Delete(&models.Settings{}).Error } // GetAll retrieves all settings func (r *SettingsRepository) GetAll() ([]models.Settings, error) { var settings []models.Settings err := r.db.Find(&settings).Error return settings, err } // CreateAPIKey creates a new API key func (r *SettingsRepository) CreateAPIKey(apiKey *models.APIKey) (*models.APIKey, error) { if err := r.db.Create(apiKey).Error; err != nil { return nil, err } return apiKey, nil } // GetAllAPIKeys retrieves all API keys func (r *SettingsRepository) GetAllAPIKeys() ([]models.APIKey, error) { var keys []models.APIKey err := r.db.Order("created_at DESC").Find(&keys).Error return keys, err } // GetAPIKeyByKey retrieves an API key by its key value func (r *SettingsRepository) GetAPIKeyByKey(key string) (*models.APIKey, error) { var apiKey models.APIKey err := r.db.Where("key = ?", key).First(&apiKey).Error if err != nil { return nil, err } return &apiKey, nil } // DeleteAPIKey deletes an API key func (r *SettingsRepository) DeleteAPIKey(id uint) error { return r.db.Delete(&models.APIKey{}, id).Error } // UpdateAPIKeyUsage updates the usage stats for an API key func (r *SettingsRepository) UpdateAPIKeyUsage(id uint) error { now := time.Now() return r.db.Model(&models.APIKey{}).Where("id = ?", id).Updates(map[string]interface{}{ "last_used": now, "usage_count": gorm.Expr("usage_count + ?", 1), }).Error } ================================================ FILE: internal/repository/subscription.go ================================================ package repository import ( "strings" "subtrackr/internal/models" "time" "gorm.io/gorm" ) type SubscriptionRepository struct { db *gorm.DB hasLegacyColumn *bool } func NewSubscriptionRepository(db *gorm.DB) *SubscriptionRepository { return &SubscriptionRepository{db: db} } func (r *SubscriptionRepository) checkLegacyColumn() bool { if r.hasLegacyColumn != nil { return *r.hasLegacyColumn } var exists bool r.db.Raw("SELECT COUNT(*) > 0 FROM pragma_table_info('subscriptions') WHERE name='category'").Scan(&exists) r.hasLegacyColumn = &exists return exists } func (r *SubscriptionRepository) Create(subscription *models.Subscription) (*models.Subscription, error) { // Check if the old category column exists (for legacy schema support) columnExists := r.checkLegacyColumn() if columnExists && subscription.CategoryID > 0 { // For legacy schema, we need to populate the old category column var category models.Category if err := r.db.First(&category, subscription.CategoryID).Error; err == nil { // Use transaction for thread safety err := r.db.Transaction(func(tx *gorm.DB) error { result := tx.Exec(` INSERT INTO subscriptions ( name, cost, schedule, schedule_interval, status, category_id, category, original_currency, payment_method, account, start_date, renewal_date, cancellation_date, url, icon_url, notes, usage, reminder_enabled, date_calculation_version, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, subscription.Name, subscription.Cost, subscription.Schedule, subscription.ScheduleInterval, subscription.Status, subscription.CategoryID, category.Name, subscription.OriginalCurrency, subscription.PaymentMethod, subscription.Account, subscription.StartDate, subscription.RenewalDate, subscription.CancellationDate, subscription.URL, subscription.IconURL, subscription.Notes, subscription.Usage, subscription.ReminderEnabled, subscription.DateCalculationVersion, time.Now(), time.Now()) if result.Error != nil { return result.Error } // Get the last inserted ID within the transaction var lastID int64 if err := tx.Raw("SELECT last_insert_rowid()").Scan(&lastID).Error; err != nil { return err } subscription.ID = uint(lastID) return nil }) if err != nil { return nil, err } return subscription, nil } } // Normal creation for migrated schema if err := r.db.Create(subscription).Error; err != nil { return nil, err } return subscription, nil } func (r *SubscriptionRepository) GetAll() ([]models.Subscription, error) { var subscriptions []models.Subscription if err := r.db.Preload("Category").Order("created_at DESC").Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil } // GetAllSorted returns all subscriptions sorted by the specified column and order // sortBy: name, cost, status, renewal_date, schedule, category, created_at // order: asc, desc func (r *SubscriptionRepository) GetAllSorted(sortBy, order string) ([]models.Subscription, error) { var subscriptions []models.Subscription query := r.db.Preload("Category") // Validate and set sort column validSortColumns := map[string]string{ "name": "name", "cost": "cost", "status": "status", "renewal_date": "renewal_date", "schedule": "schedule", "category": "categories.name", "created_at": "created_at", } sortColumn, ok := validSortColumns[sortBy] if !ok { sortColumn = "created_at" // default } // Validate order if order != "asc" && order != "desc" { order = "desc" // default } // Build order clause orderClause := sortColumn + " " + strings.ToUpper(order) // Special handling for category (requires join) if sortBy == "category" { query = query.Joins("LEFT JOIN categories ON subscriptions.category_id = categories.id") } if err := query.Order(orderClause).Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil } func (r *SubscriptionRepository) GetByID(id uint) (*models.Subscription, error) { var subscription models.Subscription if err := r.db.Preload("Category").First(&subscription, id).Error; err != nil { return nil, err } return &subscription, nil } func (r *SubscriptionRepository) Update(id uint, subscription *models.Subscription) (*models.Subscription, error) { // First, get the existing subscription var existing models.Subscription if err := r.db.First(&existing, id).Error; err != nil { return nil, err } // Check if the old category column exists columnExists := r.checkLegacyColumn() // Update the existing subscription with new values existing.Name = subscription.Name existing.Cost = subscription.Cost existing.Schedule = subscription.Schedule existing.ScheduleInterval = subscription.ScheduleInterval existing.Status = subscription.Status existing.CategoryID = subscription.CategoryID existing.OriginalCurrency = subscription.OriginalCurrency existing.PaymentMethod = subscription.PaymentMethod existing.Account = subscription.Account existing.StartDate = subscription.StartDate existing.LastReminderSent = subscription.LastReminderSent existing.LastReminderRenewalDate = subscription.LastReminderRenewalDate existing.LastCancellationReminderSent = subscription.LastCancellationReminderSent existing.LastCancellationReminderDate = subscription.LastCancellationReminderDate existing.RenewalDate = subscription.RenewalDate existing.CancellationDate = subscription.CancellationDate existing.URL = subscription.URL existing.IconURL = subscription.IconURL existing.Notes = subscription.Notes existing.Usage = subscription.Usage existing.ReminderEnabled = subscription.ReminderEnabled if columnExists && subscription.CategoryID > 0 { // For legacy schema, we need to update the old category column too var category models.Category if err := r.db.First(&category, subscription.CategoryID).Error; err == nil { // We need to manually set the category name for legacy schema updates := map[string]interface{}{ "name": existing.Name, "cost": existing.Cost, "schedule": existing.Schedule, "schedule_interval": existing.ScheduleInterval, "status": existing.Status, "category_id": existing.CategoryID, "category": category.Name, "original_currency": existing.OriginalCurrency, "payment_method": existing.PaymentMethod, "account": existing.Account, "start_date": existing.StartDate, "renewal_date": existing.RenewalDate, "cancellation_date": existing.CancellationDate, "url": existing.URL, "icon_url": existing.IconURL, "notes": existing.Notes, "usage": existing.Usage, "last_reminder_sent": existing.LastReminderSent, "last_reminder_renewal_date": existing.LastReminderRenewalDate, "reminder_enabled": existing.ReminderEnabled, "last_cancellation_reminder_sent": existing.LastCancellationReminderSent, "last_cancellation_reminder_date": existing.LastCancellationReminderDate, "updated_at": time.Now(), } if err := r.db.Model(&existing).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return r.GetByID(id) } } // The existing record already has the correct ID from the First() query above // Use Save which will update only the record with matching primary key // This also properly triggers the BeforeUpdate hook if err := r.db.Save(&existing).Error; err != nil { return nil, err } // Reload to get any changes from hooks return r.GetByID(id) } func (r *SubscriptionRepository) Delete(id uint) error { return r.db.Delete(&models.Subscription{}, id).Error } func (r *SubscriptionRepository) Count() int64 { var count int64 r.db.Model(&models.Subscription{}).Count(&count) return count } func (r *SubscriptionRepository) GetActiveSubscriptions() ([]models.Subscription, error) { var subscriptions []models.Subscription if err := r.db.Preload("Category").Where("status = ?", "Active").Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil } func (r *SubscriptionRepository) GetCancelledSubscriptions() ([]models.Subscription, error) { var subscriptions []models.Subscription if err := r.db.Preload("Category").Where("status = ?", "Cancelled").Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil } func (r *SubscriptionRepository) GetUpcomingRenewals(days int) ([]models.Subscription, error) { var subscriptions []models.Subscription endDate := time.Now().AddDate(0, 0, days) if err := r.db.Where("status = ? AND renewal_date IS NOT NULL AND renewal_date BETWEEN ? AND ?", "Active", time.Now(), endDate).Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil } func (r *SubscriptionRepository) GetUpcomingCancellations(days int) ([]models.Subscription, error) { var subscriptions []models.Subscription endDate := time.Now().AddDate(0, 0, days) if err := r.db.Where("status = ? AND cancellation_date IS NOT NULL AND cancellation_date BETWEEN ? AND ?", "Cancelled", time.Now(), endDate).Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil } func (r *SubscriptionRepository) GetCategoryStats() ([]models.CategoryStat, error) { var stats []models.CategoryStat if err := r.db.Table("subscriptions"). Select("categories.name as category, SUM(CASE WHEN subscriptions.schedule = 'Annual' THEN subscriptions.cost/12 WHEN subscriptions.schedule = 'Quarterly' THEN subscriptions.cost/3 WHEN subscriptions.schedule = 'Monthly' THEN subscriptions.cost WHEN subscriptions.schedule = 'Weekly' THEN subscriptions.cost*4.33 WHEN subscriptions.schedule = 'Daily' THEN subscriptions.cost*30.44 ELSE subscriptions.cost END) as amount, COUNT(*) as count"). Joins("left join categories on subscriptions.category_id = categories.id"). Where("subscriptions.status = ?", "Active"). Group("categories.name"). Scan(&stats).Error; err != nil { return nil, err } return stats, nil } ================================================ FILE: internal/service/category.go ================================================ package service import ( "errors" "subtrackr/internal/models" "subtrackr/internal/repository" ) // CategoryService provides business logic for categories type CategoryService struct { repo *repository.CategoryRepository } func NewCategoryService(repo *repository.CategoryRepository) *CategoryService { return &CategoryService{repo: repo} } func (s *CategoryService) Create(category *models.Category) (*models.Category, error) { return s.repo.Create(category) } func (s *CategoryService) GetAll() ([]models.Category, error) { return s.repo.GetAll() } func (s *CategoryService) GetByID(id uint) (*models.Category, error) { return s.repo.GetByID(id) } func (s *CategoryService) Update(id uint, category *models.Category) (*models.Category, error) { return s.repo.Update(id, category) } func (s *CategoryService) GetByName(name string) (*models.Category, error) { return s.repo.GetByName(name) } func (s *CategoryService) Delete(id uint) error { // Check if category has any subscriptions hasSubscriptions, err := s.repo.HasSubscriptions(id) if err != nil { return err } if hasSubscriptions { return errors.New("cannot delete category with active subscriptions") } return s.repo.Delete(id) } ================================================ FILE: internal/service/currency.go ================================================ package service import ( "crypto/tls" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "strings" "subtrackr/internal/models" "subtrackr/internal/repository" "time" ) // CurrencyInfo holds metadata for a supported currency type CurrencyInfo struct { Code string `json:"code"` Symbol string `json:"symbol"` Name string `json:"name"` } // BuiltinCurrencies is the comprehensive list of supported currencies var BuiltinCurrencies = []CurrencyInfo{ {Code: "USD", Symbol: "$", Name: "US Dollar"}, {Code: "EUR", Symbol: "€", Name: "Euro"}, {Code: "GBP", Symbol: "£", Name: "British Pound"}, {Code: "AUD", Symbol: "A$", Name: "Australian Dollar"}, {Code: "CAD", Symbol: "C$", Name: "Canadian Dollar"}, {Code: "NZD", Symbol: "NZ$", Name: "New Zealand Dollar"}, {Code: "JPY", Symbol: "¥", Name: "Japanese Yen"}, {Code: "CHF", Symbol: "Fr.", Name: "Swiss Franc"}, {Code: "CNY", Symbol: "¥", Name: "Chinese Yuan"}, {Code: "SEK", Symbol: "kr", Name: "Swedish Krona"}, {Code: "NOK", Symbol: "kr", Name: "Norwegian Krone"}, {Code: "DKK", Symbol: "kr", Name: "Danish Krone"}, {Code: "INR", Symbol: "₹", Name: "Indian Rupee"}, {Code: "RUB", Symbol: "₽", Name: "Russian Ruble"}, {Code: "BRL", Symbol: "R$", Name: "Brazilian Real"}, {Code: "PLN", Symbol: "zł", Name: "Polish Zloty"}, {Code: "KRW", Symbol: "₩", Name: "South Korean Won"}, {Code: "SGD", Symbol: "S$", Name: "Singapore Dollar"}, {Code: "HKD", Symbol: "HK$", Name: "Hong Kong Dollar"}, {Code: "MXN", Symbol: "Mex$", Name: "Mexican Peso"}, {Code: "ZAR", Symbol: "R", Name: "South African Rand"}, {Code: "TRY", Symbol: "₺", Name: "Turkish Lira"}, {Code: "THB", Symbol: "฿", Name: "Thai Baht"}, {Code: "COP", Symbol: "COL$", Name: "Colombian Peso"}, {Code: "BDT", Symbol: "৳", Name: "Bangladeshi Taka"}, {Code: "IDR", Symbol: "Rp", Name: "Indonesian Rupiah"}, {Code: "PHP", Symbol: "₱", Name: "Philippine Peso"}, {Code: "TWD", Symbol: "NT$", Name: "New Taiwan Dollar"}, {Code: "MYR", Symbol: "RM", Name: "Malaysian Ringgit"}, {Code: "AED", Symbol: "د.إ", Name: "UAE Dirham"}, {Code: "SAR", Symbol: "﷼", Name: "Saudi Riyal"}, {Code: "ILS", Symbol: "₪", Name: "Israeli Shekel"}, {Code: "CZK", Symbol: "Kč", Name: "Czech Koruna"}, {Code: "HUF", Symbol: "Ft", Name: "Hungarian Forint"}, {Code: "RON", Symbol: "lei", Name: "Romanian Leu"}, } // currencyInfoMap provides O(1) lookup by code var currencyInfoMap map[string]CurrencyInfo // SupportedCurrencies is derived from BuiltinCurrencies for backward compatibility var SupportedCurrencies []string func init() { currencyInfoMap = make(map[string]CurrencyInfo, len(BuiltinCurrencies)) SupportedCurrencies = make([]string, len(BuiltinCurrencies)) for i, c := range BuiltinCurrencies { currencyInfoMap[c.Code] = c SupportedCurrencies[i] = c.Code } } // GetCurrencyInfo returns metadata for a currency code, with a fallback for unknown codes func GetCurrencyInfo(code string) CurrencyInfo { if info, ok := currencyInfoMap[code]; ok { return info } return CurrencyInfo{Code: code, Symbol: code, Name: code} } // GetAvailableCurrencies returns all supported currencies func GetAvailableCurrencies() []CurrencyInfo { return BuiltinCurrencies } // supportedCurrencySymbols returns the currencies as a comma-separated string for API calls func supportedCurrencySymbols() string { return strings.Join(SupportedCurrencies, ",") } type CurrencyService struct { repo *repository.ExchangeRateRepository apiKey string } type FixerResponse struct { Success bool `json:"success"` Timestamp int64 `json:"timestamp"` Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` Error *FixerError `json:"error,omitempty"` } type FixerError struct { Code int `json:"code"` Info string `json:"info"` } func NewCurrencyService(repo *repository.ExchangeRateRepository) *CurrencyService { return &CurrencyService{ repo: repo, apiKey: os.Getenv("FIXER_API_KEY"), } } // IsEnabled returns true if currency conversion is enabled (API key is set) func (s *CurrencyService) IsEnabled() bool { return s.apiKey != "" } // GetExchangeRate retrieves exchange rate between two currencies func (s *CurrencyService) GetExchangeRate(fromCurrency, toCurrency string) (float64, error) { if fromCurrency == toCurrency { return 1.0, nil } // Try to get cached rate first rate, err := s.repo.GetRate(fromCurrency, toCurrency) if err == nil && !rate.IsStale() { return rate.Rate, nil } // If no API key, return error if !s.IsEnabled() { return 0, fmt.Errorf("currency conversion not available - no Fixer API key configured") } // Fetch from Fixer.io API return s.fetchAndCacheRates(fromCurrency, toCurrency) } // ConvertAmount converts an amount from one currency to another func (s *CurrencyService) ConvertAmount(amount float64, fromCurrency, toCurrency string) (float64, error) { rate, err := s.GetExchangeRate(fromCurrency, toCurrency) if err != nil { return 0, err } return amount * rate, nil } // fetchAndCacheRates fetches rates from Fixer.io and caches them. // Note: Free Fixer.io plan only supports EUR base, so baseCurrency parameter // is used for cross-rate calculations but API always fetches with EUR base. func (s *CurrencyService) fetchAndCacheRates(baseCurrency, targetCurrency string) (float64, error) { // Use supported currencies as comma-separated string symbols := supportedCurrencySymbols() // Free Fixer.io plan only supports EUR as base currency // Always fetch with EUR as base and calculate cross-rates if needed apiURL := fmt.Sprintf("https://data.fixer.io/api/latest?access_key=%s&base=EUR&symbols=%s", s.apiKey, symbols) // Validate URL to ensure we're calling the expected API parsedURL, err := url.Parse(apiURL) if err != nil { return 0, fmt.Errorf("invalid API URL: %w", err) } if parsedURL.Host != "data.fixer.io" { return 0, fmt.Errorf("unauthorized API host: %s", parsedURL.Host) } // Configure HTTP client with security and timeout settings client := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, // Require TLS 1.2 or higher }, }, } resp, err := client.Get(apiURL) if err != nil { return 0, fmt.Errorf("failed to fetch exchange rates: %w", err) } defer resp.Body.Close() var fixerResp FixerResponse if err := json.NewDecoder(resp.Body).Decode(&fixerResp); err != nil { return 0, fmt.Errorf("failed to decode response: %w", err) } if !fixerResp.Success { if fixerResp.Error != nil { return 0, fmt.Errorf("Fixer API error: %s", fixerResp.Error.Info) } return 0, fmt.Errorf("Fixer API request failed") } // Parse date rateDate := time.Unix(fixerResp.Timestamp, 0) // Cache all rates (always with EUR as base from Fixer.io) var ratesToSave []models.ExchangeRate // Add EUR to EUR rate (1.0) ratesToSave = append(ratesToSave, models.ExchangeRate{ BaseCurrency: "EUR", Currency: "EUR", Rate: 1.0, Date: rateDate, }) // Add all other rates from API for currency, rate := range fixerResp.Rates { ratesToSave = append(ratesToSave, models.ExchangeRate{ BaseCurrency: "EUR", Currency: currency, Rate: rate, Date: rateDate, }) } if len(ratesToSave) > 0 { if err := s.repo.SaveRates(ratesToSave); err != nil { // Log error but don't fail the request log.Printf("Warning: failed to cache exchange rates: %v", err) } } // Calculate the cross-rate if needed if baseCurrency == "EUR" { // Direct rate from EUR if rate, exists := fixerResp.Rates[targetCurrency]; exists { return rate, nil } } else if targetCurrency == "EUR" { // Inverse rate to EUR if rate, exists := fixerResp.Rates[baseCurrency]; exists && rate != 0 { return 1.0 / rate, nil } } else { // Cross-rate: base->EUR->target baseToEur, exists1 := fixerResp.Rates[baseCurrency] eurToTarget, exists2 := fixerResp.Rates[targetCurrency] if exists1 && exists2 && baseToEur != 0 { // Convert: (1/baseToEur) * eurToTarget = cross rate return eurToTarget / baseToEur, nil } } return 0, fmt.Errorf("exchange rate for %s to %s not available", baseCurrency, targetCurrency) } // RefreshRates updates all exchange rates from the API func (s *CurrencyService) RefreshRates() error { if !s.IsEnabled() { return fmt.Errorf("currency service not enabled") } // Fetch rates once with EUR base (free Fixer.io plan only supports EUR base) // All cross-rates are calculated from this single API call _, err := s.fetchAndCacheRates("EUR", "USD") if err != nil { return fmt.Errorf("failed to refresh rates: %w", err) } // Clean up old rates (keep last 7 days) return s.repo.DeleteStaleRates(7 * 24 * time.Hour) } ================================================ FILE: internal/service/currency_integration_test.go ================================================ package service import ( "os" "subtrackr/internal/models" "subtrackr/internal/repository" "testing" "time" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } // Migrate the schema err = db.AutoMigrate(&models.ExchangeRate{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } return db } func TestCurrencyService_Integration_IsEnabled(t *testing.T) { db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) tests := []struct { name string apiKey string expected bool }{ { name: "Enabled with API key", apiKey: "test-api-key", expected: true, }, { name: "Disabled without API key", apiKey: "", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set or unset the environment variable if tt.apiKey != "" { os.Setenv("FIXER_API_KEY", tt.apiKey) } else { os.Unsetenv("FIXER_API_KEY") } service := NewCurrencyService(repo) assert.Equal(t, tt.expected, service.IsEnabled()) }) } // Clean up os.Unsetenv("FIXER_API_KEY") } func TestCurrencyService_Integration_ConvertAmount_SameCurrency(t *testing.T) { db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) service := NewCurrencyService(repo) // Test same currency conversion (should return same amount) amount := 100.0 result, err := service.ConvertAmount(amount, "USD", "USD") assert.NoError(t, err) assert.Equal(t, amount, result) } func TestCurrencyService_Integration_ConvertAmount_WithCachedRate(t *testing.T) { os.Setenv("FIXER_API_KEY", "test-key") defer os.Unsetenv("FIXER_API_KEY") db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) service := NewCurrencyService(repo) // Create a cached rate cachedRate := &models.ExchangeRate{ BaseCurrency: "USD", Currency: "EUR", Rate: 0.85, Date: time.Now(), } err := repo.SaveRates([]models.ExchangeRate{*cachedRate}) assert.NoError(t, err) amount := 100.0 result, err := service.ConvertAmount(amount, "USD", "EUR") assert.NoError(t, err) assert.Equal(t, 85.0, result) } func TestCurrencyService_Integration_ConvertAmount_NoAPIKey(t *testing.T) { os.Unsetenv("FIXER_API_KEY") db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) service := NewCurrencyService(repo) amount := 100.0 result, err := service.ConvertAmount(amount, "USD", "EUR") assert.Error(t, err) assert.Equal(t, 0.0, result) assert.Contains(t, err.Error(), "currency conversion not available") } func TestCurrencyService_Integration_ConvertAmount_InvalidAmount(t *testing.T) { os.Setenv("FIXER_API_KEY", "test-key") defer os.Unsetenv("FIXER_API_KEY") db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) service := NewCurrencyService(repo) // Pre-cache a rate to avoid API calls cachedRate := models.ExchangeRate{ BaseCurrency: "USD", Currency: "EUR", Rate: 0.85, Date: time.Now(), } repo.SaveRates([]models.ExchangeRate{cachedRate}) tests := []struct { name string amount float64 expected float64 }{ {"Negative amount", -100.0, -85.0}, // Negative amounts are converted {"Zero amount", 0.0, 0.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := service.ConvertAmount(tt.amount, "USD", "EUR") assert.NoError(t, err) assert.Equal(t, tt.expected, result) }) } } func TestCurrencyService_Integration_SupportedCurrencies(t *testing.T) { db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) service := NewCurrencyService(repo) // Test that common currencies are supported supportedCurrencies := []string{ "USD", "EUR", "GBP", "CAD", "AUD", "JPY", "INR", "CHF", "SEK", "NOK", "DKK", "NZD", "SGD", "HKD", } for _, currency := range supportedCurrencies { t.Run(currency, func(t *testing.T) { // Test by attempting same-currency conversion (should always work) result, err := service.ConvertAmount(100.0, currency, currency) assert.NoError(t, err) assert.Equal(t, 100.0, result) }) } } func TestCurrencyService_Integration_BDTCurrency(t *testing.T) { db := setupTestDB(t) repo := repository.NewExchangeRateRepository(db) service := NewCurrencyService(repo) // Test BDT currency support t.Run("BDT same currency conversion", func(t *testing.T) { result, err := service.ConvertAmount(100.0, "BDT", "BDT") assert.NoError(t, err, "BDT should be supported") assert.Equal(t, 100.0, result, "Same currency conversion should return same amount") }) t.Run("BDT in SupportedCurrencies list", func(t *testing.T) { found := false for _, currency := range SupportedCurrencies { if currency == "BDT" { found = true break } } assert.True(t, found, "BDT should be in SupportedCurrencies list") }) } func TestSettingsService_GetCurrencySymbol_BDT(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) // Set currency to BDT err = settingsService.SetCurrency("BDT") assert.NoError(t, err, "Should be able to set BDT currency") // Get currency symbol symbol := settingsService.GetCurrencySymbol() assert.Equal(t, "৳", symbol, "BDT currency symbol should be ৳") // Verify currency is set correctly currency := settingsService.GetCurrency() assert.Equal(t, "BDT", currency, "Currency should be BDT") } func TestSettingsService_SetCurrency_BDT(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) tests := []struct { name string currency string shouldSucceed bool expectedSymbol string }{ { name: "Valid BDT currency", currency: "BDT", shouldSucceed: true, expectedSymbol: "৳", }, { name: "Invalid currency", currency: "XYZ", shouldSucceed: false, }, { name: "Other valid currencies", currency: "USD", shouldSucceed: true, expectedSymbol: "$", }, { name: "EUR currency", currency: "EUR", shouldSucceed: true, expectedSymbol: "€", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := settingsService.SetCurrency(tt.currency) if tt.shouldSucceed { assert.NoError(t, err, "Should succeed for valid currency") if tt.expectedSymbol != "" { symbol := settingsService.GetCurrencySymbol() assert.Equal(t, tt.expectedSymbol, symbol, "Currency symbol should match") } } else { assert.Error(t, err, "Should fail for invalid currency") assert.Contains(t, err.Error(), "invalid currency", "Error should mention invalid currency") } }) } } ================================================ FILE: internal/service/currency_test.go ================================================ package service import ( "subtrackr/internal/models" "subtrackr/internal/repository" "testing" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestGetCurrencyInfo_KnownCurrencies(t *testing.T) { tests := []struct { code string expectedSymbol string expectedName string }{ {"USD", "$", "US Dollar"}, {"EUR", "€", "Euro"}, {"GBP", "£", "British Pound"}, {"JPY", "¥", "Japanese Yen"}, {"INR", "₹", "Indian Rupee"}, {"BRL", "R$", "Brazilian Real"}, {"COP", "COL$", "Colombian Peso"}, {"BDT", "৳", "Bangladeshi Taka"}, {"AED", "د.إ", "UAE Dirham"}, {"CZK", "Kč", "Czech Koruna"}, } for _, tt := range tests { t.Run(tt.code, func(t *testing.T) { info := GetCurrencyInfo(tt.code) assert.Equal(t, tt.code, info.Code) assert.Equal(t, tt.expectedSymbol, info.Symbol) assert.Equal(t, tt.expectedName, info.Name) }) } } func TestGetCurrencyInfo_UnknownCurrency(t *testing.T) { info := GetCurrencyInfo("XYZ") assert.Equal(t, "XYZ", info.Code) assert.Equal(t, "XYZ", info.Symbol, "Unknown currency should use code as symbol") assert.Equal(t, "XYZ", info.Name, "Unknown currency should use code as name") } func TestGetCurrencyInfo_EmptyCode(t *testing.T) { info := GetCurrencyInfo("") assert.Equal(t, "", info.Code) assert.Equal(t, "", info.Symbol) assert.Equal(t, "", info.Name) } func TestGetAvailableCurrencies(t *testing.T) { currencies := GetAvailableCurrencies() assert.Equal(t, len(BuiltinCurrencies), len(currencies)) assert.True(t, len(currencies) >= 35, "Should have at least 35 currencies") // Verify first and last entries match assert.Equal(t, "USD", currencies[0].Code) assert.Equal(t, "RON", currencies[len(currencies)-1].Code) } func TestSupportedCurrencies_DerivedFromBuiltin(t *testing.T) { assert.Equal(t, len(BuiltinCurrencies), len(SupportedCurrencies)) for i, info := range BuiltinCurrencies { assert.Equal(t, info.Code, SupportedCurrencies[i], "SupportedCurrencies should match BuiltinCurrencies order") } } func TestCurrencyInfoMap_AllEntriesPresent(t *testing.T) { for _, info := range BuiltinCurrencies { mapped, ok := currencyInfoMap[info.Code] assert.True(t, ok, "Currency %s should be in currencyInfoMap", info.Code) assert.Equal(t, info, mapped) } } func TestCurrencySymbolForCode(t *testing.T) { tests := []struct { code string expected string }{ {"USD", "$"}, {"EUR", "€"}, {"GBP", "£"}, {"COP", "COL$"}, {"UNKNOWN", "UNKNOWN"}, } for _, tt := range tests { t.Run(tt.code, func(t *testing.T) { assert.Equal(t, tt.expected, CurrencySymbolForCode(tt.code)) }) } } func TestCurrencySymbolForSubscription(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) settingsService.SetCurrency("USD") tests := []struct { name string originalCurrency string expectedSymbol string }{ { name: "Same as preferred currency", originalCurrency: "USD", expectedSymbol: "$", }, { name: "Different from preferred currency", originalCurrency: "EUR", expectedSymbol: "€", }, { name: "Empty original currency uses preferred", originalCurrency: "", expectedSymbol: "$", }, { name: "COP shows COL$ symbol", originalCurrency: "COP", expectedSymbol: "COL$", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sub := &models.Subscription{ Name: "Test", OriginalCurrency: tt.originalCurrency, } symbol := currencySymbolForSubscription(sub, settingsService) assert.Equal(t, tt.expectedSymbol, symbol) }) } } ================================================ FILE: internal/service/email.go ================================================ package service import ( "bytes" "crypto/tls" "fmt" "html/template" "net/smtp" "subtrackr/internal/models" ) // currencySymbolForSubscription returns the appropriate currency symbol for a subscription. // If the subscription has an original currency that differs from the preferred currency, // use the subscription's own currency symbol to avoid misleading display. func currencySymbolForSubscription(subscription *models.Subscription, settings *SettingsService) string { preferred := settings.GetCurrency() if subscription.OriginalCurrency != "" && subscription.OriginalCurrency != preferred { return CurrencySymbolForCode(subscription.OriginalCurrency) } return settings.GetCurrencySymbol() } // EmailService handles sending emails via SMTP type EmailService struct { settingsService *SettingsService } // NewEmailService creates a new email service func NewEmailService(settingsService *SettingsService) *EmailService { return &EmailService{ settingsService: settingsService, } } // SendEmail sends an email using the configured SMTP settings func (e *EmailService) SendEmail(subject, body string) error { config, err := e.settingsService.GetSMTPConfig() if err != nil { return fmt.Errorf("failed to get SMTP config: %w", err) } if config.To == "" { return fmt.Errorf("no recipient email configured") } // Determine if this is an implicit TLS port (SMTPS) isSSLPort := config.Port == 465 || config.Port == 8465 || config.Port == 443 var auth smtp.Auth var addr string auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) addr = fmt.Sprintf("%s:%d", config.Host, config.Port) if isSSLPort { // Use implicit TLS (direct SSL connection) tlsConfig := &tls.Config{ ServerName: config.Host, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("failed to connect via SSL: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, config.Host) if err != nil { return fmt.Errorf("failed to create SMTP client: %w", err) } defer client.Close() // Authenticate if err = client.Auth(auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } // Set sender and recipient if err = client.Mail(config.From); err != nil { return fmt.Errorf("failed to set sender: %w", err) } if err = client.Rcpt(config.To); err != nil { return fmt.Errorf("failed to set recipient: %w", err) } // Send email body writer, err := client.Data() if err != nil { return fmt.Errorf("failed to get data writer: %w", err) } fromName := config.FromName if fromName == "" { fromName = "SubTrackr" } message := fmt.Sprintf("From: %s <%s>\r\n", fromName, config.From) message += fmt.Sprintf("To: %s\r\n", config.To) message += fmt.Sprintf("Subject: %s\r\n", subject) message += "MIME-Version: 1.0\r\n" message += "Content-Type: text/html; charset=UTF-8\r\n" message += "\r\n" message += body _, err = writer.Write([]byte(message)) if err != nil { return fmt.Errorf("failed to write message: %w", err) } err = writer.Close() if err != nil { return fmt.Errorf("failed to close writer: %w", err) } } else { // Use STARTTLS (opportunistic TLS) client, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("failed to connect: %w", err) } defer client.Close() // Upgrade to TLS tlsConfig := &tls.Config{ ServerName: config.Host, } if err = client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("failed to start TLS: %w", err) } // Authenticate if err = client.Auth(auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } // Set sender and recipient if err = client.Mail(config.From); err != nil { return fmt.Errorf("failed to set sender: %w", err) } if err = client.Rcpt(config.To); err != nil { return fmt.Errorf("failed to set recipient: %w", err) } // Send email body writer, err := client.Data() if err != nil { return fmt.Errorf("failed to get data writer: %w", err) } fromName := config.FromName if fromName == "" { fromName = "SubTrackr" } message := fmt.Sprintf("From: %s <%s>\r\n", fromName, config.From) message += fmt.Sprintf("To: %s\r\n", config.To) message += fmt.Sprintf("Subject: %s\r\n", subject) message += "MIME-Version: 1.0\r\n" message += "Content-Type: text/html; charset=UTF-8\r\n" message += "\r\n" message += body _, err = writer.Write([]byte(message)) if err != nil { return fmt.Errorf("failed to write message: %w", err) } err = writer.Close() if err != nil { return fmt.Errorf("failed to close writer: %w", err) } } return nil } // SendHighCostAlert sends an email alert when a high-cost subscription is created func (e *EmailService) SendHighCostAlert(subscription *models.Subscription) error { // Check if high cost alerts are enabled enabled, err := e.settingsService.GetBoolSetting("high_cost_alerts", true) if err != nil || !enabled { return nil // Silently skip if disabled } // Get currency symbol - use subscription's own currency if it differs from preferred currencySymbol := currencySymbolForSubscription(subscription, e.settingsService) // Build email body tmpl := `

High Cost Subscription Alert

⚠️ Alert: A new high-cost subscription has been added to your SubTrackr account.

Subscription Details

Name: {{.Subscription.Name}}
Cost: {{.CurrencySymbol}}{{printf "%.2f" .Subscription.Cost}} {{.Subscription.DisplaySchedule}}
Monthly Cost: {{.CurrencySymbol}}{{printf "%.2f" (.Subscription.MonthlyCost)}}
{{if and .Subscription.Category .Subscription.Category.Name}}
Category: {{.Subscription.Category.Name}}
{{end}} {{if .FormattedRenewalDate}}
Next Renewal: {{.FormattedRenewalDate}}
{{end}} {{if .Subscription.URL}}{{end}}
` type AlertData struct { Subscription *models.Subscription CurrencySymbol string FormattedRenewalDate string } var formattedRenewal string if subscription.RenewalDate != nil { formattedRenewal = subscription.RenewalDate.Format(e.settingsService.GetGoDateFormatLong()) } data := AlertData{ Subscription: subscription, CurrencySymbol: currencySymbol, FormattedRenewalDate: formattedRenewal, } t, err := template.New("highCostAlert").Parse(tmpl) if err != nil { return fmt.Errorf("failed to parse email template: %w", err) } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return fmt.Errorf("failed to execute email template: %w", err) } subject := fmt.Sprintf("High Cost Alert: %s - %s%.2f/month", subscription.Name, currencySymbol, subscription.MonthlyCost()) return e.SendEmail(subject, buf.String()) } // SendRenewalReminder sends an email reminder for an upcoming subscription renewal func (e *EmailService) SendRenewalReminder(subscription *models.Subscription, daysUntilRenewal int) error { // Check if renewal reminders are enabled enabled, err := e.settingsService.GetBoolSetting("renewal_reminders", false) if err != nil || !enabled { return nil // Silently skip if disabled } // Get currency symbol - use subscription's own currency if it differs from preferred currencySymbol := currencySymbolForSubscription(subscription, e.settingsService) // Build email body tmpl := `

Subscription Renewal Reminder

🔔 Reminder: Your subscription {{.Subscription.Name}} will renew in {{.DaysUntilRenewal}} {{if eq .DaysUntilRenewal 1}}day{{else}}days{{end}}.

Subscription Details

Name: {{.Subscription.Name}}
Cost: {{.CurrencySymbol}}{{printf "%.2f" .Subscription.Cost}} {{.Subscription.DisplaySchedule}}
Monthly Cost: {{.CurrencySymbol}}{{printf "%.2f" (.Subscription.MonthlyCost)}}
{{if and .Subscription.Category .Subscription.Category.Name}}
Category: {{.Subscription.Category.Name}}
{{end}} {{if .FormattedRenewalDate}}
Renewal Date: {{.FormattedRenewalDate}}
{{end}} {{if .Subscription.URL}}{{end}}
` type ReminderData struct { Subscription *models.Subscription DaysUntilRenewal int CurrencySymbol string FormattedRenewalDate string } var formattedRenewal string if subscription.RenewalDate != nil { formattedRenewal = subscription.RenewalDate.Format(e.settingsService.GetGoDateFormatLong()) } data := ReminderData{ Subscription: subscription, DaysUntilRenewal: daysUntilRenewal, CurrencySymbol: currencySymbol, FormattedRenewalDate: formattedRenewal, } t, err := template.New("renewalReminder").Parse(tmpl) if err != nil { return fmt.Errorf("failed to parse email template: %w", err) } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return fmt.Errorf("failed to execute email template: %w", err) } daysText := "days" if daysUntilRenewal == 1 { daysText = "day" } subject := fmt.Sprintf("Renewal Reminder: %s renews in %d %s", subscription.Name, daysUntilRenewal, daysText) return e.SendEmail(subject, buf.String()) } // SendCancellationReminder sends an email reminder for an upcoming subscription cancellation func (e *EmailService) SendCancellationReminder(subscription *models.Subscription, daysUntilCancellation int) error { // Check if cancellation reminders are enabled enabled, err := e.settingsService.GetBoolSetting("cancellation_reminders", false) if err != nil || !enabled { return nil // Silently skip if disabled } // Get currency symbol - use subscription's own currency if it differs from preferred currencySymbol := currencySymbolForSubscription(subscription, e.settingsService) // Build email body tmpl := `

Subscription Cancellation Reminder

⚠️ Reminder: Your subscription {{.Subscription.Name}} will end in {{.DaysUntilCancellation}} {{if eq .DaysUntilCancellation 1}}day{{else}}days{{end}}.

Subscription Details

Name: {{.Subscription.Name}}
Cost: {{.CurrencySymbol}}{{printf "%.2f" .Subscription.Cost}} {{.Subscription.DisplaySchedule}}
Monthly Cost: {{.CurrencySymbol}}{{printf "%.2f" (.Subscription.MonthlyCost)}}
{{if and .Subscription.Category .Subscription.Category.Name}}
Category: {{.Subscription.Category.Name}}
{{end}} {{if .FormattedCancellationDate}}
Cancellation Date: {{.FormattedCancellationDate}}
{{end}} {{if .Subscription.URL}}{{end}}
` type CancellationReminderData struct { Subscription *models.Subscription DaysUntilCancellation int CurrencySymbol string FormattedCancellationDate string } var formattedCancellation string if subscription.CancellationDate != nil { formattedCancellation = subscription.CancellationDate.Format(e.settingsService.GetGoDateFormatLong()) } data := CancellationReminderData{ Subscription: subscription, DaysUntilCancellation: daysUntilCancellation, CurrencySymbol: currencySymbol, FormattedCancellationDate: formattedCancellation, } t, err := template.New("cancellationReminder").Parse(tmpl) if err != nil { return fmt.Errorf("failed to parse email template: %w", err) } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return fmt.Errorf("failed to execute email template: %w", err) } daysText := "days" if daysUntilCancellation == 1 { daysText = "day" } subject := fmt.Sprintf("Cancellation Reminder: %s ends in %d %s", subscription.Name, daysUntilCancellation, daysText) return e.SendEmail(subject, buf.String()) } ================================================ FILE: internal/service/logo.go ================================================ package service import ( "fmt" "io" "net/http" "net/url" "strings" "time" ) // LogoService handles fetching logos/icons for subscriptions type LogoService struct { httpClient *http.Client } // NewLogoService creates a new logo service func NewLogoService() *LogoService { return &LogoService{ httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // FetchLogoFromURL extracts the domain from a website URL and returns a favicon URL // Uses Google's favicon service as the primary source func (s *LogoService) FetchLogoFromURL(websiteURL string) (string, error) { if websiteURL == "" { return "", fmt.Errorf("empty URL provided") } // Normalize URL - add https:// if no protocol is specified normalizedURL := strings.TrimSpace(websiteURL) if !strings.HasPrefix(normalizedURL, "http://") && !strings.HasPrefix(normalizedURL, "https://") { normalizedURL = "https://" + normalizedURL } // Parse the URL to extract domain parsedURL, err := url.Parse(normalizedURL) if err != nil { return "", fmt.Errorf("invalid URL: %w", err) } // Get the domain (hostname without port) domain := parsedURL.Hostname() if domain == "" { // If hostname is empty, try using the path as domain (for cases like "netflix.com") if parsedURL.Path != "" { domain = strings.TrimPrefix(parsedURL.Path, "/") } else { return "", fmt.Errorf("could not extract domain from URL") } } // Remove www. prefix for cleaner lookups domain = strings.TrimPrefix(domain, "www.") // Remove trailing slashes domain = strings.TrimSuffix(domain, "/") // Try Google's favicon service first (most reliable) faviconURL := fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", url.QueryEscape(domain)) return faviconURL, nil } // GetLogoURL returns the logo URL for a subscription // Returns the stored IconURL if available, otherwise tries to fetch from URL func (s *LogoService) GetLogoURL(iconURL, websiteURL string) string { // If icon URL is already set, return it if iconURL != "" { return iconURL } // If no website URL, return empty if websiteURL == "" { return "" } // Try to fetch logo from website URL fetchedURL, err := s.FetchLogoFromURL(websiteURL) if err != nil { return "" } return fetchedURL } // ValidateLogoURL checks if a logo URL is accessible func (s *LogoService) ValidateLogoURL(logoURL string) bool { if logoURL == "" { return false } resp, err := s.httpClient.Head(logoURL) if err != nil { return false } defer resp.Body.Close() // Check if response is successful (2xx) and is an image return resp.StatusCode >= 200 && resp.StatusCode < 300 } // FetchAndValidateLogo fetches a logo and validates it's accessible func (s *LogoService) FetchAndValidateLogo(websiteURL string) (string, error) { logoURL, err := s.FetchLogoFromURL(websiteURL) if err != nil { return "", err } // Validate the logo URL (check if it's accessible) if !s.ValidateLogoURL(logoURL) { // Still return the URL even if validation fails // The browser will handle broken images gracefully return logoURL, nil } return logoURL, nil } // ExtractDomain extracts the domain from a URL string // This is a helper method that reuses the domain extraction logic from FetchLogoFromURL func (s *LogoService) ExtractDomain(websiteURL string) string { if websiteURL == "" { return "" } // Normalize URL - add https:// if no protocol is specified normalizedURL := strings.TrimSpace(websiteURL) if !strings.HasPrefix(normalizedURL, "http://") && !strings.HasPrefix(normalizedURL, "https://") { normalizedURL = "https://" + normalizedURL } parsedURL, err := url.Parse(normalizedURL) if err != nil { return "" } domain := parsedURL.Hostname() if domain == "" { // If hostname is empty, try using the path as domain if parsedURL.Path != "" { domain = strings.TrimPrefix(parsedURL.Path, "/") } else { return "" } } domain = strings.TrimPrefix(domain, "www.") domain = strings.TrimSuffix(domain, "/") return domain } // DownloadLogo downloads a logo from a URL and returns the image data // This is for future use if we want to store logos locally func (s *LogoService) DownloadLogo(logoURL string) ([]byte, error) { resp, err := s.httpClient.Get(logoURL) if err != nil { return nil, fmt.Errorf("failed to download logo: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download logo: status %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read logo data: %w", err) } return data, nil } ================================================ FILE: internal/service/pushover.go ================================================ package service import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "subtrackr/internal/models" "time" ) // PushoverService handles sending notifications via Pushover type PushoverService struct { settingsService *SettingsService } // NewPushoverService creates a new Pushover service func NewPushoverService(settingsService *SettingsService) *PushoverService { return &PushoverService{ settingsService: settingsService, } } // PushoverResponse represents the response from Pushover API type PushoverResponse struct { Status int `json:"status"` Request string `json:"request"` Errors []string `json:"errors,omitempty"` } // SendNotification sends a notification via Pushover func (p *PushoverService) SendNotification(title, message string, priority int) error { config, err := p.settingsService.GetPushoverConfig() if err != nil { return fmt.Errorf("failed to get Pushover config: %w", err) } if config.UserKey == "" || config.AppToken == "" { return fmt.Errorf("Pushover not configured: user key and app token required") } // Pushover API endpoint apiURL := "https://api.pushover.net/1/messages.json" // Prepare form data formData := url.Values{} formData.Set("token", config.AppToken) formData.Set("user", config.UserKey) formData.Set("title", title) formData.Set("message", message) formData.Set("priority", strconv.Itoa(priority)) // Create HTTP request req, err := http.NewRequest("POST", apiURL, bytes.NewBufferString(formData.Encode())) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // Send request client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send Pushover notification: %w", err) } defer resp.Body.Close() // Parse response var pushoverResp PushoverResponse if err := json.NewDecoder(resp.Body).Decode(&pushoverResp); err != nil { return fmt.Errorf("failed to decode Pushover response: %w", err) } if pushoverResp.Status != 1 { errorMsg := "Pushover API error" if len(pushoverResp.Errors) > 0 { errorMsg = pushoverResp.Errors[0] } return fmt.Errorf("%s", errorMsg) } return nil } // SendHighCostAlert sends a Pushover alert when a high-cost subscription is created func (p *PushoverService) SendHighCostAlert(subscription *models.Subscription) error { // Check if high cost alerts are enabled enabled, err := p.settingsService.GetBoolSetting("high_cost_alerts", true) if err != nil || !enabled { return nil // Silently skip if disabled } // Get currency symbol - use subscription's own currency if it differs from preferred currencySymbol := currencySymbolForSubscription(subscription, p.settingsService) // Build message message := "⚠️ High Cost Alert\n\n" message += fmt.Sprintf("Subscription: %s\n", subscription.Name) message += fmt.Sprintf("Cost: %s%.2f %s\n", currencySymbol, subscription.Cost, subscription.DisplaySchedule()) message += fmt.Sprintf("Monthly Cost: %s%.2f\n", currencySymbol, subscription.MonthlyCost()) if subscription.Category.Name != "" { message += fmt.Sprintf("Category: %s\n", subscription.Category.Name) } if subscription.RenewalDate != nil { message += fmt.Sprintf("Next Renewal: %s\n", subscription.RenewalDate.Format(p.settingsService.GetGoDateFormatLong())) } if subscription.URL != "" { message += fmt.Sprintf("URL: %s", subscription.URL) } title := fmt.Sprintf("High Cost Alert: %s", subscription.Name) // Priority 1 = high priority (with sound and vibration) return p.SendNotification(title, message, 1) } // SendRenewalReminder sends a Pushover reminder for an upcoming subscription renewal func (p *PushoverService) SendRenewalReminder(subscription *models.Subscription, daysUntilRenewal int) error { // Check if renewal reminders are enabled enabled, err := p.settingsService.GetBoolSetting("renewal_reminders", false) if err != nil || !enabled { return nil // Silently skip if disabled } // Get currency symbol - use subscription's own currency if it differs from preferred currencySymbol := currencySymbolForSubscription(subscription, p.settingsService) // Build message daysText := "days" if daysUntilRenewal == 1 { daysText = "day" } message := "🔔 Renewal Reminder\n\n" message += fmt.Sprintf("Your subscription %s will renew in %d %s.\n\n", subscription.Name, daysUntilRenewal, daysText) message += "Subscription Details:\n" message += fmt.Sprintf("Cost: %s%.2f %s\n", currencySymbol, subscription.Cost, subscription.DisplaySchedule()) message += fmt.Sprintf("Monthly Cost: %s%.2f\n", currencySymbol, subscription.MonthlyCost()) if subscription.Category.Name != "" { message += fmt.Sprintf("Category: %s\n", subscription.Category.Name) } if subscription.RenewalDate != nil { message += fmt.Sprintf("Renewal Date: %s\n", subscription.RenewalDate.Format(p.settingsService.GetGoDateFormatLong())) } if subscription.URL != "" { message += fmt.Sprintf("URL: %s", subscription.URL) } title := fmt.Sprintf("Renewal Reminder: %s", subscription.Name) // Priority 0 = normal priority return p.SendNotification(title, message, 0) } // SendCancellationReminder sends a Pushover reminder for an upcoming subscription cancellation func (p *PushoverService) SendCancellationReminder(subscription *models.Subscription, daysUntilCancellation int) error { // Check if cancellation reminders are enabled enabled, err := p.settingsService.GetBoolSetting("cancellation_reminders", false) if err != nil || !enabled { return nil // Silently skip if disabled } // Get currency symbol - use subscription's own currency if it differs from preferred currencySymbol := currencySymbolForSubscription(subscription, p.settingsService) // Build message daysText := "days" if daysUntilCancellation == 1 { daysText = "day" } message := "⚠️ Cancellation Reminder\n\n" message += fmt.Sprintf("Your subscription %s will end in %d %s.\n\n", subscription.Name, daysUntilCancellation, daysText) message += "Subscription Details:\n" message += fmt.Sprintf("Cost: %s%.2f %s\n", currencySymbol, subscription.Cost, subscription.DisplaySchedule()) message += fmt.Sprintf("Monthly Cost: %s%.2f\n", currencySymbol, subscription.MonthlyCost()) if subscription.Category.Name != "" { message += fmt.Sprintf("Category: %s\n", subscription.Category.Name) } if subscription.CancellationDate != nil { message += fmt.Sprintf("Cancellation Date: %s\n", subscription.CancellationDate.Format(p.settingsService.GetGoDateFormatLong())) } if subscription.URL != "" { message += fmt.Sprintf("URL: %s", subscription.URL) } title := fmt.Sprintf("Cancellation Reminder: %s", subscription.Name) // Priority 0 = normal priority return p.SendNotification(title, message, 0) } ================================================ FILE: internal/service/pushover_test.go ================================================ package service import ( "os" "subtrackr/internal/models" "subtrackr/internal/repository" "testing" "time" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // Pushover Test Credentials Usage: // // For unit tests (default): Tests use mock credentials and will fail API calls (expected behavior) // // For integration tests: Set environment variables before running tests: // export PUSHOVER_USER_KEY="your_user_key_here" // export PUSHOVER_APP_TOKEN="your_app_token_here" // // Integration tests will automatically skip if credentials are not provided. // Example: // PUSHOVER_USER_KEY="u1234567890abcdef" PUSHOVER_APP_TOKEN="a1b2c3d4e5f6g7h8" go test ./internal/service -run Integration func setupPushoverTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } // Migrate the schema err = db.AutoMigrate( &models.Settings{}, &models.Category{}, ) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } return db } func TestPushoverService_SendNotification_NoConfig(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Try to send notification without config err := pushoverService.SendNotification("Test", "Test message", 0) assert.Error(t, err, "Should return error when Pushover is not configured") // Error will be "failed to get Pushover config: record not found" when no config exists assert.Contains(t, err.Error(), "Pushover config", "Error should mention Pushover config") } func TestPushoverService_SendNotification_EmptyUserKey(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure with empty user key (but valid app token) config := &models.PushoverConfig{ UserKey: "", // Empty user key AppToken: "test-app-token", } settingsService.SavePushoverConfig(config) err := pushoverService.SendNotification("Test", "Test message", 0) assert.Error(t, err, "Should return error when User Key is empty") assert.Contains(t, err.Error(), "not configured", "Error should mention not configured") } func TestPushoverService_SendNotification_EmptyAppToken(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure with empty app token config := &models.PushoverConfig{ UserKey: "test-user-key", AppToken: "", } settingsService.SavePushoverConfig(config) err := pushoverService.SendNotification("Test", "Test message", 0) assert.Error(t, err, "Should return error when App Token is empty") assert.Contains(t, err.Error(), "not configured", "Error should mention not configured") } func TestPushoverService_SendHighCostAlert_Disabled(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Ensure high cost alerts are disabled settingsService.SetBoolSetting("high_cost_alerts", false) subscription := &models.Subscription{ Name: "Test Subscription", Cost: 100.00, Schedule: "Monthly", Status: "Active", Category: models.Category{Name: "Test"}, } // Should return nil without error when disabled err := pushoverService.SendHighCostAlert(subscription) assert.NoError(t, err, "Should return nil when high cost alerts are disabled") } func TestPushoverService_SendHighCostAlert_EnabledButNoConfig(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Enable high cost alerts but don't configure Pushover settingsService.SetBoolSetting("high_cost_alerts", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Test Subscription", Cost: 100.00, Schedule: "Monthly", Status: "Active", Category: models.Category{Name: "Test"}, } // Should return error when Pushover is not configured err := pushoverService.SendHighCostAlert(subscription) assert.Error(t, err, "Should return error when Pushover is not configured") } func TestPushoverService_SendRenewalReminder_Disabled(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Ensure renewal reminders are disabled settingsService.SetBoolSetting("renewal_reminders", false) subscription := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Test"}, } // Should return nil without error when disabled err := pushoverService.SendRenewalReminder(subscription, 3) assert.NoError(t, err, "Should return nil when renewal reminders are disabled") } func TestPushoverService_SendRenewalReminder_EnabledButNoConfig(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Enable renewal reminders but don't configure Pushover settingsService.SetBoolSetting("renewal_reminders", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Test"}, } // Should return error when Pushover is not configured err := pushoverService.SendRenewalReminder(subscription, 3) assert.Error(t, err, "Should return error when Pushover is not configured") } func TestPushoverService_SendHighCostAlert_MessageFormat(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure Pushover with invalid credentials (we're testing message format, not actual sending) config := &models.PushoverConfig{ UserKey: "test-user-key", AppToken: "test-app-token", } settingsService.SavePushoverConfig(config) settingsService.SetBoolSetting("high_cost_alerts", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Netflix", Cost: 15.99, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 30)), Category: models.Category{Name: "Entertainment"}, URL: "https://netflix.com", } // This will fail because we don't have real Pushover credentials, but it should attempt to send err := pushoverService.SendHighCostAlert(subscription) // We expect an error because we can't actually connect to Pushover API, but the function should attempt to send assert.Error(t, err, "Should return error when Pushover API call fails (expected in test)") // The error should be about API call, not about being disabled assert.NotContains(t, err.Error(), "disabled", "Error should not be about being disabled") } func TestPushoverService_SendRenewalReminder_MessageFormat(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure Pushover with invalid credentials (we're testing message format, not actual sending) config := &models.PushoverConfig{ UserKey: "test-user-key", AppToken: "test-app-token", } settingsService.SavePushoverConfig(config) settingsService.SetBoolSetting("renewal_reminders", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Netflix", Cost: 15.99, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Entertainment"}, URL: "https://netflix.com", } // This will fail because we don't have real Pushover credentials, but it should attempt to send err := pushoverService.SendRenewalReminder(subscription, 3) // We expect an error because we can't actually connect to Pushover API, but the function should attempt to send assert.Error(t, err, "Should return error when Pushover API call fails (expected in test)") // The error should be about API call, not about being disabled assert.NotContains(t, err.Error(), "disabled", "Error should not be about being disabled") } func TestPushoverService_SendRenewalReminder_DaysText(t *testing.T) { db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure Pushover config := &models.PushoverConfig{ UserKey: "test-user-key", AppToken: "test-app-token", } settingsService.SavePushoverConfig(config) settingsService.SetBoolSetting("renewal_reminders", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 1)), Category: models.Category{Name: "Test"}, } tests := []struct { name string daysUntil int expectedDaysText string }{ { name: "Singular day", daysUntil: 1, expectedDaysText: "day", }, { name: "Plural days", daysUntil: 3, expectedDaysText: "days", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // This will fail because we don't have real Pushover credentials err := pushoverService.SendRenewalReminder(subscription, tt.daysUntil) assert.Error(t, err, "Should return error when Pushover API call fails (expected in test)") // Verify the function attempted to send (not disabled) assert.NotContains(t, err.Error(), "disabled", "Error should not be about being disabled") }) } } // getPushoverTestCredentials retrieves Pushover credentials from environment variables for integration testing // Returns empty strings if not set (for unit tests) func getPushoverTestCredentials() (userKey, appToken string) { userKey = os.Getenv("PUSHOVER_USER_KEY") appToken = os.Getenv("PUSHOVER_APP_TOKEN") return userKey, appToken } // TestPushoverService_SendNotification_Integration tests sending a real notification if credentials are provided // This is an optional integration test that only runs if PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN are set func TestPushoverService_SendNotification_Integration(t *testing.T) { userKey, appToken := getPushoverTestCredentials() if userKey == "" || appToken == "" { t.Skip("Skipping integration test: PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN environment variables not set") } db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure Pushover with real credentials from environment config := &models.PushoverConfig{ UserKey: userKey, AppToken: appToken, } err := settingsService.SavePushoverConfig(config) assert.NoError(t, err, "Should save Pushover config") // Send a test notification err = pushoverService.SendNotification("SubTrackr Test", "This is a test notification from SubTrackr integration tests", 0) assert.NoError(t, err, "Should successfully send notification with valid credentials") } // TestPushoverService_SendHighCostAlert_Integration tests sending a real high cost alert if credentials are provided func TestPushoverService_SendHighCostAlert_Integration(t *testing.T) { userKey, appToken := getPushoverTestCredentials() if userKey == "" || appToken == "" { t.Skip("Skipping integration test: PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN environment variables not set") } db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure Pushover with real credentials config := &models.PushoverConfig{ UserKey: userKey, AppToken: appToken, } settingsService.SavePushoverConfig(config) settingsService.SetBoolSetting("high_cost_alerts", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Test High Cost Subscription", Cost: 100.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 30)), Category: models.Category{Name: "Test"}, URL: "https://example.com", } err := pushoverService.SendHighCostAlert(subscription) assert.NoError(t, err, "Should successfully send high cost alert with valid credentials") } // TestPushoverService_SendRenewalReminder_Integration tests sending a real renewal reminder if credentials are provided func TestPushoverService_SendRenewalReminder_Integration(t *testing.T) { userKey, appToken := getPushoverTestCredentials() if userKey == "" || appToken == "" { t.Skip("Skipping integration test: PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN environment variables not set") } db := setupPushoverTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) pushoverService := NewPushoverService(settingsService) // Configure Pushover with real credentials config := &models.PushoverConfig{ UserKey: userKey, AppToken: appToken, } settingsService.SavePushoverConfig(config) settingsService.SetBoolSetting("renewal_reminders", true) settingsService.SetCurrency("USD") subscription := &models.Subscription{ Name: "Test Subscription", Cost: 15.99, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Test"}, URL: "https://example.com", } err := pushoverService.SendRenewalReminder(subscription, 3) assert.NoError(t, err, "Should successfully send renewal reminder with valid credentials") } ================================================ FILE: internal/service/renewal_reminder_test.go ================================================ package service import ( "subtrackr/internal/models" "subtrackr/internal/repository" "testing" "time" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupRenewalReminderTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } // Migrate the schema err = db.AutoMigrate( &models.Subscription{}, &models.Category{}, &models.Settings{}, ) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } return db } func TestSubscriptionService_GetSubscriptionsNeedingReminders(t *testing.T) { db := setupRenewalReminderTestDB(t) subscriptionRepo := repository.NewSubscriptionRepository(db) categoryRepo := repository.NewCategoryRepository(db) categoryService := NewCategoryService(categoryRepo) subscriptionService := NewSubscriptionService(subscriptionRepo, categoryService) now := time.Now() tests := []struct { name string reminderDays int subscriptions []models.Subscription expectedCount int description string }{ { name: "Subscription renewing in 3 days with 7 day reminder", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 1", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, 3)), // 3 days from now }, }, expectedCount: 1, description: "Should find subscription renewing within reminder window", }, { name: "Subscription renewing in 10 days with 7 day reminder", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 2", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, 10)), // 10 days from now }, }, expectedCount: 0, description: "Should not find subscription outside reminder window", }, { name: "Subscription renewing today", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 3", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.Add(12 * time.Hour)), // 12 hours from now }, }, expectedCount: 1, description: "Should find subscription renewing today (within 24 hours)", }, { name: "Multiple subscriptions in reminder window", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 4", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, 2)), // 2 days }, { Name: "Test Subscription 5", Cost: 20.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, 5)), // 5 days }, { Name: "Test Subscription 6", Cost: 30.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, 10)), // 10 days (outside window) }, }, expectedCount: 2, description: "Should find only subscriptions within reminder window", }, { name: "Cancelled subscription should be excluded", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 7", Cost: 10.00, Schedule: "Monthly", Status: "Cancelled", RenewalDate: timePtr(now.AddDate(0, 0, 3)), // 3 days }, }, expectedCount: 0, description: "Should exclude cancelled subscriptions", }, { name: "Subscription without renewal date should be excluded", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 8", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: nil, }, }, expectedCount: 0, description: "Should exclude subscriptions without renewal date", }, { name: "Zero reminder days should return empty", reminderDays: 0, subscriptions: []models.Subscription{ { Name: "Test Subscription 9", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, 3)), }, }, expectedCount: 0, description: "Should return empty when reminder days is 0", }, { name: "Past renewal date should be excluded", reminderDays: 7, subscriptions: []models.Subscription{ { Name: "Test Subscription 10", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(now.AddDate(0, 0, -1)), // 1 day ago }, }, expectedCount: 0, description: "Should exclude subscriptions with past renewal dates", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Clean up previous test data db.Exec("DELETE FROM subscriptions") // Create test subscriptions for _, sub := range tt.subscriptions { err := db.Create(&sub).Error assert.NoError(t, err, "Failed to create test subscription") } // Get subscriptions needing reminders result, err := subscriptionService.GetSubscriptionsNeedingReminders(tt.reminderDays) assert.NoError(t, err, "GetSubscriptionsNeedingReminders should not return error") assert.Equal(t, tt.expectedCount, len(result), tt.description) // Verify days until renewal calculation for sub, daysUntil := range result { assert.GreaterOrEqual(t, daysUntil, 0, "Days until renewal should be non-negative") assert.LessOrEqual(t, daysUntil, tt.reminderDays, "Days until renewal should be within reminder window") assert.Equal(t, "Active", sub.Status, "Subscription should be active") assert.NotNil(t, sub.RenewalDate, "Subscription should have renewal date") } }) } } func TestEmailService_SendRenewalReminder_Disabled(t *testing.T) { db := setupRenewalReminderTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) emailService := NewEmailService(settingsService) // Ensure reminders are disabled settingsService.SetBoolSetting("renewal_reminders", false) subscription := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), } // Should return nil without error when disabled err := emailService.SendRenewalReminder(subscription, 3) assert.NoError(t, err, "Should return nil when reminders are disabled") } func TestEmailService_SendRenewalReminder_EnabledButNoSMTP(t *testing.T) { db := setupRenewalReminderTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) emailService := NewEmailService(settingsService) // Enable reminders but don't configure SMTP settingsService.SetBoolSetting("renewal_reminders", true) subscription := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), } // Should return error when SMTP is not configured err := emailService.SendRenewalReminder(subscription, 3) assert.Error(t, err, "Should return error when SMTP is not configured") assert.Contains(t, err.Error(), "SMTP", "Error should mention SMTP") } func TestEmailService_SendRenewalReminder_WithSMTPConfig(t *testing.T) { db := setupRenewalReminderTestDB(t) settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) emailService := NewEmailService(settingsService) // Enable reminders settingsService.SetBoolSetting("renewal_reminders", true) // Configure SMTP (using invalid config - we're just testing the logic, not actual email sending) smtpConfig := &models.SMTPConfig{ Host: "smtp.example.com", Port: 587, Username: "test@example.com", Password: "password", From: "test@example.com", FromName: "Test", To: "recipient@example.com", } settingsService.SaveSMTPConfig(smtpConfig) subscription := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), } // This will fail because we don't have a real SMTP server, but it should get past the enabled check err := emailService.SendRenewalReminder(subscription, 3) // We expect an error because we can't actually connect to SMTP, but the function should attempt to send assert.Error(t, err, "Should return error when SMTP connection fails (expected in test)") // The error should be about connection, not about being disabled assert.NotContains(t, err.Error(), "disabled", "Error should not be about being disabled") } func TestSubscriptionService_GetSubscriptionsNeedingReminders_DaysCalculation(t *testing.T) { db := setupRenewalReminderTestDB(t) subscriptionRepo := repository.NewSubscriptionRepository(db) categoryRepo := repository.NewCategoryRepository(db) categoryService := NewCategoryService(categoryRepo) subscriptionService := NewSubscriptionService(subscriptionRepo, categoryService) now := time.Now() // Create subscription renewing in exactly 5 days renewalDate := now.AddDate(0, 0, 5) sub := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: &renewalDate, } err := db.Create(sub).Error assert.NoError(t, err) // Get subscriptions needing reminders with 7 day window result, err := subscriptionService.GetSubscriptionsNeedingReminders(7) assert.NoError(t, err) assert.Equal(t, 1, len(result), "Should find one subscription") // Check days until renewal for foundSub, daysUntil := range result { assert.Equal(t, sub.ID, foundSub.ID, "Should be the same subscription") // Days should be approximately 5 (allowing for small time differences) assert.InDelta(t, 5, daysUntil, 1, "Days until renewal should be approximately 5") } } func TestSubscriptionService_GetSubscriptionsNeedingReminders_BoundaryCases(t *testing.T) { db := setupRenewalReminderTestDB(t) subscriptionRepo := repository.NewSubscriptionRepository(db) categoryRepo := repository.NewCategoryRepository(db) categoryService := NewCategoryService(categoryRepo) subscriptionService := NewSubscriptionService(subscriptionRepo, categoryService) now := time.Now() tests := []struct { name string renewalDate time.Time reminderDays int shouldFind bool description string }{ { name: "Exactly at reminder window boundary", renewalDate: now.AddDate(0, 0, 7), // Exactly 7 days reminderDays: 7, shouldFind: true, description: "Should find subscription renewing exactly at reminder window boundary", }, { name: "Just outside reminder window", renewalDate: now.AddDate(0, 0, 8), // 8 days (outside 7 day window) reminderDays: 7, shouldFind: false, description: "Should not find subscription just outside reminder window", }, { name: "Renewing tomorrow", renewalDate: now.AddDate(0, 0, 1), // 1 day reminderDays: 7, shouldFind: true, description: "Should find subscription renewing tomorrow", }, { name: "Renewing in 1 hour (less than 1 day)", renewalDate: now.Add(1 * time.Hour), reminderDays: 7, shouldFind: true, description: "Should find subscription renewing in less than 1 day (counts as 0 days)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Clean up db.Exec("DELETE FROM subscriptions") sub := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: &tt.renewalDate, } err := db.Create(sub).Error assert.NoError(t, err) result, err := subscriptionService.GetSubscriptionsNeedingReminders(tt.reminderDays) assert.NoError(t, err) if tt.shouldFind { assert.Equal(t, 1, len(result), tt.description) } else { assert.Equal(t, 0, len(result), tt.description) } }) } } func TestSubscriptionService_GetSubscriptionsNeedingReminders_DuplicatePrevention(t *testing.T) { db := setupRenewalReminderTestDB(t) subscriptionRepo := repository.NewSubscriptionRepository(db) categoryRepo := repository.NewCategoryRepository(db) categoryService := NewCategoryService(categoryRepo) subscriptionService := NewSubscriptionService(subscriptionRepo, categoryService) now := time.Now() renewalDate := now.AddDate(0, 0, 5) // 5 days from now lastReminderDate := now.AddDate(0, 0, -1) // 1 day ago // Create subscription with reminder already sent for this renewal date sub := &models.Subscription{ Name: "Test Subscription", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: &renewalDate, LastReminderSent: &lastReminderDate, LastReminderRenewalDate: &renewalDate, // Same as current renewal date } err := db.Create(sub).Error assert.NoError(t, err) // Get subscriptions needing reminders with 7 day window result, err := subscriptionService.GetSubscriptionsNeedingReminders(7) assert.NoError(t, err) assert.Equal(t, 0, len(result), "Should not find subscription that already has reminder sent for this renewal date") // Now update the renewal date (simulating renewal date change) newRenewalDate := now.AddDate(0, 0, 10) // 10 days from now sub.RenewalDate = &newRenewalDate err = db.Save(sub).Error assert.NoError(t, err) // Should still not find it (outside reminder window) result, err = subscriptionService.GetSubscriptionsNeedingReminders(7) assert.NoError(t, err) assert.Equal(t, 0, len(result), "Should not find subscription outside reminder window") // Update to within window with different renewal date newRenewalDate2 := now.AddDate(0, 0, 3) // 3 days from now sub.RenewalDate = &newRenewalDate2 err = db.Save(sub).Error assert.NoError(t, err) // Should find it now because renewal date changed (different from LastReminderRenewalDate) result, err = subscriptionService.GetSubscriptionsNeedingReminders(7) assert.NoError(t, err) assert.Equal(t, 1, len(result), "Should find subscription when renewal date changes") } func TestSubscriptionService_GetSubscriptionsNeedingReminders_ReminderDisabled(t *testing.T) { db := setupRenewalReminderTestDB(t) subscriptionRepo := repository.NewSubscriptionRepository(db) categoryRepo := repository.NewCategoryRepository(db) categoryService := NewCategoryService(categoryRepo) subscriptionService := NewSubscriptionService(subscriptionRepo, categoryService) now := time.Now() renewalDate := now.AddDate(0, 0, 5) // Create subscription with reminders disabled sub := &models.Subscription{ Name: "No Reminders Sub", Cost: 10.00, Schedule: "Monthly", Status: "Active", RenewalDate: &renewalDate, ReminderEnabled: true, } err := db.Create(sub).Error assert.NoError(t, err) // Explicitly disable after create (GORM skips false for default:true fields) db.Model(sub).Update("reminder_enabled", false) // Should not be included in reminders result, err := subscriptionService.GetSubscriptionsNeedingReminders(7) assert.NoError(t, err) assert.Equal(t, 0, len(result), "Should not find subscription with reminders disabled") // Create subscription with reminders enabled sub2 := &models.Subscription{ Name: "With Reminders Sub", Cost: 20.00, Schedule: "Monthly", Status: "Active", RenewalDate: &renewalDate, ReminderEnabled: true, } err = db.Create(sub2).Error assert.NoError(t, err) // Should find only the enabled one result, err = subscriptionService.GetSubscriptionsNeedingReminders(7) assert.NoError(t, err) assert.Equal(t, 1, len(result), "Should only find subscription with reminders enabled") } // Helper function to create time pointer func timePtr(t time.Time) *time.Time { return &t } ================================================ FILE: internal/service/session.go ================================================ package service import ( "net/http" "github.com/gorilla/sessions" ) const ( SessionName = "subtrackr_session" SessionUserKey = "user_authenticated" SessionMaxAge = 24 * 60 * 60 // 24 hours in seconds RememberMeMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds ) type SessionService struct { store *sessions.CookieStore } // NewSessionService creates a new session service func NewSessionService(secretKey string) *SessionService { store := sessions.NewCookieStore([]byte(secretKey)) // Configure session options store.Options = &sessions.Options{ Path: "/", MaxAge: SessionMaxAge, HttpOnly: true, Secure: false, // Set to true if using HTTPS SameSite: http.SameSiteStrictMode, } return &SessionService{store: store} } // CreateSession creates a new authenticated session func (s *SessionService) CreateSession(w http.ResponseWriter, r *http.Request, rememberMe bool) error { session, err := s.store.Get(r, SessionName) if err != nil { return err } session.Values[SessionUserKey] = true // Extend session if "remember me" is checked if rememberMe { session.Options.MaxAge = RememberMeMaxAge } else { session.Options.MaxAge = SessionMaxAge } return session.Save(r, w) } // IsAuthenticated checks if the user is authenticated func (s *SessionService) IsAuthenticated(r *http.Request) bool { session, err := s.store.Get(r, SessionName) if err != nil { return false } auth, ok := session.Values[SessionUserKey].(bool) return ok && auth } // DestroySession destroys the user session func (s *SessionService) DestroySession(w http.ResponseWriter, r *http.Request) error { session, err := s.store.Get(r, SessionName) if err != nil { return err } // Mark session as expired session.Options.MaxAge = -1 delete(session.Values, SessionUserKey) return session.Save(r, w) } // RefreshSession extends the session expiration func (s *SessionService) RefreshSession(w http.ResponseWriter, r *http.Request) error { session, err := s.store.Get(r, SessionName) if err != nil { return err } // Only refresh if authenticated if auth, ok := session.Values[SessionUserKey].(bool); ok && auth { // Extend the max age currentMaxAge := session.Options.MaxAge if currentMaxAge > 0 { session.Options.MaxAge = currentMaxAge } return session.Save(r, w) } return nil } // UpdateSessionExpiry updates the session secret (useful when secret changes) func (s *SessionService) UpdateSessionExpiry(maxAge int) { s.store.Options.MaxAge = maxAge } // GetSession retrieves the current session func (s *SessionService) GetSession(r *http.Request) (*sessions.Session, error) { return s.store.Get(r, SessionName) } ================================================ FILE: internal/service/settings.go ================================================ package service import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "strconv" "subtrackr/internal/models" "subtrackr/internal/repository" "time" "golang.org/x/crypto/bcrypt" ) type SettingsService struct { repo *repository.SettingsRepository } func NewSettingsService(repo *repository.SettingsRepository) *SettingsService { return &SettingsService{repo: repo} } // SaveSMTPConfig saves SMTP configuration func (s *SettingsService) SaveSMTPConfig(config *models.SMTPConfig) error { // Convert to JSON data, err := json.Marshal(config) if err != nil { return err } return s.repo.Set("smtp_config", string(data)) } // GetSMTPConfig retrieves SMTP configuration func (s *SettingsService) GetSMTPConfig() (*models.SMTPConfig, error) { data, err := s.repo.Get("smtp_config") if err != nil { return nil, err } var config models.SMTPConfig err = json.Unmarshal([]byte(data), &config) if err != nil { return nil, err } return &config, nil } // SetBoolSetting saves a boolean setting func (s *SettingsService) SetBoolSetting(key string, value bool) error { return s.repo.Set(key, fmt.Sprintf("%t", value)) } // GetBoolSetting retrieves a boolean setting func (s *SettingsService) GetBoolSetting(key string, defaultValue bool) (bool, error) { value, err := s.repo.Get(key) if err != nil { return defaultValue, err } return value == "true", nil } // GetBoolSettingWithDefault retrieves a boolean setting with default func (s *SettingsService) GetBoolSettingWithDefault(key string, defaultValue bool) bool { value, err := s.GetBoolSetting(key, defaultValue) if err != nil { return defaultValue } return value } // SetIntSetting saves an integer setting func (s *SettingsService) SetIntSetting(key string, value int) error { return s.repo.Set(key, strconv.Itoa(value)) } // GetIntSetting retrieves an integer setting func (s *SettingsService) GetIntSetting(key string, defaultValue int) (int, error) { value, err := s.repo.Get(key) if err != nil { return defaultValue, err } intValue, err := strconv.Atoi(value) if err != nil { return defaultValue, err } return intValue, nil } // GetIntSettingWithDefault retrieves an integer setting with default func (s *SettingsService) GetIntSettingWithDefault(key string, defaultValue int) int { value, err := s.GetIntSetting(key, defaultValue) if err != nil { return defaultValue } return value } // SetFloatSetting saves a float setting func (s *SettingsService) SetFloatSetting(key string, value float64) error { return s.repo.Set(key, fmt.Sprintf("%.2f", value)) } // GetFloatSetting retrieves a float setting func (s *SettingsService) GetFloatSetting(key string, defaultValue float64) (float64, error) { value, err := s.repo.Get(key) if err != nil { return defaultValue, err } floatValue, err := strconv.ParseFloat(value, 64) if err != nil { return defaultValue, err } return floatValue, nil } // GetTheme retrieves the current theme setting func (s *SettingsService) GetTheme() (string, error) { theme, err := s.repo.Get("theme") if err != nil { return "default", err } return theme, nil } // SetTheme saves the theme preference func (s *SettingsService) SetTheme(theme string) error { return s.repo.Set("theme", theme) } // GetFloatSettingWithDefault retrieves a float setting with default func (s *SettingsService) GetFloatSettingWithDefault(key string, defaultValue float64) float64 { value, err := s.GetFloatSetting(key, defaultValue) if err != nil { return defaultValue } return value } // CreateAPIKey creates a new API key func (s *SettingsService) CreateAPIKey(name, key string) (*models.APIKey, error) { apiKey := &models.APIKey{ Name: name, Key: key, } return s.repo.CreateAPIKey(apiKey) } // GetAllAPIKeys retrieves all API keys func (s *SettingsService) GetAllAPIKeys() ([]models.APIKey, error) { return s.repo.GetAllAPIKeys() } // DeleteAPIKey deletes an API key func (s *SettingsService) DeleteAPIKey(id uint) error { return s.repo.DeleteAPIKey(id) } // ValidateAPIKey checks if an API key is valid and updates usage func (s *SettingsService) ValidateAPIKey(key string) (*models.APIKey, error) { apiKey, err := s.repo.GetAPIKeyByKey(key) if err != nil { return nil, err } // Update usage stats err = s.repo.UpdateAPIKeyUsage(apiKey.ID) if err != nil { return nil, err } return apiKey, nil } // SetCurrency saves the currency preference func (s *SettingsService) SetCurrency(currency string) error { // Validate against known currencies if _, ok := currencyInfoMap[currency]; !ok { return fmt.Errorf("invalid currency: %s", currency) } return s.repo.Set("currency", currency) } // GetCurrency retrieves the currency preference func (s *SettingsService) GetCurrency() string { currency, err := s.repo.Get("currency") if err != nil || currency == "" { return "USD" // Default to USD } return currency } // CurrencySymbolForCode returns the symbol for a given currency code func CurrencySymbolForCode(currency string) string { return GetCurrencyInfo(currency).Symbol } // GetCurrencySymbol returns the symbol for the current currency func (s *SettingsService) GetCurrencySymbol() string { return CurrencySymbolForCode(s.GetCurrency()) } // SetDateFormat saves the date format preference func (s *SettingsService) SetDateFormat(format string) error { switch format { case "MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD": return s.repo.Set("date_format", format) default: return fmt.Errorf("invalid date format: %s", format) } } // GetDateFormat retrieves the date format preference func (s *SettingsService) GetDateFormat() string { format, err := s.repo.Get("date_format") if err != nil || format == "" { return "MM/DD/YYYY" } return format } // GetGoDateFormat returns the Go time format string for the current date format func (s *SettingsService) GetGoDateFormat() string { return DateFormatToGo(s.GetDateFormat()) } // GetGoDateFormatLong returns the long Go time format string for emails/notifications func (s *SettingsService) GetGoDateFormatLong() string { return DateFormatToGoLong(s.GetDateFormat()) } // DateFormatToGo converts a date format key to a short Go time format string func DateFormatToGo(format string) string { switch format { case "DD/MM/YYYY": return "02/01/2006" case "YYYY-MM-DD": return "2006-01-02" default: return "01/02/2006" } } // DateFormatToGoLong converts a date format key to a long Go time format string func DateFormatToGoLong(format string) string { switch format { case "DD/MM/YYYY": return "2 January 2006" case "YYYY-MM-DD": return "2006-01-02" default: return "January 2, 2006" } } // SetDarkMode saves the dark mode preference func (s *SettingsService) SetDarkMode(enabled bool) error { return s.SetBoolSetting("dark_mode", enabled) } // IsDarkModeEnabled returns whether dark mode is enabled func (s *SettingsService) IsDarkModeEnabled() bool { return s.GetBoolSettingWithDefault("dark_mode", false) } // Auth-related methods // IsAuthEnabled returns whether authentication is enabled func (s *SettingsService) IsAuthEnabled() bool { return s.GetBoolSettingWithDefault("auth_enabled", false) } // SetAuthEnabled enables or disables authentication func (s *SettingsService) SetAuthEnabled(enabled bool) error { return s.SetBoolSetting("auth_enabled", enabled) } // GetAuthUsername returns the configured admin username func (s *SettingsService) GetAuthUsername() (string, error) { return s.repo.Get("auth_username") } // SetAuthUsername sets the admin username func (s *SettingsService) SetAuthUsername(username string) error { return s.repo.Set("auth_username", username) } // HashPassword hashes a password using bcrypt func (s *SettingsService) HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hash), nil } // SetAuthPassword hashes and stores the admin password func (s *SettingsService) SetAuthPassword(password string) error { hash, err := s.HashPassword(password) if err != nil { return err } return s.repo.Set("auth_password_hash", hash) } // ValidatePassword checks if a password matches the stored hash func (s *SettingsService) ValidatePassword(password string) error { hash, err := s.repo.Get("auth_password_hash") if err != nil { return fmt.Errorf("no password configured") } return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) } // GetOrGenerateSessionSecret returns the session secret, generating one if it doesn't exist func (s *SettingsService) GetOrGenerateSessionSecret() (string, error) { secret, err := s.repo.Get("auth_session_secret") if err == nil && secret != "" { return secret, nil } // Generate a new 64-byte random secret bytes := make([]byte, 64) if _, err := rand.Read(bytes); err != nil { return "", err } secret = base64.URLEncoding.EncodeToString(bytes) // Save it if err := s.repo.Set("auth_session_secret", secret); err != nil { return "", err } return secret, nil } // SetupAuth sets up authentication with username and password func (s *SettingsService) SetupAuth(username, password string) error { // Set username if err := s.SetAuthUsername(username); err != nil { return err } // Set password if err := s.SetAuthPassword(password); err != nil { return err } // Generate session secret if _, err := s.GetOrGenerateSessionSecret(); err != nil { return err } // Enable auth return s.SetAuthEnabled(true) } // DisableAuth disables authentication and removes credentials func (s *SettingsService) DisableAuth() error { // Disable auth first if err := s.SetAuthEnabled(false); err != nil { return err } // Optionally clear credentials (commented out to allow re-enabling without re-entering) // s.repo.Delete("auth_username") // s.repo.Delete("auth_password_hash") return nil } // GenerateResetToken generates a password reset token func (s *SettingsService) GenerateResetToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } token := base64.URLEncoding.EncodeToString(bytes) // Store token with 1-hour expiry if err := s.repo.Set("auth_reset_token", token); err != nil { return "", err } expiry := time.Now().Add(1 * time.Hour).Format(time.RFC3339) if err := s.repo.Set("auth_reset_token_expiry", expiry); err != nil { return "", err } return token, nil } // ValidateResetToken checks if a reset token is valid func (s *SettingsService) ValidateResetToken(token string) error { storedToken, err := s.repo.Get("auth_reset_token") if err != nil || subtle.ConstantTimeCompare([]byte(storedToken), []byte(token)) != 1 { return fmt.Errorf("invalid token") } expiryStr, err := s.repo.Get("auth_reset_token_expiry") if err != nil { return fmt.Errorf("token expired") } expiry, err := time.Parse(time.RFC3339, expiryStr) if err != nil || time.Now().After(expiry) { return fmt.Errorf("token expired") } return nil } // ClearResetToken removes the reset token after use func (s *SettingsService) ClearResetToken() error { s.repo.Delete("auth_reset_token") s.repo.Delete("auth_reset_token_expiry") return nil } // GetBaseURL returns the configured base URL for external links, or empty string if not set func (s *SettingsService) GetBaseURL() string { baseURL, err := s.repo.Get("base_url") if err != nil { return "" } return baseURL } // SetBaseURL saves the base URL setting func (s *SettingsService) SetBaseURL(baseURL string) error { return s.repo.Set("base_url", baseURL) } // iCal Subscription methods // IsICalSubscriptionEnabled returns whether iCal subscription is enabled func (s *SettingsService) IsICalSubscriptionEnabled() bool { return s.GetBoolSettingWithDefault("ical_subscription_enabled", false) } // SetICalSubscriptionEnabled enables or disables iCal subscription func (s *SettingsService) SetICalSubscriptionEnabled(enabled bool) error { return s.SetBoolSetting("ical_subscription_enabled", enabled) } // GetOrGenerateICalToken returns the iCal token, generating one if it doesn't exist func (s *SettingsService) GetOrGenerateICalToken() (string, error) { token, err := s.repo.Get("ical_subscription_token") if err == nil && token != "" { return token, nil } // Generate a new 32-byte random token bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } token = base64.URLEncoding.EncodeToString(bytes) if err := s.repo.Set("ical_subscription_token", token); err != nil { return "", err } return token, nil } // RegenerateICalToken replaces the iCal token with a new one func (s *SettingsService) RegenerateICalToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } token := base64.URLEncoding.EncodeToString(bytes) if err := s.repo.Set("ical_subscription_token", token); err != nil { return "", err } return token, nil } // ValidateICalToken checks if a given token matches the stored iCal token func (s *SettingsService) ValidateICalToken(token string) bool { storedToken, err := s.repo.Get("ical_subscription_token") if err != nil || storedToken == "" { return false } return subtle.ConstantTimeCompare([]byte(storedToken), []byte(token)) == 1 } // SavePushoverConfig saves Pushover configuration func (s *SettingsService) SavePushoverConfig(config *models.PushoverConfig) error { // Convert to JSON data, err := json.Marshal(config) if err != nil { return err } return s.repo.Set("pushover_config", string(data)) } // GetPushoverConfig retrieves Pushover configuration func (s *SettingsService) GetPushoverConfig() (*models.PushoverConfig, error) { data, err := s.repo.Get("pushover_config") if err != nil { return nil, err } var config models.PushoverConfig err = json.Unmarshal([]byte(data), &config) if err != nil { return nil, err } return &config, nil } // SaveWebhookConfig saves Webhook configuration func (s *SettingsService) SaveWebhookConfig(config *models.WebhookConfig) error { data, err := json.Marshal(config) if err != nil { return err } return s.repo.Set("webhook_config", string(data)) } // GetWebhookConfig retrieves Webhook configuration func (s *SettingsService) GetWebhookConfig() (*models.WebhookConfig, error) { data, err := s.repo.Get("webhook_config") if err != nil { return nil, err } var config models.WebhookConfig err = json.Unmarshal([]byte(data), &config) if err != nil { return nil, err } return &config, nil } ================================================ FILE: internal/service/settings_test.go ================================================ package service import ( "subtrackr/internal/models" "subtrackr/internal/repository" "testing" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupSettingsTestDB(t *testing.T) *SettingsService { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) return NewSettingsService(settingsRepo) } func TestSetDateFormat_Valid(t *testing.T) { s := setupSettingsTestDB(t) tests := []struct { name string format string }{ {"US format", "MM/DD/YYYY"}, {"European format", "DD/MM/YYYY"}, {"ISO format", "YYYY-MM-DD"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := s.SetDateFormat(tt.format) assert.NoError(t, err) result := s.GetDateFormat() assert.Equal(t, tt.format, result) }) } } func TestSetDateFormat_Invalid(t *testing.T) { s := setupSettingsTestDB(t) tests := []struct { name string format string }{ {"Empty string", ""}, {"Random string", "foobar"}, {"Close but wrong", "MM-DD-YYYY"}, {"Lowercase", "mm/dd/yyyy"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := s.SetDateFormat(tt.format) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid date format") }) } } func TestGetDateFormat_Default(t *testing.T) { s := setupSettingsTestDB(t) format := s.GetDateFormat() assert.Equal(t, "MM/DD/YYYY", format, "Default date format should be MM/DD/YYYY") } func TestDateFormatToGo(t *testing.T) { tests := []struct { input string expected string }{ {"MM/DD/YYYY", "01/02/2006"}, {"DD/MM/YYYY", "02/01/2006"}, {"YYYY-MM-DD", "2006-01-02"}, {"unknown", "01/02/2006"}, // defaults to US {"", "01/02/2006"}, // defaults to US } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { assert.Equal(t, tt.expected, DateFormatToGo(tt.input)) }) } } func TestDateFormatToGoLong(t *testing.T) { tests := []struct { input string expected string }{ {"MM/DD/YYYY", "January 2, 2006"}, {"DD/MM/YYYY", "2 January 2006"}, {"YYYY-MM-DD", "2006-01-02"}, {"unknown", "January 2, 2006"}, // defaults to US {"", "January 2, 2006"}, // defaults to US } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { assert.Equal(t, tt.expected, DateFormatToGoLong(tt.input)) }) } } func TestGetGoDateFormat(t *testing.T) { s := setupSettingsTestDB(t) // Default assert.Equal(t, "01/02/2006", s.GetGoDateFormat()) // Set to European s.SetDateFormat("DD/MM/YYYY") assert.Equal(t, "02/01/2006", s.GetGoDateFormat()) // Set to ISO s.SetDateFormat("YYYY-MM-DD") assert.Equal(t, "2006-01-02", s.GetGoDateFormat()) } func TestGetGoDateFormatLong(t *testing.T) { s := setupSettingsTestDB(t) // Default assert.Equal(t, "January 2, 2006", s.GetGoDateFormatLong()) // Set to European s.SetDateFormat("DD/MM/YYYY") assert.Equal(t, "2 January 2006", s.GetGoDateFormatLong()) } func TestWebhookConfig_SaveAndRetrieve(t *testing.T) { s := setupSettingsTestDB(t) config := &models.WebhookConfig{ URL: "https://example.com/webhook", Headers: map[string]string{ "Authorization": "Bearer test-token", "X-Custom": "value", }, } err := s.SaveWebhookConfig(config) assert.NoError(t, err) retrieved, err := s.GetWebhookConfig() assert.NoError(t, err) assert.Equal(t, config.URL, retrieved.URL) assert.Equal(t, config.Headers, retrieved.Headers) } func TestWebhookConfig_NotConfigured(t *testing.T) { s := setupSettingsTestDB(t) _, err := s.GetWebhookConfig() assert.Error(t, err, "Should error when webhook not configured") } ================================================ FILE: internal/service/subscription.go ================================================ package service import ( "subtrackr/internal/models" "subtrackr/internal/repository" "time" ) type SubscriptionService struct { repo *repository.SubscriptionRepository categoryService *CategoryService } func NewSubscriptionService(repo *repository.SubscriptionRepository, categoryService *CategoryService) *SubscriptionService { return &SubscriptionService{repo: repo, categoryService: categoryService} } func (s *SubscriptionService) Create(subscription *models.Subscription) (*models.Subscription, error) { return s.repo.Create(subscription) } func (s *SubscriptionService) GetAll() ([]models.Subscription, error) { return s.repo.GetAll() } func (s *SubscriptionService) GetAllSorted(sortBy, order string) ([]models.Subscription, error) { return s.repo.GetAllSorted(sortBy, order) } func (s *SubscriptionService) GetByID(id uint) (*models.Subscription, error) { return s.repo.GetByID(id) } func (s *SubscriptionService) Update(id uint, subscription *models.Subscription) (*models.Subscription, error) { return s.repo.Update(id, subscription) } func (s *SubscriptionService) Delete(id uint) error { return s.repo.Delete(id) } func (s *SubscriptionService) Count() int64 { return s.repo.Count() } func (s *SubscriptionService) GetStats() (*models.Stats, error) { activeSubscriptions, err := s.repo.GetActiveSubscriptions() if err != nil { return nil, err } cancelledSubscriptions, err := s.repo.GetCancelledSubscriptions() if err != nil { return nil, err } upcomingRenewals, err := s.repo.GetUpcomingRenewals(7) if err != nil { return nil, err } categoryStats, err := s.repo.GetCategoryStats() if err != nil { return nil, err } stats := &models.Stats{ ActiveSubscriptions: len(activeSubscriptions), CancelledSubscriptions: len(cancelledSubscriptions), UpcomingRenewals: len(upcomingRenewals), CategorySpending: make(map[string]float64), } // Calculate totals for _, sub := range activeSubscriptions { stats.TotalMonthlySpend += sub.MonthlyCost() stats.TotalAnnualSpend += sub.AnnualCost() } // Calculate savings from cancelled subscriptions for _, sub := range cancelledSubscriptions { stats.TotalSaved += sub.AnnualCost() stats.MonthlySaved += sub.MonthlyCost() } // Build category spending map for _, cat := range categoryStats { stats.CategorySpending[cat.Category] = cat.Amount } return stats, nil } func (s *SubscriptionService) GetAllCategories() ([]models.Category, error) { return s.categoryService.GetAll() } // GetSubscriptionsNeedingReminders returns subscriptions that need renewal reminders // based on the reminder_days setting. It returns a map of subscription to days until renewal. func (s *SubscriptionService) GetSubscriptionsNeedingReminders(reminderDays int) (map[*models.Subscription]int, error) { if reminderDays <= 0 { return make(map[*models.Subscription]int), nil } // Get all subscriptions with renewals in the next reminderDays subscriptions, err := s.repo.GetUpcomingRenewals(reminderDays) if err != nil { return nil, err } result := make(map[*models.Subscription]int) for i := range subscriptions { sub := &subscriptions[i] if sub.RenewalDate == nil { continue } if !sub.ReminderEnabled { continue } // Calculate days until renewal using proper date arithmetic // Use time.Until for more accurate calculation (handles timezone differences better) daysUntil := int(time.Until(*sub.RenewalDate).Hours() / 24) // Only include if within the reminder window and not past due if daysUntil >= 0 && daysUntil <= reminderDays { // Check if we've already sent a reminder for this renewal date // Skip if we've sent a reminder for the same renewal date if sub.LastReminderRenewalDate != nil && sub.RenewalDate != nil && sub.LastReminderRenewalDate.Equal(*sub.RenewalDate) { // Already sent reminder for this renewal date, skip continue } result[sub] = daysUntil } } return result, nil } // GetSubscriptionsNeedingCancellationReminders returns subscriptions that need cancellation reminders // based on the cancellation_reminder_days setting. It returns a map of subscription to days until cancellation. func (s *SubscriptionService) GetSubscriptionsNeedingCancellationReminders(reminderDays int) (map[*models.Subscription]int, error) { if reminderDays <= 0 { return make(map[*models.Subscription]int), nil } // Get all subscriptions with cancellations in the next reminderDays subscriptions, err := s.repo.GetUpcomingCancellations(reminderDays) if err != nil { return nil, err } result := make(map[*models.Subscription]int) for i := range subscriptions { sub := &subscriptions[i] if sub.CancellationDate == nil { continue } if !sub.ReminderEnabled { continue } // Calculate days until cancellation daysUntil := int(time.Until(*sub.CancellationDate).Hours() / 24) // Only include if within the reminder window and not past due if daysUntil >= 0 && daysUntil <= reminderDays { // Check if we've already sent a reminder for this cancellation date if sub.LastCancellationReminderDate != nil && sub.CancellationDate != nil && sub.LastCancellationReminderDate.Equal(*sub.CancellationDate) { // Already sent reminder for this cancellation date, skip continue } result[sub] = daysUntil } } return result, nil } ================================================ FILE: internal/service/webhook.go ================================================ package service import ( "bytes" "encoding/json" "fmt" "net/http" "subtrackr/internal/models" "time" ) // WebhookService handles sending notifications via generic webhooks type WebhookService struct { settingsService *SettingsService } // NewWebhookService creates a new Webhook service func NewWebhookService(settingsService *SettingsService) *WebhookService { return &WebhookService{ settingsService: settingsService, } } // WebhookPayload is the JSON body sent to webhook endpoints type WebhookPayload struct { Event string `json:"event"` Title string `json:"title"` Message string `json:"message"` Subscription *WebhookSubscription `json:"subscription"` Timestamp string `json:"timestamp"` } // WebhookSubscription is a simplified subscription for webhook payloads type WebhookSubscription struct { ID uint `json:"id"` Name string `json:"name"` Cost float64 `json:"cost"` Currency string `json:"currency"` CurrencySymbol string `json:"currency_symbol"` Schedule string `json:"schedule"` MonthlyCost float64 `json:"monthly_cost"` Category string `json:"category,omitempty"` URL string `json:"url,omitempty"` RenewalDate string `json:"renewal_date,omitempty"` CancellationDate string `json:"cancellation_date,omitempty"` } func subscriptionToWebhook(sub *models.Subscription, settings *SettingsService) *WebhookSubscription { currencySymbol := currencySymbolForSubscription(sub, settings) ws := &WebhookSubscription{ ID: sub.ID, Name: sub.Name, Cost: sub.Cost, Currency: sub.OriginalCurrency, CurrencySymbol: currencySymbol, Schedule: sub.Schedule, MonthlyCost: sub.MonthlyCost(), } if sub.Category.Name != "" { ws.Category = sub.Category.Name } if sub.URL != "" { ws.URL = sub.URL } dateFormat := settings.GetGoDateFormat() if sub.RenewalDate != nil { ws.RenewalDate = sub.RenewalDate.Format(dateFormat) } if sub.CancellationDate != nil { ws.CancellationDate = sub.CancellationDate.Format(dateFormat) } return ws } // SendWebhook sends a payload to the configured webhook endpoint func (w *WebhookService) SendWebhook(payload *WebhookPayload) error { config, err := w.settingsService.GetWebhookConfig() if err != nil || config.URL == "" { return nil // Not configured, silently skip (matches email/pushover behavior) } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal webhook payload: %w", err) } req, err := http.NewRequest("POST", config.URL, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "SubTrackr-Webhook/1.0") for key, value := range config.Headers { req.Header.Set(key, value) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send webhook: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("webhook returned status %d", resp.StatusCode) } return nil } // SendHighCostAlert sends a webhook alert when a high-cost subscription is created func (w *WebhookService) SendHighCostAlert(subscription *models.Subscription) error { enabled, err := w.settingsService.GetBoolSetting("high_cost_alerts", true) if err != nil || !enabled { return nil } currencySymbol := currencySymbolForSubscription(subscription, w.settingsService) payload := &WebhookPayload{ Event: "high_cost_alert", Title: fmt.Sprintf("High Cost Alert: %s", subscription.Name), Message: fmt.Sprintf("A new high-cost subscription has been added: %s at %s%.2f %s", subscription.Name, currencySymbol, subscription.Cost, subscription.Schedule), Subscription: subscriptionToWebhook(subscription, w.settingsService), Timestamp: time.Now().UTC().Format(time.RFC3339), } return w.SendWebhook(payload) } // SendRenewalReminder sends a webhook reminder for an upcoming subscription renewal func (w *WebhookService) SendRenewalReminder(subscription *models.Subscription, daysUntilRenewal int) error { enabled, err := w.settingsService.GetBoolSetting("renewal_reminders", false) if err != nil || !enabled { return nil } daysText := "days" if daysUntilRenewal == 1 { daysText = "day" } payload := &WebhookPayload{ Event: "renewal_reminder", Title: fmt.Sprintf("Renewal Reminder: %s", subscription.Name), Message: fmt.Sprintf("Your subscription %s will renew in %d %s", subscription.Name, daysUntilRenewal, daysText), Subscription: subscriptionToWebhook(subscription, w.settingsService), Timestamp: time.Now().UTC().Format(time.RFC3339), } return w.SendWebhook(payload) } // SendCancellationReminder sends a webhook reminder for an upcoming subscription cancellation func (w *WebhookService) SendCancellationReminder(subscription *models.Subscription, daysUntilCancellation int) error { enabled, err := w.settingsService.GetBoolSetting("cancellation_reminders", false) if err != nil || !enabled { return nil } daysText := "days" if daysUntilCancellation == 1 { daysText = "day" } payload := &WebhookPayload{ Event: "cancellation_reminder", Title: fmt.Sprintf("Cancellation Reminder: %s", subscription.Name), Message: fmt.Sprintf("Your subscription %s will end in %d %s", subscription.Name, daysUntilCancellation, daysText), Subscription: subscriptionToWebhook(subscription, w.settingsService), Timestamp: time.Now().UTC().Format(time.RFC3339), } return w.SendWebhook(payload) } ================================================ FILE: internal/service/webhook_test.go ================================================ package service import ( "subtrackr/internal/models" "subtrackr/internal/repository" "testing" "time" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupWebhookTestDB(t *testing.T) (*SettingsService, *WebhookService) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}, &models.Category{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) webhookService := NewWebhookService(settingsService) return settingsService, webhookService } func TestWebhookService_SendWebhook_NoConfig(t *testing.T) { _, ws := setupWebhookTestDB(t) payload := &WebhookPayload{ Event: "test", Title: "Test", Message: "Test message", } err := ws.SendWebhook(payload) assert.NoError(t, err, "Should silently skip when webhook is not configured") } func TestWebhookService_SendWebhook_EmptyURL(t *testing.T) { ss, ws := setupWebhookTestDB(t) config := &models.WebhookConfig{ URL: "", } ss.SaveWebhookConfig(config) payload := &WebhookPayload{ Event: "test", Title: "Test", Message: "Test message", } err := ws.SendWebhook(payload) assert.NoError(t, err, "Should silently skip when webhook URL is empty") } func TestWebhookService_SendHighCostAlert_Disabled(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("high_cost_alerts", false) sub := &models.Subscription{ Name: "Test Sub", Cost: 100.00, Schedule: "Monthly", Category: models.Category{Name: "Test"}, } err := ws.SendHighCostAlert(sub) assert.NoError(t, err, "Should return nil when high cost alerts are disabled") } func TestWebhookService_SendHighCostAlert_EnabledNoConfig(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("high_cost_alerts", true) ss.SetCurrency("USD") sub := &models.Subscription{ Name: "Test Sub", Cost: 100.00, Schedule: "Monthly", Category: models.Category{Name: "Test"}, } err := ws.SendHighCostAlert(sub) assert.NoError(t, err, "Should silently skip when webhook is not configured") } func TestWebhookService_SendRenewalReminder_Disabled(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("renewal_reminders", false) sub := &models.Subscription{ Name: "Test Sub", Cost: 10.00, Schedule: "Monthly", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Test"}, } err := ws.SendRenewalReminder(sub, 3) assert.NoError(t, err, "Should return nil when renewal reminders are disabled") } func TestWebhookService_SendRenewalReminder_EnabledNoConfig(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("renewal_reminders", true) ss.SetCurrency("USD") sub := &models.Subscription{ Name: "Test Sub", Cost: 10.00, Schedule: "Monthly", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Test"}, } err := ws.SendRenewalReminder(sub, 3) assert.NoError(t, err, "Should silently skip when webhook is not configured") } func TestWebhookService_SendCancellationReminder_Disabled(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("cancellation_reminders", false) sub := &models.Subscription{ Name: "Test Sub", Cost: 10.00, Schedule: "Monthly", CancellationDate: timePtr(time.Now().AddDate(0, 0, 5)), Category: models.Category{Name: "Test"}, } err := ws.SendCancellationReminder(sub, 5) assert.NoError(t, err, "Should return nil when cancellation reminders are disabled") } func TestWebhookService_SendCancellationReminder_EnabledNoConfig(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("cancellation_reminders", true) ss.SetCurrency("USD") sub := &models.Subscription{ Name: "Test Sub", Cost: 10.00, Schedule: "Monthly", CancellationDate: timePtr(time.Now().AddDate(0, 0, 5)), Category: models.Category{Name: "Test"}, } err := ws.SendCancellationReminder(sub, 5) assert.NoError(t, err, "Should silently skip when webhook is not configured") } func TestSubscriptionToWebhook(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) settingsService.SetCurrency("USD") renewalDate := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) cancellationDate := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) sub := &models.Subscription{ Name: "Netflix", Cost: 15.99, OriginalCurrency: "EUR", Schedule: "Monthly", Category: models.Category{Name: "Entertainment"}, URL: "https://netflix.com", RenewalDate: &renewalDate, CancellationDate: &cancellationDate, } sub.ID = 42 ws := subscriptionToWebhook(sub, settingsService) assert.Equal(t, uint(42), ws.ID) assert.Equal(t, "Netflix", ws.Name) assert.Equal(t, 15.99, ws.Cost) assert.Equal(t, "EUR", ws.Currency) assert.Equal(t, "€", ws.CurrencySymbol) assert.Equal(t, "Monthly", ws.Schedule) assert.Equal(t, "Entertainment", ws.Category) assert.Equal(t, "https://netflix.com", ws.URL) assert.NotEmpty(t, ws.RenewalDate) assert.NotEmpty(t, ws.CancellationDate) } func TestSubscriptionToWebhook_MinimalFields(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open test database: %v", err) } err = db.AutoMigrate(&models.Settings{}) if err != nil { t.Fatalf("Failed to migrate test database: %v", err) } settingsRepo := repository.NewSettingsRepository(db) settingsService := NewSettingsService(settingsRepo) settingsService.SetCurrency("USD") sub := &models.Subscription{ Name: "Basic Sub", Cost: 5.00, Schedule: "Monthly", } ws := subscriptionToWebhook(sub, settingsService) assert.Equal(t, "Basic Sub", ws.Name) assert.Equal(t, 5.00, ws.Cost) assert.Empty(t, ws.Category, "Category should be empty when not set") assert.Empty(t, ws.URL, "URL should be empty when not set") assert.Empty(t, ws.RenewalDate, "RenewalDate should be empty when nil") assert.Empty(t, ws.CancellationDate, "CancellationDate should be empty when nil") } func TestWebhookService_SendRenewalReminder_DaysText(t *testing.T) { ss, ws := setupWebhookTestDB(t) ss.SetBoolSetting("renewal_reminders", true) ss.SetCurrency("USD") sub := &models.Subscription{ Name: "Test Sub", Cost: 10.00, Schedule: "Monthly", RenewalDate: timePtr(time.Now().AddDate(0, 0, 3)), Category: models.Category{Name: "Test"}, } tests := []struct { name string daysUntil int }{ {"Singular day", 1}, {"Plural days", 3}, {"Zero days", 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ws.SendRenewalReminder(sub, tt.daysUntil) assert.NoError(t, err, "Should silently skip when webhook is not configured") }) } } ================================================ FILE: internal/version/version.go ================================================ package version var ( // GitCommit is the git commit SHA that will be set at build time GitCommit = "unknown" // Version is the semantic version tag that will be set at build time Version = "dev" ) // GetVersion returns the current version string // Prefers the semantic version tag over git commit SHA func GetVersion() string { if Version != "dev" && Version != "" { return Version } if GitCommit != "unknown" && GitCommit != "" { return GitCommit } return "dev" } ================================================ FILE: package.json ================================================ { "dependencies": { "@playwright/test": "^1.54.2" }, "scripts": { "test": "playwright test", "test:headed": "playwright test --headed", "test:ui": "playwright test --ui", "test:report": "playwright show-report" } } ================================================ FILE: playwright.config.js ================================================ // @ts-check const { defineConfig, devices } = require('@playwright/test'); /** * @see https://playwright.dev/docs/test-configuration */ module.exports = defineConfig({ testDir: './tests', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:8082', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, /* Configure projects for major browsers */ projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, // { // name: 'Mobile Safari', // use: { ...devices['iPhone 12'] }, // }, /* Test against branded browsers. */ // { // name: 'Microsoft Edge', // use: { ...devices['Desktop Edge'], channel: 'msedge' }, // }, // { // name: 'Google Chrome', // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // }, ], /* Run your local dev server before starting the tests */ webServer: { command: 'go run .', url: 'http://localhost:8082', reuseExistingServer: !process.env.CI, }, }); ================================================ FILE: templates/analytics.html ================================================ {{.Title}} - SubTrackr

Total Monthly Spend

{{.CurrencySymbol}}{{printf "%.2f" .Stats.TotalMonthlySpend}}

Across {{.Stats.ActiveSubscriptions}} active subscriptions

Total Annual Spend

{{.CurrencySymbol}}{{printf "%.2f" .Stats.TotalAnnualSpend}}

Projected yearly cost

Annual Savings

{{.CurrencySymbol}}{{printf "%.2f" .Stats.TotalSaved}}

From {{.Stats.CancelledSubscriptions}} cancelled subscriptions

Spending by Category

{{range $category, $amount := .Stats.CategorySpending}}
{{$category}}
{{$.CurrencySymbol}}{{printf "%.2f" $amount}}
{{end}}

Subscription Status

Active
{{.Stats.ActiveSubscriptions}}
Cancelled
{{.Stats.CancelledSubscriptions}}
Upcoming Renewals
{{.Stats.UpcomingRenewals}}

Cost Analysis

{{.CurrencySymbol}}{{printf "%.2f" (div .Stats.TotalMonthlySpend 30)}}

Average Daily Cost

{{.CurrencySymbol}}{{printf "%.2f" .Stats.TotalMonthlySpend}}

Total Monthly Cost

================================================ FILE: templates/api-keys-list.html ================================================ {{if .Keys}} {{range .Keys}}
{{.Name}}
{{if .IsNew}} New {{end}}
{{if .IsNew}}

Important: Copy this API key now. You won't be able to see it again!

{{.Key}}
{{else}}
Created: {{fmtTime .CreatedAt $.GoDateFormat}} • {{if .LastUsed}}Last used: {{fmtTime .LastUsed $.GoDateFormat}}{{else}}Never used{{end}} • Usage: {{.UsageCount}} requests
{{end}}
{{end}} {{else}}
No API keys created yet
{{end}} ================================================ FILE: templates/auth-message.html ================================================ {{if .Error}}

{{.Error}}

{{else if .Message}}

{{.Message}}

{{end}} ================================================ FILE: templates/calendar.html ================================================ {{.Title}} - SubTrackr

{{.MonthName}}

Today
{{if .ICalSubscriptionEnabled}} {{end}} Export to iCal
Sun
Mon
Tue
Wed
Thu
Fri
Sat
================================================ FILE: templates/categories-list.html ================================================ {{if .}} {{range .}}
{{.Name}}
{{end}} {{else}}
No categories found.
{{end}} ================================================ FILE: templates/dashboard.html ================================================ {{.Title}} - SubTrackr

Monthly Spend

{{.CurrencySymbol}}{{printf "%.2f" .Stats.TotalMonthlySpend}}

Annual Spend

{{.CurrencySymbol}}{{printf "%.2f" .Stats.TotalAnnualSpend}}

Active Subscriptions

{{.Stats.ActiveSubscriptions}}

Monthly Savings

{{.CurrencySymbol}}{{printf "%.2f" .Stats.MonthlySaved}}

From cancellations

Spending by Category

{{range $category, $amount := .Stats.CategorySpending}}
{{$category}}
{{$.CurrencySymbol}}{{printf "%.2f" $amount}}
{{else}}

No category spending data found.

Add some active subscriptions to see category breakdown.

{{end}}

All Subscriptions

{{range .Subscriptions}}
{{if .IconURL}} {{.Name}} {{else}}
{{end}}

{{.Name}}

{{.Category.Name}} • {{.Status}}

{{if .ShowConversion}}

{{.DisplayCurrencySymbol}}{{printf "%.2f" .ConvertedCost}}

{{.OriginalCurrency}} {{printf "%.2f" .Cost}} {{else}}

{{.DisplayCurrencySymbol}}{{printf "%.2f" .Cost}}

{{end}}

{{.DisplaySchedule}}

{{end}}
================================================ FILE: templates/error.html ================================================ {{define "content"}}

Something went wrong

{{.error}}

Back to Dashboard
{{end}} ================================================ FILE: templates/forgot-password-error.html ================================================

{{.Error}}

================================================ FILE: templates/forgot-password-success.html ================================================

{{.Message}}

Please check your email for the password reset link

================================================ FILE: templates/forgot-password.html ================================================ Forgot Password - SubTrackr
SubTrackr

Reset Your Password

We'll send a reset link to your configured email address

================================================ FILE: templates/form-errors.html ================================================

Error

{{.Error}}

================================================ FILE: templates/login-error.html ================================================

{{.Error}}

================================================ FILE: templates/login.html ================================================ Login - SubTrackr
SubTrackr

Sign in to SubTrackr

================================================ FILE: templates/reset-password-error.html ================================================

{{.Error}}

================================================ FILE: templates/reset-password-success.html ================================================

{{.Message}}

================================================ FILE: templates/reset-password.html ================================================ Reset Password - SubTrackr
SubTrackr

Set New Password

{{if .Error}}

{{.Error}}

{{else}}

Minimum 8 characters

{{end}}
================================================ FILE: templates/settings.html ================================================ {{.Title}} - SubTrackr

Settings

Manage your SubTrackr preferences and data

Appearance

Personalize your SubTrackr experience with beautiful themes

Default
Clean and professional
Dark
Easy on the eyes
Dark Classic
Original dark mode
Christmas 🎄
Festive and jolly!
Midnight
Deep and mysterious
Ocean
Cool and refreshing

Export Data

Download your subscription data in various formats

Base URL

Set the external URL for your SubTrackr instance. Used for iCal subscription links and password reset emails. Leave blank to auto-detect from request headers.

Calendar Subscription

Enable a subscribable iCal URL that calendar apps can poll for live updates

iCal Subscription

Allow calendar apps to subscribe to your renewal dates

Add this URL as a "Subscribe to Calendar" in your calendar app (Google Calendar, Apple Calendar, Outlook, etc.)

Data Management

Backup Data

Create a backup of all your subscription data

Create Backup

Restore Backup

Import subscriptions from a backup file

Clear All Data

Permanently delete all subscription data

Email Notifications

SMTP Configuration

This is where notification emails will be sent

Renewal Reminders

Get notified before subscriptions renew

High Cost Alerts

Alert when adding expensive subscriptions

High Cost Threshold

Monthly cost threshold for high cost alerts (in {{.CurrencySymbol}})

{{.CurrencySymbol}}

Days Before Renewal

How many days before renewal to send reminder

Cancellation Reminders

Get notified before subscriptions end

Days Before Cancellation

How many days before cancellation to send reminder

Pushover Notifications

Receive push notifications on your mobile device via Pushover

Pushover Configuration

Get your User Key from pushover.net

Create an application at pushover.net/apps

Webhook Notifications

Send notifications to any webhook endpoint (Slack, Discord, n8n, Home Assistant, etc.)

Security

Protect your SubTrackr instance with login authentication

Require Login

Enable authentication to protect your data

{{if .AuthEnabled}}

✓ Authentication is enabled

Username: {{.AuthUsername}}

{{else}}

ⓘ Email must be configured first for password recovery. {{if not .SMTPConfigured}}Please configure email settings above before enabling login.{{end}}

Minimum 8 characters

{{end}}

Currency

Choose your preferred currency for displaying subscription costs

{{range .Currencies}} {{end}}

Date Format

Choose how dates are displayed throughout the application

Categories

Manage your subscription categories

Add New Category

API Keys

Create API keys to access SubTrackr from external applications

Loading API keys...

Create New API Key

API Documentation: Include the API key in the X-API-Key header for all requests. View full API documentation

About SubTrackr

Version {{.Version}}
Build Go + HTMX
Database SQLite

API Documentation

Authentication

All API requests require authentication using an API key. Include your API key in the request headers:

# Authorization header (recommended)
Authorization: Bearer sk_your_api_key_here
# X-API-Key header (alternative)
X-API-Key: sk_your_api_key_here

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

iCal Subscription

Method Endpoint Description
GET /ical/:token Subscribe to iCal feed (public, token-authenticated)

MCP Server (AI Integration)

SubTrackr includes a Model Context Protocol (MCP) server that allows AI assistants like Claude to read and manage your subscriptions via natural language.

Tool Description
list_subscriptions List all subscriptions
get_subscription Get 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
Configuration

Add this to your Claude Desktop or Claude Code MCP config:

{
  "mcpServers": {
    "subtrackr": {
      "command": "subtrackr-mcp",
      "args": [],
      "env": {
        "DATABASE_PATH": "/path/to/subtrackr.db"
      }
    }
  }
}

Example Requests

List Subscriptions

curl -H "Authorization: Bearer sk_your_api_key_here" \
http://localhost:8080/api/v1/subscriptions

Create Subscription

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_id": 1
}' \
http://localhost:8080/api/v1/subscriptions

Get Statistics

curl -H "Authorization: Bearer sk_your_api_key_here" \
http://localhost:8080/api/v1/stats
Response:
{
  "total_count": 15,
  "active_count": 12,
  "total_cost": 245.67,
  "categories": {
    "Entertainment": 45.99,
    "Productivity": 89.00,
    "Storage": 29.99
  }
}

Additional Resources

For more detailed API documentation and examples, check the README file in the project repository.

The test script test-api.sh provides interactive examples of API usage.

================================================ FILE: templates/smtp-message.html ================================================ {{if .Error}}

{{.Error}}

{{else if .Message}}

{{.Message}}

{{end}} ================================================ FILE: templates/subscription-form.html ================================================

{{if .IsEdit}}Edit{{else}}Add{{end}} Subscription

{{.CurrencySymbol}}

Disable for autopay subscriptions that don't need reminders

================================================ FILE: templates/subscription-list.html ================================================
{{if .Subscriptions}} {{range .Subscriptions}} {{end}}
Actions
{{if .IconURL}} {{.Name}} {{else}}
{{end}}
{{.Name}}{{if not .ReminderEnabled}} {{end}}
{{if .URL}} {{.URL}} {{end}}
{{.Category.Name}}
{{if .ShowConversion}}
{{.DisplayCurrencySymbol}}{{printf "%.2f" .ConvertedCost}}
{{.OriginalCurrency}} {{printf "%.2f" .Cost}} {{else}}
{{.DisplayCurrencySymbol}}{{printf "%.2f" .Cost}}
{{end}}
{{.DisplaySchedule}}
{{.Status}} {{if .RenewalDate}} {{fmtDate .RenewalDate $.GoDateFormat}} {{else}} {{end}}
{{if .Notes}}
{{end}}
{{else}}

No subscriptions yet

{{end}}
================================================ FILE: templates/subscriptions.html ================================================ {{.Title}} - SubTrackr

Subscriptions

{{if .Subscriptions}} {{range .Subscriptions}} {{end}}
Actions
{{if .IconURL}} {{.Name}} {{else}}
{{end}}
{{.Name}}
{{if .URL}} {{.URL}} {{end}}
{{.Category.Name}}
{{if .ShowConversion}}
{{.DisplayCurrencySymbol}}{{printf "%.2f" .ConvertedCost}}
{{.OriginalCurrency}} {{printf "%.2f" .Cost}} {{else}}
{{$.CurrencySymbol}}{{printf "%.2f" .Cost}}
{{end}}
{{.DisplaySchedule}}
{{.Status}} {{if .RenewalDate}} {{fmtDate .RenewalDate $.GoDateFormat}} {{else}} {{end}}
{{if .Notes}}
{{end}}
{{else}}

No subscriptions yet

{{end}}
================================================ FILE: test-api.sh ================================================ #!/bin/bash # SubTrackr API Test Script # This script demonstrates how to use the SubTrackr API with authentication API_KEY="sk_your_api_key_here" # Replace with your actual API key BASE_URL="http://localhost:8080" echo "SubTrackr API Test Script" echo "========================" echo "" echo "Make sure to:" echo "1. Start the SubTrackr server (go run cmd/server/main.go)" echo "2. Create an API key from the Settings page" echo "3. Replace the API_KEY variable in this script with your actual key" echo "" echo "Press Enter to continue..." read # Test 1: Get all subscriptions echo "Test 1: Getting all subscriptions..." curl -s -H "Authorization: Bearer $API_KEY" \ "$BASE_URL/api/v1/subscriptions" | jq . echo "" echo "Press Enter to continue..." read # Test 2: Get statistics echo "Test 2: Getting statistics..." curl -s -H "Authorization: Bearer $API_KEY" \ "$BASE_URL/api/v1/stats" | jq . echo "" echo "Press Enter to continue..." read # Test 3: Create a new subscription echo "Test 3: Creating a new subscription..." # Note: You'll need to replace category_id with an actual ID from your categories # You can get the list of categories with: curl -s "$BASE_URL/api/categories" | jq . curl -s -X POST \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Test Subscription", "cost": 9.99, "schedule": "Monthly", "status": "Active", "category_id": 1 }' \ "$BASE_URL/api/v1/subscriptions" | jq . echo "" echo "Press Enter to continue..." read # Test 4: Export as JSON echo "Test 4: Exporting as JSON..." curl -s -H "Authorization: Bearer $API_KEY" \ "$BASE_URL/api/v1/export/json" | jq . echo "" echo "Test complete!" ================================================ FILE: tests/example.spec.js ================================================ // @ts-check const { test, expect } = require('@playwright/test'); test('has title', async ({ page }) => { await page.goto('/'); // Expect a title "to contain" a substring. await expect(page).toHaveTitle(/SubTrackr/); }); test('can navigate to subscriptions', async ({ page }) => { await page.goto('/'); // Click the subscriptions link. await page.click('a[href="/subscriptions"]'); // Expects page to have a heading with the name of subscriptions. await expect(page.getByRole('heading', { name: 'Subscriptions' })).toBeVisible(); }); ================================================ FILE: tests/subscription-crud.spec.js ================================================ // @ts-check const { test, expect } = require('@playwright/test'); test.describe('Subscription CRUD Operations', () => { test('can create a new subscription', async ({ page }) => { await page.goto('/subscriptions'); // Click Add Subscription button await page.click('button:has-text("Add Subscription")'); // Fill out the form await page.fill('input[name="name"]', 'Test Subscription'); await page.fill('input[name="cost"]', '9.99'); await page.selectOption('select[name="billing_cycle"]', 'Monthly'); await page.selectOption('select[name="status"]', 'Active'); // Submit the form await page.click('button[type="submit"]'); // Wait for page reload and check if subscription appears await page.waitForLoadState('networkidle'); await expect(page.getByText('Test Subscription')).toBeVisible(); await expect(page.getByText('$9.99')).toBeVisible(); }); test('can edit an existing subscription', async ({ page }) => { await page.goto('/subscriptions'); // Assuming there's at least one subscription from the previous test // Click the first edit button await page.click('button:has-text("Edit"):first-of-type'); // Modify the name await page.fill('input[name="name"]', 'Updated Test Subscription'); await page.fill('input[name="cost"]', '14.99'); // Submit the form await page.click('button[type="submit"]'); // Wait for page reload and check if changes are saved await page.waitForLoadState('networkidle'); await expect(page.getByText('Updated Test Subscription')).toBeVisible(); await expect(page.getByText('$14.99')).toBeVisible(); }); test('displays correct currency formatting', async ({ page }) => { await page.goto('/subscriptions'); // Check that all prices end with .00 or have proper decimal formatting const priceElements = await page.locator('[data-testid="subscription-cost"], .text-sm.font-medium.text-gray-900').all(); for (const element of priceElements) { const text = await element.textContent(); if (text && text.includes('$')) { // Should match format like $9.99 or $10.00 expect(text).toMatch(/\$\d+\.\d{2}/); } } }); test('annual totals calculation is correct', async ({ page }) => { await page.goto('/'); // Get the annual total from dashboard const annualTotalElement = page.locator('[data-testid="annual-total"]'); if (await annualTotalElement.count() > 0) { const annualTotal = await annualTotalElement.textContent(); // Navigate to subscriptions and calculate expected total await page.goto('/subscriptions'); const subscriptionElements = await page.locator('[data-testid="subscription-row"]').all(); let expectedTotal = 0; for (const row of subscriptionElements) { const costText = await row.locator('[data-testid="subscription-cost"]').textContent(); const billingCycleText = await row.locator('[data-testid="billing-cycle"]').textContent(); if (costText && billingCycleText) { const cost = parseFloat(costText.replace('$', '')); let annualCost = cost; if (billingCycleText.includes('Monthly')) { annualCost = cost * 12; } else if (billingCycleText.includes('Weekly')) { annualCost = cost * 52; } else if (billingCycleText.includes('Daily')) { annualCost = cost * 365; } expectedTotal += annualCost; } } // Compare with actual total (allowing for small floating point differences) const actualTotal = parseFloat(annualTotal?.replace('$', '') || '0'); expect(Math.abs(actualTotal - expectedTotal)).toBeLessThan(0.01); } }); }); ================================================ FILE: web/static/category-management.js ================================================ function startEditCategory(id) { document.getElementById(`edit-category-form-${id}`).classList.remove('hidden'); document.getElementById(`category-name-${id}`).classList.add('hidden'); document.getElementById(`edit-btn-${id}`).classList.add('hidden'); } function cancelEditCategory(id) { document.getElementById(`edit-category-form-${id}`).classList.add('hidden'); document.getElementById(`category-name-${id}`).classList.remove('hidden'); document.getElementById(`edit-btn-${id}`).classList.remove('hidden'); } ================================================ FILE: web/static/css/themes.css ================================================ /* SubTrackr Theme System Styles */ :root { /* Default theme colors (will be overridden by theme selection) */ --theme-primary: #3b82f6; --theme-primaryHover: #2563eb; --theme-secondary: #64748b; --theme-success: #10b981; --theme-warning: #f59e0b; --theme-danger: #ef4444; --theme-background: #f9fafb; --theme-surface: #ffffff; --theme-surfaceHover: #f3f4f6; --theme-text: #111827; --theme-textSecondary: #6b7280; --theme-border: #e5e7eb; } /* Default Theme - Modern Light */ [data-theme="default"] body { background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important; color: #0f172a !important; } [data-theme="default"] .bg-white, [data-theme="default"] header { background-color: #ffffff !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important; } [data-theme="default"] .bg-gray-50 { background-color: #f8fafc !important; } [data-theme="default"] .border-gray-200 { border-color: #e2e8f0 !important; } /* Dark Theme - Professional Dark (not pure black) */ [data-theme="dark"] body { background-color: #121212 !important; color: #E4E4E7 !important; } [data-theme="dark"] .bg-white, [data-theme="dark"] header { background-color: #1e1e1e !important; } [data-theme="dark"] .bg-gray-50 { background-color: #2a2a2a !important; } [data-theme="dark"] .text-gray-900 { color: #E4E4E7 !important; } [data-theme="dark"] .text-gray-600, [data-theme="dark"] .text-gray-700 { color: #a1a1aa !important; } [data-theme="dark"] .border-gray-200 { border-color: #3f3f46 !important; } [data-theme="dark"] .bg-primary, [data-theme="dark"] button.bg-primary { background-color: #60a5fa !important; } [data-theme="dark"] .bg-primary:hover { background-color: #3b82f6 !important; } /* Dark Theme - Hover States */ [data-theme="dark"] .hover\:bg-gray-50:hover, [data-theme="dark"] .hover\:bg-gray-100:hover { background-color: #3f3f46 !important; } [data-theme="dark"] .hover\:bg-white:hover { background-color: #2a2a2a !important; } /* Dark Theme - Calendar Events */ [data-theme="dark"] .bg-blue-50 { background-color: #1e293b !important; } [data-theme="dark"] .bg-blue-100 { background-color: #374151 !important; } [data-theme="dark"] .text-blue-700 { color: #93c5fd !important; } [data-theme="dark"] .hover\:bg-blue-200:hover { background-color: #4b5563 !important; } /* Christmas Theme */ [data-theme="christmas"] body { background: linear-gradient(135deg, #fef3f3 0%, #fef2f2 100%) !important; color: #1f2937 !important; background-image: repeating-linear-gradient( 45deg, transparent, transparent 10px, rgba(196, 30, 58, 0.02) 10px, rgba(196, 30, 58, 0.02) 20px ); } [data-theme="christmas"] .bg-white, [data-theme="christmas"] header { background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%) !important; border-color: #fecaca !important; } [data-theme="christmas"] .bg-primary, [data-theme="christmas"] .bg-blue-600, [data-theme="christmas"] button.bg-primary { background-color: #c41e3a !important; } [data-theme="christmas"] .bg-primary:hover, [data-theme="christmas"] button.bg-primary:hover { background-color: #a01729 !important; } [data-theme="christmas"] .text-primary { color: #c41e3a !important; } /* Midnight Theme - Deep Purple Dreams */ [data-theme="midnight"] body { background: linear-gradient(135deg, #0a0a0f 0%, #1a0f2e 100%) !important; color: #e9d5ff !important; } [data-theme="midnight"] .bg-white, [data-theme="midnight"] header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%) !important; border-color: #4c1d95 !important; box-shadow: 0 0 20px rgba(139, 92, 246, 0.15) !important; } [data-theme="midnight"] .bg-gray-50 { background-color: #1e1e2e !important; } [data-theme="midnight"] .text-gray-900 { color: #e9d5ff !important; } [data-theme="midnight"] .text-gray-600, [data-theme="midnight"] .text-gray-700 { color: #c4b5fd !important; } [data-theme="midnight"] .border-gray-200 { border-color: #4c1d95 !important; } [data-theme="midnight"] .bg-primary, [data-theme="midnight"] button.bg-primary { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; box-shadow: 0 0 15px rgba(139, 92, 246, 0.5) !important; } [data-theme="midnight"] .bg-primary:hover { background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%) !important; box-shadow: 0 0 25px rgba(139, 92, 246, 0.7) !important; } /* Midnight theme glow effects */ [data-theme="midnight"] h1, [data-theme="midnight"] h2, [data-theme="midnight"] h3 { text-shadow: 0 0 10px rgba(139, 92, 246, 0.3); } /* Midnight Theme - Calendar Events */ [data-theme="midnight"] .bg-blue-50 { background-color: #1e1e2e !important; } [data-theme="midnight"] .bg-blue-100 { background-color: #2a2540 !important; } [data-theme="midnight"] .text-blue-700 { color: #c4b5fd !important; } [data-theme="midnight"] .hover\:bg-blue-200:hover { background-color: #3a3550 !important; } /* Ocean Theme */ [data-theme="ocean"] body { background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%) !important; color: #0c4a6e !important; } [data-theme="ocean"] .bg-white, [data-theme="ocean"] header { background-color: #ffffff !important; border-color: #bae6fd !important; } [data-theme="ocean"] .text-gray-900 { color: #0c4a6e !important; } [data-theme="ocean"] .text-gray-600, [data-theme="ocean"] .text-gray-700 { color: #475569 !important; } [data-theme="ocean"] .bg-primary, [data-theme="ocean"] button.bg-primary { background-color: #0891b2 !important; } [data-theme="ocean"] .bg-primary:hover { background-color: #06b6d4 !important; } /* Christmas Theme - Add festive touches */ [data-theme="christmas"] .logo, [data-theme="christmas"] img[alt="SubTrackr"] { filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.3)); } /* Christmas Theme - Festive buttons */ [data-theme="christmas"] button, [data-theme="christmas"] .btn { transition: all 0.3s ease; } [data-theme="christmas"] button:hover, [data-theme="christmas"] .btn:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(196, 30, 58, 0.2); } /* Christmas Theme - Add gold sparkle to primary actions */ [data-theme="christmas"] .btn-primary::after { content: '✨'; margin-left: 0.5rem; opacity: 0; transition: opacity 0.3s ease; } [data-theme="christmas"] .btn-primary:hover::after { opacity: 1; } /* Snowfall Animation */ @keyframes snowfall { 0% { transform: translateY(0) rotate(0deg); } 100% { transform: translateY(110vh) rotate(360deg); } } .snowflake { color: #ffffff; text-shadow: 0 0 5px #ffffff, 0 0 10px #e0f2fe, 0 0 15px #bae6fd; } /* Christmas Theme - Festive card decorations */ [data-theme="christmas"] .card, [data-theme="christmas"] .bg-white { border-left: 3px solid var(--theme-special-accent, #ffd700); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), -3px 0 0 rgba(255, 215, 0, 0.3); } /* Christmas Theme - Add holly decorations to headers */ [data-theme="christmas"] h1::before, [data-theme="christmas"] h2::before { content: '🎄 '; margin-right: 0.5rem; } [data-theme="christmas"] h1::after, [data-theme="christmas"] h2::after { content: ' 🎄'; margin-left: 0.5rem; } /* Ocean Theme - Wavy effect on hover */ [data-theme="ocean"] .card:hover, [data-theme="ocean"] .bg-white:hover { box-shadow: 0 4px 6px -1px rgba(8, 145, 178, 0.1), 0 2px 4px -1px rgba(8, 145, 178, 0.06); } /* Midnight Theme - Glow effects */ [data-theme="midnight"] button:focus, [data-theme="midnight"] .btn:focus { box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3), 0 0 12px rgba(139, 92, 246, 0.4); } /* Theme transition */ * { transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; } /* Reduce motion for users who prefer it */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } .snowflake { display: none; } } /* Theme selector styles */ .theme-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; } .theme-option { padding: 1rem; border: 2px solid var(--theme-border); border-radius: 0.5rem; cursor: pointer; transition: all 0.2s ease; background: var(--theme-surface); } .theme-option:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } .theme-option.active { border-color: var(--theme-primary); background: var(--theme-primary); color: white; } .theme-preview { display: flex; gap: 0.5rem; margin-top: 0.5rem; height: 40px; } .theme-preview-color { flex: 1; border-radius: 0.25rem; } .theme-name { font-weight: 600; margin-bottom: 0.25rem; } .theme-description { font-size: 0.875rem; opacity: 0.8; } ================================================ FILE: web/static/js/darkmode.js ================================================ // Enhanced Dark Mode Management for SubTrackr class DarkModeManager { constructor() { this.init(); } init() { // Check system preference first, then saved preference const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const savedPreference = localStorage.getItem('darkMode'); const shouldBeDark = savedPreference ? savedPreference === 'true' : systemPrefersDark; this.setDarkMode(shouldBeDark, false); // Don't save on init this.setupSystemPreferenceListener(); } setDarkMode(enabled, save = true) { document.documentElement.classList.toggle('dark', enabled); if (save) { localStorage.setItem('darkMode', enabled.toString()); this.syncWithServer(enabled); } // Update toggle switch to match current state (if it exists on current page) const toggle = document.querySelector('input[hx-post="/api/settings/dark-mode"]'); if (toggle) { toggle.checked = enabled; } } toggle() { const isDark = document.documentElement.classList.contains('dark'); this.setDarkMode(!isDark); } syncWithServer(enabled) { fetch('/api/settings/dark-mode', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `enabled=${enabled}` }).catch(err => console.log('Failed to sync dark mode with server:', err)); } setupSystemPreferenceListener() { window.matchMedia('(prefers-color-scheme: dark)') .addEventListener('change', (e) => { // Only auto-switch if user hasn't set a manual preference if (!localStorage.getItem('darkMode')) { this.setDarkMode(e.matches, false); } }); } } // Global dark mode manager let darkModeManager; // Legacy toggle function for backward compatibility function toggleDarkMode() { if (darkModeManager) { darkModeManager.toggle(); } } // Initialize on DOM ready document.addEventListener('DOMContentLoaded', function() { darkModeManager = new DarkModeManager(); }); ================================================ FILE: web/static/js/mobile-menu.js ================================================ // Mobile menu functions for responsive navigation // Used across all page templates to provide consistent mobile menu behavior function openMobileMenu() { const mobileMenu = document.getElementById('mobile-menu'); if (mobileMenu) { mobileMenu.classList.remove('hidden'); document.body.style.overflow = 'hidden'; // Prevent body scroll when menu is open } } function closeMobileMenu() { const mobileMenu = document.getElementById('mobile-menu'); if (mobileMenu) { mobileMenu.classList.add('hidden'); document.body.style.overflow = ''; // Restore body scroll } } // Close mobile menu and execute callback after menu is closed // Uses requestAnimationFrame to ensure DOM updates are processed function closeMobileMenuAndThen(callback) { closeMobileMenu(); // Use double requestAnimationFrame to ensure browser has processed the DOM changes // This is more reliable than setTimeout and adapts to browser rendering speed requestAnimationFrame(() => { requestAnimationFrame(() => { if (callback) callback(); }); }); } // Initialize mobile menu functionality when DOM is ready document.addEventListener('DOMContentLoaded', function() { // Restore body scroll on page load (handles navigation before closeMobileMenu completes) document.body.style.overflow = ''; // Open mobile menu when hamburger button is clicked const mobileMenuButton = document.getElementById('mobile-menu-button'); if (mobileMenuButton) { mobileMenuButton.addEventListener('click', openMobileMenu); } // Close mobile menu on escape key // Close only the topmost element (modal first, then menu) document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { const modal = document.getElementById('modal'); const mobileMenu = document.getElementById('mobile-menu'); // If modal is open, close it (modal is topmost) if (modal && !modal.classList.contains('hidden')) { modal.classList.add('hidden'); } // Otherwise, if mobile menu is open, close it else if (mobileMenu && !mobileMenu.classList.contains('hidden')) { closeMobileMenu(); } } }); }); ================================================ FILE: web/static/js/sorting.js ================================================ // SubTrackr Sort Preference Persistence // Saves and restores user's sort preference using localStorage const SORT_STORAGE_KEY = 'subtrackr-sort'; const VALID_SORT_FIELDS = ['name', 'cost', 'renewal_date', 'status', 'category', 'schedule', 'created_at']; const VALID_SORT_ORDERS = ['asc', 'desc']; // Validate sort parameters function isValidSortPreference(sortBy, order) { return VALID_SORT_FIELDS.includes(sortBy) && VALID_SORT_ORDERS.includes(order); } // Save sort preference to localStorage function saveSortPreference(sortBy, order) { if (!isValidSortPreference(sortBy, order)) return; const preference = { sortBy, order }; localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(preference)); } // Get saved sort preference function getSortPreference() { const stored = localStorage.getItem(SORT_STORAGE_KEY); if (stored) { try { return JSON.parse(stored); } catch (e) { console.error('Failed to parse sort preference:', e); return null; } } return null; } // Extract sort params from URL function extractSortParams(url) { try { const urlObj = new URL(url, window.location.origin); const sortBy = urlObj.searchParams.get('sort'); const order = urlObj.searchParams.get('order'); if (sortBy && order) { return { sortBy, order }; } } catch (e) { console.error('Failed to extract sort params:', e); } return null; } // Apply saved sort preference on page load function applySavedSortPreference() { const preference = getSortPreference(); if (!preference) return; const subscriptionList = document.getElementById('subscription-list'); if (!subscriptionList) return; // Check if we're on the subscriptions page and not already sorted const currentUrl = new URL(window.location.href); const currentSort = currentUrl.searchParams.get('sort'); // Validate preference before using if (!isValidSortPreference(preference.sortBy, preference.order)) return; // Only apply if no sort is currently specified in URL if (!currentSort && typeof htmx !== 'undefined') { // Trigger HTMX request with saved sort preference const sortUrl = `/api/subscriptions?sort=${encodeURIComponent(preference.sortBy)}&order=${encodeURIComponent(preference.order)}`; htmx.ajax('GET', sortUrl, { target: '#subscription-list', swap: 'outerHTML' }); } } // Listen for HTMX requests to capture sort changes document.addEventListener('htmx:configRequest', function(event) { const path = event.detail.path; // Check if this is a sort request to subscriptions API if (path && path.includes('/api/subscriptions')) { const params = extractSortParams(path); if (params) { saveSortPreference(params.sortBy, params.order); } } }); // Initialize on page load document.addEventListener('DOMContentLoaded', function() { // Apply saved sort preference once HTMX is ready if (typeof htmx !== 'undefined') { applySavedSortPreference(); } }); ================================================ FILE: web/static/js/theme-init.js ================================================ // Theme initialization - runs immediately to prevent flash (function() { const theme = localStorage.getItem('subtrackr-theme') || 'dark-classic'; document.documentElement.setAttribute('data-theme', theme); // Handle Tailwind dark mode for dark-classic theme if (theme === 'dark-classic') { document.documentElement.classList.add('dark'); } })(); ================================================ FILE: web/static/js/themes.js ================================================ // SubTrackr Theme System const themes = { default: { name: 'Default', description: 'Clean and professional', colors: { primary: '#3b82f6', primaryHover: '#2563eb', secondary: '#64748b', success: '#10b981', warning: '#f59e0b', danger: '#ef4444', background: '#f9fafb', surface: '#ffffff', surfaceHover: '#f3f4f6', text: '#111827', textSecondary: '#6b7280', border: '#e5e7eb', } }, dark: { name: 'Dark', description: 'Easy on the eyes', colors: { primary: '#3b82f6', primaryHover: '#60a5fa', secondary: '#64748b', success: '#10b981', warning: '#f59e0b', danger: '#ef4444', background: '#111827', surface: '#1f2937', surfaceHover: '#374151', text: '#f9fafb', textSecondary: '#9ca3af', border: '#374151', } }, 'dark-classic': { name: 'Dark Classic', description: 'Original dark mode', useTailwindDark: true, // Special flag to use Tailwind's dark mode colors: { primary: '#3b82f6', primaryHover: '#60a5fa', secondary: '#64748b', success: '#10b981', warning: '#f59e0b', danger: '#ef4444', background: '#111827', surface: '#1f2937', surfaceHover: '#374151', text: '#f9fafb', textSecondary: '#9ca3af', border: '#374151', } }, christmas: { name: 'Christmas', description: 'Festive and jolly! 🎄', colors: { primary: '#c41e3a', // Christmas red primaryHover: '#a01729', secondary: '#165b33', // Forest green success: '#10b981', warning: '#ffd700', // Gold danger: '#ef4444', background: '#fef3f3', // Light snow white with hint of red surface: '#ffffff', surfaceHover: '#fef2f2', text: '#1f2937', textSecondary: '#6b7280', border: '#fecaca', // Light red border }, special: { accent: '#ffd700', // Gold accents snow: '#ffffff', holly: '#165b33', berry: '#c41e3a' } }, midnight: { name: 'Midnight', description: 'Deep and mysterious', colors: { primary: '#8b5cf6', // Purple primaryHover: '#7c3aed', secondary: '#64748b', success: '#10b981', warning: '#f59e0b', danger: '#ef4444', background: '#0f172a', // Very dark blue surface: '#1e293b', surfaceHover: '#334155', text: '#f1f5f9', textSecondary: '#94a3b8', border: '#334155', } }, ocean: { name: 'Ocean', description: 'Cool and refreshing', colors: { primary: '#0891b2', // Cyan primaryHover: '#06b6d4', secondary: '#64748b', success: '#10b981', warning: '#f59e0b', danger: '#ef4444', background: '#f0f9ff', // Light cyan surface: '#ffffff', surfaceHover: '#e0f2fe', text: '#0c4a6e', textSecondary: '#475569', border: '#bae6fd', } } }; // Apply theme to document function applyTheme(themeName) { const theme = themes[themeName] || themes['dark-classic']; const root = document.documentElement; // Set theme data attribute root.setAttribute('data-theme', themeName); // Handle Tailwind dark mode for dark-classic theme if (theme.useTailwindDark) { root.classList.add('dark'); } else { root.classList.remove('dark'); } // Apply CSS variables Object.entries(theme.colors).forEach(([key, value]) => { root.style.setProperty(`--theme-${key}`, value); }); // Apply special properties for Christmas theme if (themeName === 'christmas' && theme.special) { Object.entries(theme.special).forEach(([key, value]) => { root.style.setProperty(`--theme-special-${key}`, value); }); // Enable snow animation enableSnowfall(); } else { // Disable snow animation for other themes disableSnowfall(); } // Save theme preference to localStorage AND server localStorage.setItem('subtrackr-theme', themeName); saveThemePreference(themeName); } // Save theme preference to server function saveThemePreference(themeName) { fetch('/api/settings/theme', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ theme: themeName }) }) .catch(err => console.error('Failed to save theme:', err)); } // Get theme from localStorage or server function getStoredTheme() { // First check localStorage for instant access const localTheme = localStorage.getItem('subtrackr-theme'); if (localTheme) { return Promise.resolve(localTheme); } // Fall back to server return fetch('/api/settings/theme') .then(response => response.json()) .then(data => { const theme = data.theme || 'dark-classic'; localStorage.setItem('subtrackr-theme', theme); return theme; }) .catch(err => { console.error('Failed to load theme:', err); return 'dark-classic'; }); } // Load saved theme on page load function loadSavedTheme() { getStoredTheme().then(themeName => { applyTheme(themeName); }); } // Snowfall animation for Christmas theme function enableSnowfall() { // Remove existing snowflakes disableSnowfall(); const snowContainer = document.createElement('div'); snowContainer.id = 'snowfall-container'; snowContainer.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999; overflow: hidden; `; // Create snowflakes for (let i = 0; i < 50; i++) { createSnowflake(snowContainer); } document.body.appendChild(snowContainer); } function createSnowflake(container) { const snowflake = document.createElement('div'); snowflake.className = 'snowflake'; snowflake.innerHTML = '❄'; // Random properties const size = Math.random() * 0.5 + 0.5; // 0.5 to 1em const left = Math.random() * 100; // 0 to 100% const animationDuration = Math.random() * 3 + 2; // 2 to 5 seconds const opacity = Math.random() * 0.5 + 0.3; // 0.3 to 0.8 const delay = Math.random() * 5; // 0 to 5 seconds delay snowflake.style.cssText = ` position: absolute; top: -10%; left: ${left}%; font-size: ${size}em; opacity: ${opacity}; animation: snowfall ${animationDuration}s linear ${delay}s infinite; user-select: none; `; container.appendChild(snowflake); } function disableSnowfall() { const snowContainer = document.getElementById('snowfall-container'); if (snowContainer) { snowContainer.remove(); } } // Initialize on page load document.addEventListener('DOMContentLoaded', () => { loadSavedTheme(); }); ================================================ FILE: web/static/manifest.json ================================================ { "name": "SubTrackr", "short_name": "SubTrackr", "description": "Track and manage your subscriptions", "start_url": "/", "display": "standalone", "background_color": "#111827", "theme_color": "#37889b", "icons": [ { "src": "/static/images/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/static/images/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" } ] }